Fakes in Unit Testing: A Deep Dive

Fakes in Unit Testing: A Deep Dive

Of all the test double types, fakes are the most underused and the most powerful. A fake has working logic—real behavior—just implemented in a way that's unsuitable for production. When done well, a fake can make your test suite faster, more readable, and more resilient to refactoring than any mock ever could.

This deep dive covers what fakes are, when to use them, how to build them, and the pitfalls to avoid.


What Makes a Fake Different

Other test doubles are passive:

  • Dummies do nothing
  • Stubs return canned responses
  • Mocks verify calls

A fake actively processes logic. It's a simplified but functional implementation.

The canonical example is an in-memory repository:

// Real repository: hits PostgreSQL, requires a running DB
class PostgresUserRepository {
  async save(user) {
    await db.query("INSERT INTO users ...", [user]);
  }
  async findById(id) {
    return db.query("SELECT * FROM users WHERE id = $1", [id]);
  }
}

// Fake repository: same interface, stores in memory
class InMemoryUserRepository {
  constructor() {
    this.store = new Map();
    this.nextId = 1;
  }

  async save(user) {
    const id = user.id ?? this.nextId++;
    this.store.set(id, { ...user, id });
    return { ...user, id };
  }

  async findById(id) {
    return this.store.get(id) ?? null;
  }
}

Both implement the same interface. Tests use the fake; production uses PostgreSQL. The fake is fast, deterministic, and requires zero infrastructure.


When to Use a Fake

The dependency has significant stateful behavior

Stubs return fixed data. That's fine for simple cases. But when your code depends on state that evolves across calls—creating, updating, finding, deleting—a stub becomes unwieldy.

// Stub approach: painful for stateful scenarios
const repo = {
  save: jest.fn(),
  findById: jest.fn()
    .mockResolvedValueOnce(null)          // first call: not found
    .mockResolvedValueOnce({ id: 1 })     // second call: found
};

// Fake approach: just works
const repo = new InMemoryUserRepository();
// save/find/update just work naturally

You have many tests sharing the same dependency

Setting up a stub for every test is tedious. A shared fake class eliminates the repetition:

// beforeEach: just create a fresh fake
let repo;
beforeEach(() => {
  repo = new InMemoryUserRepository();
});

You want tests that read like real scenarios

With stubs, you configure outcomes. With a fake, you create real data and let code interact with it:

// With fake: reads like real usage
const repo = new InMemoryUserRepository();
await repo.save({ id: 1, name: "Alice", plan: "pro" });

const billingService = new BillingService(repo);
await billingService.processMonthly();

const user = await repo.findById(1);
expect(user.lastBilled).toBeDefined();

Building a Good Fake

Rule 1: Honor the contract

The fake must implement the same interface as the real dependency—including error conditions.

class InMemoryUserRepository {
  async findById(id) {
    if (typeof id !== "number") {
      throw new TypeError("id must be a number"); // same as real impl
    }
    return this.store.get(id) ?? null;
  }
}

If the real implementation throws on invalid input, the fake should too. Otherwise your tests give false confidence.

Rule 2: Test the fake itself

This sounds redundant, but it's critical. A fake that doesn't behave like the real thing gives false confidence.

// Test your fake against the same contract tests as the real implementation
describe.each([
  ["Postgres", new PostgresUserRepository()],
  ["InMemory", new InMemoryUserRepository()],
])("%s UserRepository", (name, repo) => {
  it("saves and retrieves a user", async () => {
    await repo.save({ id: 1, name: "Alice" });
    const user = await repo.findById(1);
    expect(user.name).toBe("Alice");
  });

  it("returns null for unknown id", async () => {
    const user = await repo.findById(999);
    expect(user).toBeNull();
  });
});

This is called contract testing. It ensures your fake stays in sync with the real thing.

Rule 3: Keep it in the test package

Fakes are test infrastructure. They belong in test directories:

src/
  repositories/
    user-repository.ts       # real
tests/
  fakes/
    in-memory-user-repository.ts  # fake
    in-memory-email-service.ts

Don't let fakes leak into production code.

Rule 4: Keep it simple

A fake should implement the minimum needed for your tests. Don't build a full in-memory database. Build what your tests exercise.

// Good: minimal implementation
class InMemoryEmailService {
  constructor() {
    this.sent = [];
  }

  async send(message) {
    this.sent.push(message);
  }

  getSentTo(address) {
    return this.sent.filter(m => m.to === address);
  }
}

// Then in tests:
const emails = emailService.getSentTo("alice@example.com");
expect(emails).toHaveLength(1);
expect(emails[0].subject).toBe("Welcome!");

The getSentTo() helper makes assertions ergonomic—far cleaner than mock assertion syntax.


Practical Examples

In-memory event bus

class InMemoryEventBus {
  constructor() {
    this.published = [];
    this.handlers = new Map();
  }

  async publish(event) {
    this.published.push(event);
    const handler = this.handlers.get(event.type);
    if (handler) await handler(event);
  }

  subscribe(type, handler) {
    this.handlers.set(type, handler);
  }

  getEvents(type) {
    return this.published.filter(e => e.type === type);
  }
}

In-memory cache

class InMemoryCache {
  constructor() {
    this.store = new Map();
  }

  async get(key) {
    return this.store.get(key) ?? null;
  }

  async set(key, value, ttl) {
    this.store.set(key, value);
    if (ttl) {
      setTimeout(() => this.store.delete(key), ttl * 1000);
    }
  }

  async invalidate(key) {
    this.store.delete(key);
  }
}

When NOT to Use Fakes

When a stub is simpler

Fakes have a maintenance cost. If you only need one test that returns a user, a stub is the right tool.

// Fake: overkill for a single scenario
const repo = new InMemoryUserRepository();
await repo.save({ id: 1, name: "Alice" });

// Stub: perfect
const repo = { findById: async () => ({ id: 1, name: "Alice" }) };

When you're testing interaction, not state

If the test is "did my code call the payment gateway?", a mock verifies that directly. A fake would require you to inspect the fake's state to infer whether the call happened.

When the dependency is too complex to fake accurately

If you can't faithfully reproduce the real behavior cheaply, the fake becomes a liability—false confidence wrapped in complexity. Consider contract tests against the real dependency instead.


Fakes vs. Integration Tests

Fakes trade speed for fidelity. They're fast and deterministic but don't test against real systems. Integration tests do the opposite.

The right balance:

  • Fakes for the logic layer—services, use cases, domain code
  • Integration tests for adapters—the code that talks to real databases, APIs
  • End-to-end tests for complete flows

HelpMeTest handles the end-to-end layer—plain English tests running against your real application. No fakes needed there. Fakes are your unit test tool; HelpMeTest is your full-stack verification tool.


Summary

  • A fake has real working logic, just simplified for test use
  • Use fakes when stubs are too rigid for stateful, multi-call scenarios
  • Always test your fakes with contract tests against the real interface
  • Keep fakes in test code, not production code
  • Fakes improve test readability and resilience to refactoring
  • Choose fakes over mocks when state matters more than interaction

Read more

Start now free