Skip to main content
Software Testing · lesson 2 of 8
Software Testing intermediate Lesson 2 of 8

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

  1. Loosen the assertion. Make overspecified.test.ts pass without touching user-service.ts, while still checking the exact email and that the id is a string.
  2. Make the id a boundary. Give registerUser an optional newId parameter that defaults to crypto.randomUUID, so the exact-object assertion can pass as written. Check that the original fake tests still pass unchanged.
  3. Stub a failure. Write a UserRepo whose findByEmail throws Error("DB_DOWN"). Assert that registerUser rejects with that error and that save is never called.
  4. Assert through the fake. Instead of mocking save, register a user with the fake repository and check the result by reading it back through findByEmail.
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

Frequently Asked Questions

Should I mock everything?
No. Mock only the boundaries you can’t reliably control (external services, time, random). For pure logic, test real functions; for stateful behavior, use fakes or test containers.
What’s the difference between a stub and a mock?
A stub provides canned responses so the code under test can run. A mock records how it was called so the test can verify interactions, such as which method was called and with what arguments. Overusing interaction checks makes refactors painful, because the tests encode how the code works rather than what it produces.
What is a fake in testing?
A fake is a simplified working implementation of a dependency, such as an in-memory map standing in for a database repository. Unlike a stub it keeps state, so a test can save something and then read it back. That makes fakes a good fit for behaviour that depends on earlier calls, like rejecting a duplicate email.
Why does my Jest mock assertion fail on a random id?
If the code generates ids with something like crypto.randomUUID, the value changes on every run, so toHaveBeenCalledWith with an exact id can never match. Either loosen the assertion with expect.any(String), or pass the id generator in as a parameter so the test can supply a fixed one.