Test doubles replace real dependencies in tests. But “mock” is often used as a catch-all term when there are actually four distinct types, each with a different purpose.

The Four Types

Stubs — Provide Canned Answers

Stubs return predefined data. They don’t verify how they’re called.

// Stub: always returns the same data
const userRepoStub: UserRepository = {
  findById: async (id: string) => ({
    id, name: 'Alice', email: '[email protected]', role: 'user',
  }),
  findByEmail: async () => null,
  save: async (user) => user,
  delete: async () => {},
};

test('greets user by name', async () => {
  const service = new GreetingService(userRepoStub);
  const greeting = await service.greet('user-123');
  expect(greeting).toBe('Hello, Alice!');
});

Mocks — Verify Interactions

Mocks verify that specific methods were called with specific arguments.

test('sends welcome email after registration', async () => {
  const emailService = {
    sendWelcome: vi.fn().mockResolvedValue(undefined),
  };
  
  const service = new RegistrationService(userRepo, emailService);
  await service.register({ name: 'Alice', email: '[email protected]' });

  // Mock assertion: verify the interaction
  expect(emailService.sendWelcome).toHaveBeenCalledWith(
    expect.objectContaining({ email: '[email protected]' })
  );
  expect(emailService.sendWelcome).toHaveBeenCalledTimes(1);
});

Spies — Watch Real Implementations

Spies wrap the real implementation, letting you verify calls while keeping original behavior.

test('logs each API request', async () => {
  const logger = new ConsoleLogger();
  const logSpy = vi.spyOn(logger, 'info');
  
  const client = new ApiClient(logger);
  await client.get('/users');

  expect(logSpy).toHaveBeenCalledWith(
    expect.stringContaining('GET /users')
  );
  
  logSpy.mockRestore(); // Clean up
});

Fakes — Working Implementations

Fakes are lightweight working implementations used for testing. They actually work, just with simplified behavior.

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

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) ?? null;
  }

  async findByEmail(email: string): Promise<User | null> {
    return [...this.users.values()].find(u => u.email === email) ?? null;
  }

  async save(user: User): Promise<User> {
    this.users.set(user.id, { ...user });
    return user;
  }

  async delete(id: string): Promise<void> {
    this.users.delete(id);
  }

  // Test helpers
  seed(users: User[]): void {
    users.forEach(u => this.users.set(u.id, u));
  }

  clear(): void {
    this.users.clear();
  }
}

test('user can update their email', async () => {
  const repo = new InMemoryUserRepository();
  repo.seed([{ id: '1', name: 'Alice', email: '[email protected]', role: 'user' }]);
  
  const service = new ProfileService(repo);
  await service.updateEmail('1', '[email protected]');
  
  const user = await repo.findById('1');
  expect(user?.email).toBe('[email protected]');
});

When to Use What

TypeUse WhenVerifies
StubYou need to control what a dependency returnsState (output)
MockYou need to verify a side effect happenedBehavior (was called?)
SpyYou want real behavior + verificationBoth
FakeYou need a working but lightweight implementationState (through the fake)

Best Practices

  1. Prefer fakes over mocks for repositories and data stores
  2. Use mocks sparingly — for side effects (email sent, event emitted)
  3. Don’t mock what you don’t own — mock your adapters, not third-party APIs
  4. Avoid deep mocking — if you need to mock a.b.c.d(), your design is too coupled

“Mock objects are like stunt doubles: they look like the real thing but are purpose-built for a specific scene.” — Gerard Meszaros