IntermediateTesting and maintainability~40 min

Testing and Maintainability

The test pyramid as a heuristic, test doubles and when a fake beats a mock, keeping tests deterministic, and designing code around cohesion and dependency inversion so it stays testable.

By the end of this lesson, you can

  • Explain the test pyramid as a heuristic about cost and confidence, not a strict ratio to enforce
  • Choose between a stub, a mock, and a fake for a given test double, and explain the trade-off
  • Identify and remove non-determinism (clocks, randomness, network, filesystem) from a unit test
  • Apply dependency inversion to make a tightly coupled function testable without real I/O

Why it matters

Modules, Tooling, and First Tests wrote unit tests for pure functions in isolation. Most real code isn’t purely functional β€” it calls a network, reads a clock, touches a file β€” and testing it well requires deliberate choices about what to fake, what to keep deterministic, and how to design the code itself so those choices are even possible.

The test pyramid, as a heuristic

More tests at the bottom, fewer at the top β€” a heuristic, not a strict ratio
LevelTestsSpeedConfidence
UnitOne function or module in isolationVery fastNarrow β€” confirms a piece works alone
IntegrationSeveral real pieces working togetherModerateConfirms the pieces connect correctly
End-to-endThe whole system, as a user would use itSlowBroadest, but slowest and most brittle

Contract tests β€” verifying that a producer and consumer of an API agree on its shape β€” and component tests β€” testing one UI component’s rendering/behavior in isolation, more integrated than a pure-function unit test but far cheaper than a full end-to-end run β€” both sit usefully between the three main levels above.

Test doubles: stub, mock, and fake

A test double replaces a real dependency for testing. The three most common kinds serve different purposes:

// Stub β€” returns canned data, doesn't care how it's called
const stubApi = { getUser: () => ({ id: 1, name: 'Ada' }) };

// Mock β€” asserts on HOW it was called, not just what it returns
const mockEmailer = { send: vi.fn() };
await notifyUser(mockEmailer);
expect(mockEmailer.send).toHaveBeenCalledWith('[email protected]', 'Welcome!');

// Fake β€” a real, working, simplified implementation
class FakeDatabase {
  #records = new Map();
  save(id, record) {
    this.#records.set(id, record);
  }
  find(id) {
    return this.#records.get(id);
  }
}

Determinism: removing hidden non-determinism

A test that reads the real clock, generates real randomness, or hits a real network is non-deterministic β€” it can pass or fail depending on factors the test itself doesn’t control.

// Non-deterministic β€” depends on the real clock and Math.random
function isExpired(timestamp) {
  return Date.now() - timestamp > 3600_000;
}

// Testable β€” the clock is passed in explicitly, defaulting to the real one in production
function isExpiredAt(timestamp, now = Date.now()) {
  return now - timestamp > 3600_000;
}

// Test: no waiting, no flakiness, exact control over "now"
expect(isExpiredAt(1000, 1000 + 3600_001)).toBe(true);

The four classic sources of test non-determinism are the clock, randomness, the network, and the filesystem β€” each fixed the same way: make the dependency an explicit parameter (or an injected object) rather than reaching for the global/ambient version directly inside the function.

Cohesion, coupling, and dependency inversion

Coupling is how much one piece of code depends on the specific details of another; cohesion is how focused a single piece of code is on one responsibility. Tightly coupled code is hard to test in isolation, since testing it drags its dependencies along:

JavaScriptDependency inversion β€” passing a dependency in, instead of reaching out to it directly
// Tightly coupled β€” reaches directly for fetch, hard to test without a real network
async function getUserName(userId) {
  const response = await fetch(`/api/users/${userId}`);
  const user = await response.json();
  return user.name;
}

// Inverted β€” the fetching mechanism is a parameter, defaulting to the real one
async function getUserNameOf(userId, fetchUser = defaultFetchUser) {
  const user = await fetchUser(userId);
  return user.name;
}

// Test: no real network needed at all
const fakeFetchUser = async (id) => ({ id, name: 'Ada' });
expect(await getUserNameOf(1, fakeFetchUser)).toBe('Ada');

Dependency inversion β€” depending on a passed-in interface rather than a concrete, hard-coded implementation β€” is what makes this substitution possible. It’s the same principle behind the determinism fix above: the clock, fetch, the database, all become parameters instead of ambient globals reached for directly.

Check your understanding

A test asserts that emailer.send was called exactly once, with a specific address and subject β€” but never checks what send actually does with that data. What kind of test double is emailer here?
Or reveal the answer without checking

Answer:A mock
Asserting on how a dependency was called (arguments, call count) β€” rather than exercising its actual behavior β€” is the defining trait of a mock, as distinct from a stub (just returns data) or a fake (a real, simplified working implementation).

A function directly calls Date.now() and Math.random() internally. What's the first step toward making it unit-testable without flakiness?
Or reveal the answer without checking

Answer:Make the clock and randomness explicit parameters (or injected dependencies) instead of reaching for the global versions directly
Removing hidden non-determinism means turning ambient globals (Date.now(), Math.random()) into explicit, injectable parameters β€” the same dependency-inversion technique used for a database or network call β€” so a test can supply fixed, known values.

Practice: warm-up

For each test double description, name it: (1) returns a fixed list of three users, regardless of input, (2) an in-memory array standing in for a real database, with working add/remove/find methods, (3) records that logError was called exactly twice.

Stuck? Reveal one hint at a time.

  1. Hint 1

    One of these just returns canned data with no behavior to speak of.

  2. Hint 2

    One of these is a real, working, simplified implementation.

  3. Hint 3

    One of these specifically checks how many times something was called.

Reveal the answers

Try the problem yourself before reading this. There is often more than one reasonable approach β€” treat this as one worked example, not the only correct answer.

1. Fixed list of users, regardless of input   -> Stub
2. Working in-memory add/remove/find          -> Fake
3. Asserts logError was called exactly twice  -> Mock

Practice: apply it

Refactor this function to remove its hidden non-determinism (the real network call and the real clock), so it can be unit-tested without either.

async function isSubscriptionActive(userId) {
  const response = await fetch(`/api/subscriptions/${userId}`);
  const subscription = await response.json();
  return subscription.expiresAt > Date.now();
}

Stuck? Reveal one hint at a time.

  1. Hint 1

    Both fetch and Date.now() are ambient globals reached for directly β€” both are exactly the kind of hidden dependency this lesson's determinism section fixes.

  2. Hint 2

    Add both as parameters with sensible real-world defaults, so calling code doesn't need to change, but a test can supply fakes for both.

Reveal a reference solution

Try the problem yourself before reading this. There is often more than one reasonable approach β€” treat this as one worked example, not the only correct answer.

async function isSubscriptionActive(
  userId,
  fetchSubscription = (id) => fetch(`/api/subscriptions/${id}`).then((r) => r.json()),
  now = Date.now(),
) {
  const subscription = await fetchSubscription(userId);
  return subscription.expiresAt > now;
}

// Test β€” no real network, no real clock:
const fakeFetch = async () => ({ expiresAt: 2000 });
expect(await isSubscriptionActive(1, fakeFetch, 1000)).toBe(true);

Summary

  • The test pyramid (more unit tests, fewer integration, fewer still end-to-end) is a heuristic about cost versus confidence β€” not a ratio to enforce mechanically.
  • A stub returns fixed data; a mock asserts on how it was called; a fake is a real, simplified working implementation β€” often clearer than a mock beyond a single interaction.
  • The clock, randomness, the network, and the filesystem are the classic sources of test non-determinism β€” fixed by making each an explicit, injectable parameter rather than an ambient global.
  • Tightly coupled code (reaching directly for fetch, Date.now(), a database) is hard to test in isolation; dependency inversion β€” depending on a passed-in interface instead β€” is what makes substitution with a test double possible.
  • A characterization test records existing behavior as a safety net before refactoring, without asserting that behavior is correct.