Advanced Testing Patterns

Mocking, Stubs, and Test Doubles

Isolate units under test from their dependencies using mocks, spies, stubs, and fakes.

Why Mock?

Unit tests must be fast, reliable, and isolated. A test that hits a real database, calls a real API, or reads real files is not a unit test — it's an integration test, and it will be slow and flaky.

Mocking replaces real dependencies with controlled substitutes that behave predictably.

Taxonomy of Test Doubles

Dummy — Passed as argument but never used. Just fills a parameter.

Stub — Returns predetermined data. "When called with X, return Y."

Spy — Records how it was called. "Was this function called? With what arguments? How many times?"

Mock — A stub that also verifies expected interactions. Fails the test if not called correctly.

Fake — A simplified working implementation (in-memory database, fake email sender).

Vitest Mocking

typescript
import { vi, expect } from 'vitest';

// vi.fn() — creates a mock function
const mockFn = vi.fn();
mockFn('hello');
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenCalledTimes(1);

// .mockReturnValue() — stub a return value
const mockGetUser = vi.fn().mockReturnValue({ id: 1, name: 'Alice' });
const user = mockGetUser();
expect(user.name).toBe('Alice');

// .mockResolvedValue() — stub an async return value
const mockFetch = vi.fn().mockResolvedValue({ status: 200, data: [] });
const result = await mockFetch('/api/users');
expect(result.status).toBe(200);

// .mockRejectedValue() — stub a thrown error
const mockFail = vi.fn().mockRejectedValue(new Error('Network error'));
await expect(mockFail()).rejects.toThrow('Network error');

Mocking Modules

typescript
// Mock an entire module
vi.mock('@/lib/email', () => ({
  sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true }),
  sendPasswordReset: vi.fn().mockResolvedValue({ success: true }),
}));

import { sendWelcomeEmail } from '@/lib/email';

it('sends a welcome email after registration', async () => {
  await registerUser({ email: 'alice@example.com', password: 'pass' });
  expect(sendWelcomeEmail).toHaveBeenCalledWith('alice@example.com');
});

Mocking Fetch

typescript
import { vi } from 'vitest';

it('handles API errors gracefully', async () => {
  global.fetch = vi.fn().mockResolvedValue({
    ok: false,
    status: 500,
    json: async () => ({ error: 'Server error' }),
  });

  await expect(fetchUserData(123)).rejects.toThrow('Server error');
});

Spying on Existing Methods

typescript
import * as dateUtils from '@/utils/date';

it('uses the current date when no date is provided', () => {
  const spy = vi.spyOn(dateUtils, 'getCurrentDate')
    .mockReturnValue(new Date('2025-01-01'));

  const result = createPost({ title: 'Test' });
  expect(result.publishedAt).toEqual(new Date('2025-01-01'));

  spy.mockRestore(); // Always restore after test
});

When NOT to Mock

Over-mocking is a common anti-pattern. If everything is mocked, the test verifies the mocks, not the real behavior.

Don't mock:

  • Pure functions (no side effects, just transform inputs to outputs)
  • Simple data objects
  • Your own code's internal logic

Do mock:

  • External APIs and HTTP calls
  • Database queries (in unit tests — use real DB in integration tests)
  • File system operations
  • Email/SMS services
  • Date.now() and Math.random() for determinism

Key Takeaways

  • vi.fn() creates mock functions; .mockReturnValue() and .mockResolvedValue() control what they return
  • vi.mock() replaces entire modules — use for external services, databases, and email senders
  • Spies record calls without changing behavior; use vi.spyOn() for monitoring existing functions
  • Over-mocking creates tests that don't verify real behavior — only mock external dependencies
  • Always restore spies after tests to prevent mock state from leaking between tests

Example

typescript
// UserService tests with mocked dependencies
import { vi, describe, it, expect, beforeEach } from 'vitest';

vi.mock('@/lib/db');
vi.mock('@/lib/email');

import { db } from '@/lib/db';
import { sendWelcomeEmail } from '@/lib/email';
import { UserService } from './UserService';

describe('UserService.createUser', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('hashes the password before saving', async () => {
    vi.mocked(db.users.create).mockResolvedValue({ id: '1', email: 'a@b.com' });

    await UserService.createUser('a@b.com', 'plaintext123');

    const savedData = vi.mocked(db.users.create).mock.calls[0][0];
    expect(savedData.password).not.toBe('plaintext123');
    expect(savedData.password).toMatch(/^$2[aby]$/); // bcrypt pattern
  });

  it('sends a welcome email after creation', async () => {
    vi.mocked(db.users.create).mockResolvedValue({ id: '1', email: 'a@b.com' });

    await UserService.createUser('a@b.com', 'SecurePass1!');

    expect(sendWelcomeEmail).toHaveBeenCalledWith('a@b.com');
  });
});
Try it yourself — TYPESCRIPT