Software Testing · lesson 2 of 8
Beginner
1Intermediate
4Advanced
3Also part of Software Testing
12- + Testing Python Code from Python
- + Unit Testing with JUnit 5 and Mockito from Java
- + Testing JavaScript with Jest and Vitest from JavaScript
- + Testing in Go from Go
- + Testing in Rust from Rust
- + Testing with MUnit and ScalaCheck from Scala
- + Contract Testing with OpenAPI, and Running in CI from API Testing
- + Your First Playwright Test from Playwright
- + Flaky Tests: Staleness, Retries, and CI from Selenium
- + Testing Data Pipelines from Data Engineering
- + terraform test: Catching a Wrong Assertion, Not Just a Wrong Plan from Terraform
- + Testing TypeScript from TypeScript
Test Doubles & Dependency Boundaries (Intermediate)
When to use a stub, a fake or a mock, shown with Jest tests against a repository interface, plus how an over-specified mock breaks on a random id.
By SkillByExample · Updated
Theory
As tests grow, the biggest risk is not missing coverage—it’s fragile tests that break during refactors.
1) Use dependency boundaries intentionally
If your core logic directly calls:
- HTTP
- databases
- message brokers
- filesystem
- time/random
…your tests become slow and flaky.
Goal: push side effects to the edges and keep the core deterministic.
2) Types of test doubles (when to use which)
- Stub: returns fixed values
- use for stable, deterministic responses
- Fake: in-memory or simplified real implementation
- use when you need behavior but can keep it local
- Mock: asserts that specific calls happened
- use sparingly for external boundaries
3) Avoid testing implementation details
Prefer assertions about behavior:
- “returns expected result”
- “persists correct state”
- “publishes event once”
Over “method X was called with argument Y” unless you truly need interaction verification.
Code Example (TypeScript/Jest: fake repository)
The examples run under Jest with ts-jest, with a jest.config.js containing
module.exports = { preset: "ts-jest", testEnvironment: "node" }; in the same directory.
The service depends only on the UserRepo interface, which is the boundary a test can
replace.
// user-repo.ts
export type User = { id: string; email: string };
export interface UserRepo {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
// user-service.ts
import type { User, UserRepo } from "./user-repo";
export async function registerUser(
repo: UserRepo,
email: string
): Promise<User> {
const existing = await repo.findByEmail(email);
if (existing) throw new Error("EMAIL_ALREADY_USED");
const user: User = { id: crypto.randomUUID(), email };
await repo.save(user);
return user;
}
// user-service.test.ts
import { registerUser } from "./user-service";
import type { User, UserRepo } from "./user-repo";
function createFakeRepo(): UserRepo {
const usersByEmail = new Map<string, User>();
return {
async findByEmail(email) {
return usersByEmail.get(email) ?? null;
},
async save(user) {
usersByEmail.set(user.email, user);
},
};
}
test("should register a new user", async () => {
const repo = createFakeRepo();
const user = await registerUser(repo, "a@example.com");
expect(user.email).toBe("a@example.com");
});
test("should reject an email that is already registered", async () => {
const repo = createFakeRepo();
await registerUser(repo, "a@example.com");
await expect(registerUser(repo, "a@example.com")).rejects.toThrow("EMAIL_ALREADY_USED");
});
$ npx jest user-service.test.ts
PASS ./user-service.test.ts
√ should register a new user (2 ms)
√ should reject an email that is already registered (7 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.914 s
Ran all test suites matching user-service.test.ts.
The second test is what makes this a fake rather than a stub. A stub’s findByEmail would
return the same canned answer every time, so the test would have to set up “already
registered” by hand. The fake remembers what save stored, so the first
registerUser call creates the state that the second call trips over, as a real database
would. Neither test checks which repository methods were called or in what order. They check
the outcome, so changing how registerUser talks to the repository will not break them as
long as the behaviour stays the same.
Code Example (TypeScript/Jest: a stub)
When a test needs only one branch, a stub with a fixed answer is enough.
// stub.test.ts
import { registerUser } from "./user-service";
import type { UserRepo } from "./user-repo";
const alwaysTakenRepo: UserRepo = {
async findByEmail(email) {
return { id: "existing-1", email };
},
async save() {},
};
test("stub: rejects when the repo says the email exists", async () => {
await expect(registerUser(alwaysTakenRepo, "b@example.com"))
.rejects.toThrow("EMAIL_ALREADY_USED");
});
$ npx jest stub.test.ts
PASS ./stub.test.ts
√ stub: rejects when the repo says the email exists (15 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.961 s
Ran all test suites matching stub.test.ts.
The stub has no memory. It gives the same canned answer for every email, so one call is enough to reach the error path. That is quicker to set up than the fake when you only need one branch, but it can’t model “register, then register again”.
Code Example (TypeScript/Jest: a mock)
A mock built from jest.fn records every call, so the test can check the interaction itself.
// mock.test.ts
import { registerUser } from "./user-service";
import type { UserRepo } from "./user-repo";
test("mock: saves the new user exactly once", async () => {
const repo: UserRepo = {
findByEmail: jest.fn(async () => null),
save: jest.fn(async () => {}),
};
await registerUser(repo, "c@example.com");
expect(repo.save).toHaveBeenCalledTimes(1);
expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({ email: "c@example.com" }),
);
});
$ npx jest mock.test.ts
PASS ./mock.test.ts
√ mock: saves the new user exactly once (6 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.858 s
Ran all test suites matching mock.test.ts.
This asserts an interaction, not an outcome: save was called once. That is worth checking
when the call is the behaviour, for example “write exactly one row” or “publish one event”.
expect.objectContaining keeps the assertion to the field the test cares about.
Code Example (TypeScript/Jest: an over-specified mock)
Now pin the whole saved object instead of just the field that matters.
// overspecified.test.ts
import { registerUser } from "./user-service";
import type { UserRepo } from "./user-repo";
test("mock: pins the exact saved object", async () => {
const repo: UserRepo = {
findByEmail: jest.fn(async () => null),
save: jest.fn(async () => {}),
};
await registerUser(repo, "d@example.com");
expect(repo.save).toHaveBeenCalledWith({ id: "user-1", email: "d@example.com" });
});
$ npx jest overspecified.test.ts
FAIL ./overspecified.test.ts
× mock: pins the exact saved object (8 ms)
● mock: pins the exact saved object
expect(jest.fn()).toHaveBeenCalledWith(...expected)
- Expected
+ Received
Object {
"email": "d@example.com",
- "id": "user-1",
+ "id": "ce01310c-4905-4b44-8e29-5ac030981f27",
},
Number of calls: 1
10 | await registerUser(repo, "d@example.com");
11 |
> 12 | expect(repo.save).toHaveBeenCalledWith({ id: "user-1", email: "d@example.com" });
| ^
13 | });
14 |
at Object.<anonymous> (overspecified.test.ts:12:21)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: 1.837 s
Ran all test suites matching overspecified.test.ts.
The id comes from crypto.randomUUID(), so it is different on every run. Your received
UUID will differ from the one above, but it will never be user-1. Randomness is one of
the boundaries listed in the theory, and this test reaches past it. The assertion encodes
a detail the test doesn’t control, so it can never pass. The Practice tasks below fix it two
ways: loosen the assertion, or control the randomness.
Practice
- Loosen the assertion. Make
overspecified.test.tspass without touchinguser-service.ts, while still checking the exact email and that the id is a string. - Make the id a boundary. Give
registerUseran optionalnewIdparameter that defaults tocrypto.randomUUID, so the exact-object assertion can pass as written. Check that the original fake tests still pass unchanged. - Stub a failure. Write a
UserRepowhosefindByEmailthrowsError("DB_DOWN"). Assert thatregisterUserrejects with that error and thatsaveis never called. - Assert through the fake. Instead of mocking
save, register a user with the fake repository and check the result by reading it back throughfindByEmail.
Solution 1
// loose.test.ts
import { registerUser } from "./user-service";
import type { UserRepo } from "./user-repo";
test("mock: any string id, exact email", async () => {
const repo: UserRepo = {
findByEmail: jest.fn(async () => null),
save: jest.fn(async () => {}),
};
await registerUser(repo, "d@example.com");
expect(repo.save).toHaveBeenCalledWith({ id: expect.any(String), email: "d@example.com" });
});
$ npx jest loose.test.ts
PASS ./loose.test.ts
√ mock: any string id, exact email (9 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.795 s
Ran all test suites matching loose.test.ts.
Only the last line differs from overspecified.test.ts. expect.any(String) matches
whatever UUID was generated, and email is still checked exactly. Use this when the id’s
value doesn’t matter to the behaviour under test.
Solution 2
// user-service.ts
import type { User, UserRepo } from "./user-repo";
export async function registerUser(
repo: UserRepo,
email: string,
newId: () => string = () => crypto.randomUUID(),
): Promise<User> {
const existing = await repo.findByEmail(email);
if (existing) throw new Error("EMAIL_ALREADY_USED");
const user: User = { id: newId(), email };
await repo.save(user);
return user;
}
In overspecified.test.ts, pass the generator as the third argument:
await registerUser(repo, "d@example.com", () => "user-1");
$ npx jest overspecified.test.ts user-service.test.ts
PASS ./overspecified.test.ts
PASS ./user-service.test.ts
Test Suites: 2 passed, 2 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 6.833 s
Ran all test suites matching overspecified.test.ts|user-service.test.ts.
The exact-object assertion now passes because the test chooses the id. The two fake-repo
tests pass without edits because production callers, and those tests, get the
randomUUID default. This is the same move as the injected clock: a source of
non-determinism becomes a parameter at the edge.
Solution 3
// db-down.test.ts
import { registerUser } from "./user-service";
import type { UserRepo } from "./user-repo";
test("a failing lookup is passed on and nothing is saved", async () => {
const save = jest.fn(async () => {});
const repo: UserRepo = {
findByEmail: async () => {
throw new Error("DB_DOWN");
},
save,
};
await expect(registerUser(repo, "e@example.com")).rejects.toThrow("DB_DOWN");
expect(save).not.toHaveBeenCalled();
});
$ npx jest db-down.test.ts
PASS ./db-down.test.ts
√ a failing lookup is passed on and nothing is saved (12 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.374 s
Ran all test suites matching db-down.test.ts.
A stub that throws is the cheapest way to test an outage you could never trigger reliably
against a real database. The not.toHaveBeenCalled check is one place where an interaction
assertion is the behaviour: no half-registered user may be written.
Solution 4
// fake-state.test.ts
import { registerUser } from "./user-service";
import type { User, UserRepo } from "./user-repo";
function createFakeRepo(): UserRepo {
const usersByEmail = new Map<string, User>();
return {
async findByEmail(email) {
return usersByEmail.get(email) ?? null;
},
async save(user) {
usersByEmail.set(user.email, user);
},
};
}
test("the registered user can be found afterwards", async () => {
const repo = createFakeRepo();
const user = await registerUser(repo, "f@example.com");
expect(await repo.findByEmail("f@example.com")).toEqual(user);
});
$ npx jest fake-state.test.ts
PASS ./fake-state.test.ts
√ the registered user can be found afterwards (2 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.378 s
Ran all test suites matching fake-state.test.ts.
This checks the same thing as the mock example, that the user was saved, but through state
rather than calls. The test reads the result the way a caller would, so it doesn’t name
save at all, and it has no opinion on how many repository calls registerUser made to get
there.
Common pitfalls
- Mocking time/randomness without controlling it
- Using mocks to “freeze behavior” instead of verifying outcomes
- Letting mocks encode implementation details that change often