A

Mocking Strategies

testingmockingvitestjestmswtest-doubles

Mocking Strategies

Mocking replaces real implementations with controlled substitutes during testing. Done well, mocking enables fast, deterministic tests that isolate the code under test. Done poorly, mocking creates brittle tests coupled to implementation details that break on every refactor. This guide covers when to mock, what to mock, and how to mock effectively.

Why Mock?

Mocking serves several purposes:

Isolation. Test one unit without its dependencies:

// Without mocking: test depends on real database
const user = await userService.getUser(1);  // Hits actual DB

// With mocking: test is isolated
vi.mocked(db.query).mockResolvedValue({ id: 1, name: 'John' });
const user = await userService.getUser(1);  // Uses mock

Speed. Avoid slow operations:

// Real: 500ms network request
await fetch('/api/users');

// Mocked: instant
vi.mocked(fetch).mockResolvedValue({ json: () => ({ users: [] }) });

Determinism. Control unpredictable values:

// Unpredictable
const id = crypto.randomUUID();

// Predictable
vi.mocked(crypto.randomUUID).mockReturnValue('test-uuid-123');

Edge cases. Test scenarios hard to create naturally:

// Hard to trigger real 500 error
vi.mocked(fetch).mockRejectedValue(new Error('Server error'));

// Hard to test time-dependent code
vi.setSystemTime(new Date('2024-12-25'));

Levels of Mocking

Function Mocks

Replace individual functions:

import { describe, it, expect, vi } from 'vitest';

// Create a mock function
const mockCallback = vi.fn();

// Configure return values
mockCallback.mockReturnValue(42);
mockCallback.mockReturnValueOnce(1).mockReturnValueOnce(2);
mockCallback.mockResolvedValue({ data: 'async' });
mockCallback.mockImplementation((x) => x * 2);

// Verify calls
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockCallback).toHaveBeenCalledTimes(3);

Use function mocks for:

  • Callbacks passed to functions under test
  • Event handlers
  • Dependency-injected functions

Module Mocks

Replace entire modules:

// Vitest
vi.mock('./database', () => ({
  query: vi.fn(),
  connect: vi.fn(),
}));

// Jest
jest.mock('./database', () => ({
  query: jest.fn(),
  connect: jest.fn(),
}));
// src/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getUser } from './userService';
import { query } from './database';

vi.mock('./database');

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

  it('returns user from database', async () => {
    vi.mocked(query).mockResolvedValue([{ id: 1, name: 'John' }]);

    const user = await getUser(1);

    expect(query).toHaveBeenCalledWith('SELECT * FROM users WHERE id = ?', [1]);
    expect(user).toEqual({ id: 1, name: 'John' });
  });
});

Use module mocks for:

  • External libraries
  • Internal modules with side effects
  • Heavy dependencies you want to replace entirely

Partial Module Mocks

Keep some exports real, mock others:

vi.mock('./utils', async () => {
  const actual = await vi.importActual('./utils');
  return {
    ...actual,
    sendEmail: vi.fn(),  // Only mock this
  };
});

Spies

Observe calls without replacing implementation:

const user = {
  getName() {
    return 'John';
  },
};

const spy = vi.spyOn(user, 'getName');

user.getName();

expect(spy).toHaveBeenCalled();
expect(spy).toHaveReturnedWith('John');  // Real value

spy.mockReturnValue('Jane');  // Can also override
expect(user.getName()).toBe('Jane');

spy.mockRestore();  // Restore original
expect(user.getName()).toBe('John');

Use spies for:

  • Verifying calls without changing behavior
  • Partially mocking objects
  • Console methods, built-in functions

Mocking HTTP Requests

Built-in Mocking (Vitest/Jest)

// Mock global fetch
global.fetch = vi.fn();

beforeEach(() => {
  vi.mocked(fetch).mockResolvedValue({
    ok: true,
    json: () => Promise.resolve({ users: [] }),
  } as Response);
});

it('fetches users', async () => {
  await fetchUsers();
  expect(fetch).toHaveBeenCalledWith('/api/users');
});

MSW (Mock Service Worker)

MSW intercepts requests at the network level—the gold standard for HTTP mocking:

pnpm add -D msw
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'John' },
      { id: 2, name: 'Jane' },
    ]);
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 3, ...body }, { status: 201 });
  }),

  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({ id: params.id, name: 'John' });
  }),

  // Error response
  http.get('/api/error', () => {
    return HttpResponse.json(
      { message: 'Something went wrong' },
      { status: 500 }
    );
  }),
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '../mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Override handlers in specific tests
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';

it('handles server errors', async () => {
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.json({ error: 'Server error' }, { status: 500 });
    })
  );

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

Why MSW over fetch mocking:

  • Tests real fetch code (headers, body parsing, etc.)
  • Works with any HTTP client (fetch, axios, got)
  • Same handlers work in browser for development
  • Request inspection is more natural

Nock (Node.js HTTP)

Alternative for Node.js-specific HTTP mocking:

pnpm add -D nock
import nock from 'nock';

beforeEach(() => {
  nock('https://api.example.com')
    .get('/users')
    .reply(200, [{ id: 1, name: 'John' }]);
});

afterEach(() => {
  nock.cleanAll();
});

it('fetches users from API', async () => {
  const users = await fetchUsers();
  expect(users).toHaveLength(1);
});

Mocking Timers

Control setTimeout, setInterval, and Date:

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

describe('timer-based functions', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('executes after delay', () => {
    const callback = vi.fn();
    setTimeout(callback, 1000);

    expect(callback).not.toHaveBeenCalled();

    vi.advanceTimersByTime(1000);

    expect(callback).toHaveBeenCalledTimes(1);
  });

  it('handles intervals', () => {
    const callback = vi.fn();
    setInterval(callback, 100);

    vi.advanceTimersByTime(350);

    expect(callback).toHaveBeenCalledTimes(3);
  });

  it('mocks current date', () => {
    vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));

    expect(new Date().toISOString()).toBe('2024-01-01T00:00:00.000Z');
    expect(Date.now()).toBe(1704067200000);
  });
});

Timer utilities:

vi.advanceTimersByTime(ms);      // Advance by specific time
vi.advanceTimersToNextTimer();    // Jump to next scheduled timer
vi.runAllTimers();                // Execute all pending timers
vi.runOnlyPendingTimers();        // Run current timers, not new ones
vi.getTimerCount();               // Number of pending timers

Dependency Injection vs. Module Mocking

Two approaches to making code testable:

Module Mocking

Mock imports directly:

// userService.ts
import { db } from './database';

export async function getUser(id: string) {
  return db.query('SELECT * FROM users WHERE id = ?', [id]);
}

// userService.test.ts
vi.mock('./database');

it('queries database', async () => {
  vi.mocked(db.query).mockResolvedValue([{ id: '1' }]);
  await getUser('1');
  expect(db.query).toHaveBeenCalled();
});

Pros: No changes to production code Cons: Coupled to import structure, can be fragile

Dependency Injection

Pass dependencies explicitly:

// userService.ts
interface Database {
  query(sql: string, params: unknown[]): Promise<unknown[]>;
}

export class UserService {
  constructor(private db: Database) {}

  async getUser(id: string) {
    return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

// userService.test.ts
it('queries database', async () => {
  const mockDb = { query: vi.fn().mockResolvedValue([{ id: '1' }]) };
  const service = new UserService(mockDb);

  await service.getUser('1');

  expect(mockDb.query).toHaveBeenCalled();
});

Pros: Explicit dependencies, easier to test, more flexible Cons: More boilerplate, requires upfront design

Recommendation: Use dependency injection for classes and complex modules. Use module mocking for simple functions and external libraries.

Database Mocking vs. Test Databases

Mocking Database Calls

vi.mock('./database');

it('creates user in database', async () => {
  vi.mocked(db.insert).mockResolvedValue({ id: '1', name: 'John' });

  const user = await createUser({ name: 'John' });

  expect(db.insert).toHaveBeenCalledWith('users', { name: 'John' });
  expect(user.id).toBe('1');
});

When to mock:

  • Unit testing business logic
  • Testing error handling
  • Fast CI pipelines

Test Database

Use a real database (often in-memory or Docker):

// Setup: create test database
beforeAll(async () => {
  await db.migrate();
});

beforeEach(async () => {
  await db.truncateAll();
});

it('creates user in database', async () => {
  const user = await createUser({ name: 'John' });

  const saved = await db.query('SELECT * FROM users WHERE id = ?', [user.id]);
  expect(saved[0].name).toBe('John');
});

When to use real database:

  • Integration tests
  • Testing complex queries
  • Verifying schema constraints
  • Testing transactions

In-Memory Databases

SQLite in-memory for testing:

// test/setup.ts
import Database from 'better-sqlite3';

export const testDb = new Database(':memory:');

beforeAll(() => {
  testDb.exec(`
    CREATE TABLE users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      name TEXT NOT NULL,
      email TEXT UNIQUE
    )
  `);
});

Anti-Patterns

Over-Mocking

Mocking too much defeats the purpose of testing:

// ❌ Over-mocked: tests nothing real
it('creates user', async () => {
  const mockValidator = { validate: vi.fn().mockReturnValue(true) };
  const mockHasher = { hash: vi.fn().mockReturnValue('hash') };
  const mockDb = { insert: vi.fn().mockResolvedValue({ id: '1' }) };
  const mockEmailer = { send: vi.fn() };

  const service = new UserService(mockValidator, mockHasher, mockDb, mockEmailer);
  await service.createUser({ email: '[email protected]', password: 'pass' });

  // What are we even testing? Just that methods were called?
  expect(mockValidator.validate).toHaveBeenCalled();
  expect(mockHasher.hash).toHaveBeenCalled();
  expect(mockDb.insert).toHaveBeenCalled();
});

// ✅ Better: use real implementations where practical
it('creates user with hashed password', async () => {
  const mockDb = { insert: vi.fn().mockResolvedValue({ id: '1' }) };

  const service = new UserService(
    new RealValidator(),  // Use real validator
    new RealHasher(),     // Use real hasher
    mockDb,               // Mock only external boundary
    new FakeEmailer(),    // Fake that doesn't send real emails
  );

  await service.createUser({ email: '[email protected]', password: 'pass' });

  // Test real behavior
  expect(mockDb.insert).toHaveBeenCalledWith('users', expect.objectContaining({
    email: '[email protected]',
    passwordHash: expect.stringMatching(/^\$2[aby]\$/),  // bcrypt format
  }));
});

Mocking What You Don't Own

Don't mock third-party libraries directly—wrap them:

// ❌ Mocking library internals
vi.mock('stripe', () => ({
  Stripe: vi.fn().mockImplementation(() => ({
    customers: { create: vi.fn() },
    charges: { create: vi.fn() },
  })),
}));

// ✅ Create your own wrapper
// src/payments.ts
import Stripe from 'stripe';

export interface PaymentProvider {
  createCustomer(email: string): Promise<{ id: string }>;
  charge(customerId: string, amount: number): Promise<{ id: string }>;
}

export class StripePaymentProvider implements PaymentProvider {
  private stripe: Stripe;

  constructor(apiKey: string) {
    this.stripe = new Stripe(apiKey);
  }

  async createCustomer(email: string) {
    const customer = await this.stripe.customers.create({ email });
    return { id: customer.id };
  }

  async charge(customerId: string, amount: number) {
    const charge = await this.stripe.charges.create({
      customer: customerId,
      amount,
      currency: 'usd',
    });
    return { id: charge.id };
  }
}

// Test with a fake implementation
class FakePaymentProvider implements PaymentProvider {
  async createCustomer() {
    return { id: 'cus_fake' };
  }
  async charge() {
    return { id: 'ch_fake' };
  }
}

Testing Implementation Details

// ❌ Tests internal implementation
it('calls _internalMethod', () => {
  const spy = vi.spyOn(service, '_validateInput');
  service.process({ data: 'test' });
  expect(spy).toHaveBeenCalled();
});

// ✅ Tests behavior
it('rejects invalid input', () => {
  expect(() => service.process({ data: '' })).toThrow('Input required');
});

Not Resetting Mocks

// ❌ Mocks leak between tests
it('first test', () => {
  vi.mocked(api.fetch).mockResolvedValue({ data: 'first' });
  // ...
});

it('second test', () => {
  // Still uses mock from first test!
});

// ✅ Reset mocks
afterEach(() => {
  vi.clearAllMocks();  // Clear call history
  // or
  vi.resetAllMocks();  // Clear history + reset return values
  // or
  vi.restoreAllMocks(); // Restore original implementations
});

When to Use Real Implementations

Use real implementations for:

  • Pure functions with no side effects
  • Data transformation logic
  • Validation utilities
  • In-memory data structures
  • Small, fast utilities
// These don't need mocking
import { formatDate, validateEmail, sortUsers } from './utils';

it('sorts users by name', () => {
  const users = [{ name: 'Zoe' }, { name: 'Alice' }];
  expect(sortUsers(users)).toEqual([{ name: 'Alice' }, { name: 'Zoe' }]);
});

Mock only at system boundaries:

  • Network requests (HTTP, WebSocket)
  • Databases
  • File system
  • External services
  • Time and randomness

Quick Reference

Scenario Approach
Callback functions vi.fn()
External modules vi.mock()
Partial module override vi.mock() with importActual
Method observation vi.spyOn()
HTTP requests MSW (preferred) or mock fetch
Timers vi.useFakeTimers()
Dates vi.setSystemTime()
Database Mock for unit tests, real for integration
Third-party libraries Wrap and mock the wrapper

See Also

Last updated: March 23, 2026