Skip to main content
Software Testing intermediate Lesson 2 of 3

Test Doubles & Dependency Boundaries (Intermediate)

Learn when to mock vs stub vs fake, and how to structure code so tests stay reliable.

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: fake repository)

// 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
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, type UserRepo } from "./user-service";

function createFakeRepo(): UserRepo {
  const usersByEmail = new Map<string, any>();

  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");
});

Practice

  1. Identify one function in your codebase that:
    • calls an external dependency (DB/HTTP)
    • and mixes business logic with side effects
  2. Refactor into:
    • a pure core (inputs → outputs)
    • a thin adapter that performs side effects
  3. Write tests using a fake adapter (not mocks) to keep them stable.

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?
Stub provides canned responses. Mock verifies interactions (called methods, arguments). Overusing interaction tests can make refactors painful.