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
| Level | Tests | Speed | Confidence |
|---|---|---|---|
| Unit | One function or module in isolation | Very fast | Narrow β confirms a piece works alone |
| Integration | Several real pieces working together | Moderate | Confirms the pieces connect correctly |
| End-to-end | The whole system, as a user would use it | Slow | Broadest, 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:
// 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
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).
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.
Hint 1
One of these just returns canned data with no behavior to speak of.
Hint 2
One of these is a real, working, simplified implementation.
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 -> MockPractice: 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.
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.
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.