DEV Community

Watchmaker
Watchmaker

Posted on

Test Doubles: Dummy, Stub, Spy, Mock and Fake

Let's talk about test doubles. Pretty much all of us use test doubles, but sometimes the differences between their types are small. And these nuances matter, depending on what we want to achieve.

We can start with what they are and what they are not:

  • Test doubles are stand-ins for real dependencies in a test.
  • They aren't types of tests (like unit, integration, or e2e).
  • The terms come from Gerard Meszaros's xUnit Test Patterns.
  • They differ in two ways: how much behavior the double has, and whether you assert against it.

The example

Every section below tests the same SignupService. It depends on a UserRepo, an EmailSender and a Logger. Only the double changes from one section to the next.

type User = { email: string };

interface UserRepo {
  save(u: User): Promise<void>;
  findByEmail(e: string): Promise<User | null>;
}
interface EmailSender {
  send(to: string, body: string): Promise<void>;
}
interface Logger {
  log(msg: string): void;
}

class SignupService {
  constructor(
    private repo: UserRepo,
    private email: EmailSender,
    private logger: Logger,
  ) {}

  async signup(email: string): Promise<User> {
    if (await this.repo.findByEmail(email)) {
      throw new Error("User already exists");
    }

    const user = { email };
    await this.repo.save(user);

    try {
      await this.email.send(email, "Welcome!");
    } catch {
      this.logger.log(`Welcome email failed for ${email}`); // only used on this path
    }

    return user;
  }
}
Enter fullscreen mode Exit fullscreen mode

In each test, the dependencies that aren't the focus are kept as minimal inline objects, so the double being described stands out.

Dummy

A dummy is passed in only to satisfy a parameter list and is never actually used. If the code touches it, that's arguably a bug in your test.

The constructor requires a Logger, but the logger is only used when the welcome email fails. On the happy path it's never called, so an empty object is enough.

it("signs up a new user", async () => {
  const repo: UserRepo = {
    findByEmail: async () => null,
    save: async () => {},
  };
  const email: EmailSender = { send: async () => {} };
  const dummyLogger = {} as Logger; // required by the constructor, never used here

  const service = new SignupService(repo, email, dummyLogger);

  const userEmail = "::userEmail::";
  await expect(service.signup(userEmail)).resolves.toEqual({
    email: userEmail,
  });
});
Enter fullscreen mode Exit fullscreen mode

Stub

A stub returns canned answers so the code under test can take a specific path. You don't assert on the stub itself. It exists to control indirect inputs.

Here the repo stub pretends the email is already registered, forcing the "already exists" branch.

it("rejects an email that is already registered", async () => {
  const userEmailTaken = "::userEmailTaken::";

  const repoStub: UserRepo = {
    findByEmail: async () => ({ email: userEmailTaken }), // canned answer: user exists
    save: async () => {},
  };

  const service = new SignupService(
    repoStub,
    { send: async () => {} },
    {} as Logger,
  );

  await expect(service.signup(userEmailTaken)).rejects.toThrow(
    "already exists",
  );
});
Enter fullscreen mode Exit fullscreen mode

Spy

A spy records how it was called so you can check afterward. It's often a stub that also takes notes, and it verifies indirect outputs. Assertions happen after the fact, in your test.

Here the email spy records every message, and the test checks the welcome email went out.

it("sends a welcome email", async () => {
  const sent: Array<{ to: string; body: string }> = [];
  const emailSpy: EmailSender = {
    send: async (to, body) => {
      sent.push({ to, body });
    },
  };
  const repo: UserRepo = {
    findByEmail: async () => null,
    save: async () => {},
  };

  const service = new SignupService(repo, emailSpy, {} as Logger);

  const userEmail = "::userEmail::";
  await service.signup(userEmail);

  expect(sent).toEqual([{ to: userEmail, body: "Welcome!" }]);
});
Enter fullscreen mode Exit fullscreen mode

Jest's jest.fn() / jest.spyOn() and Vitest's vi.fn() are spies in this sense, despite the "mock" naming.

Mock

A mock is pre-programmed with expectations and verifies itself: "I expect send to be called once with X". It fails if reality doesn't match. The difference from a spy is subtle. A spy is inspected afterward by the test. A mock carries the expectations and does the verification. Strict mocking libraries (Sinon's .mock(), Java's Mockito in strict mode, Python's Mock with assert_called_once_with) are closest to this.

Same check as the spy, but the expectation is declared up front and the mock verifies it.

it("sends exactly one welcome email", async () => {
  const email: EmailSender = { send: async () => {} };
  const emailMock = sinon.mock(email);

  const userEmail = "::userEmail::";
  emailMock.expects("send").once().withArgs(userEmail, "Welcome!").resolves();

  const repo: UserRepo = {
    findByEmail: async () => null,
    save: async () => {},
  };
  const service = new SignupService(repo, email, {} as Logger);
  await service.signup(userEmail);

  emailMock.verify(); // fails if send wasn't called exactly once with those args
});
Enter fullscreen mode Exit fullscreen mode

Mocks lead to behavior verification (did it call the right things?). Stubs and fakes lead to state verification (is the end result right?).

Fake

A fake is a working, simplified implementation that is unsuitable for production. It has real logic, just a shortcut version, like an in-memory DB or a fake clock.

class InMemoryUserRepo implements UserRepo {
  private users = new Map<string, User>();

  async save(u: User) {
    this.users.set(u.email, u);
  }

  async findByEmail(e: string) {
    return this.users.get(e) ?? null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Because the fake keeps state, one test can cover a realistic flow (signing up and then signing up again) without scripting each return value, as the stub had to.

it("lets a user sign up once, then rejects the duplicate", async () => {
  const repoFake = new InMemoryUserRepo();
  const service = new SignupService(
    repoFake,
    { send: async () => {} },
    {} as Logger,
  );

  const userEmail = "::userEmail::";
  await service.signup(userEmail);

  await expect(service.signup(userEmail)).rejects.toThrow("already exists");
  expect(await repoFake.findByEmail(userEmail)).toEqual({ email: userEmail });
});
Enter fullscreen mode Exit fullscreen mode

Quick comparison

Double In the example Has behavior? You assert on it? Purpose
Dummy Logger No No Fill a parameter
Stub UserRepo Canned responses No Control inputs
Spy EmailSender Canned + records calls Yes, afterward Check outputs
Mock EmailSender Pre-set expectations Self-verifies Check interactions
Fake UserRepo Real but simplified No (assert on results) Realistic lightweight dependency

Practical notes

  • In everyday speech, people call all of these "mocks," and tools blur the lines too (jest.fn() can be a stub, spy, or mock depending on use). The vocabulary matters most when discussing test design, and in interviews.
  • Prefer stubs and fakes where possible. Heavy use of mocks couples tests to implementation details, so refactors break tests even when the behavior is unchanged.
  • Mock at boundaries you own or that are expensive: network, email, time, and randomness. Avoid mocking your own internal classes.
  • Python equivalent: unittest.mock.Mock / MagicMock covers stub, spy, and mock roles. For example, set return_value for a stub, then check assert_called_once_with(...) for spy-style verification. Use patch() to swap them in. Fakes are just small classes you write yourself.

Top comments (0)