
If your unit tests break every time you refactor internal code, or if they pass despite real bugs slipping through, you likely have a mocking problem. Mocking in tests is one of the most misunderstood practices in software testing — used well, it isolates the code under test and makes tests fast and deterministic. Used poorly, it creates a parallel universe where tests pass but the application fails. This guide explains the different types of test doubles, shows practical mocking patterns in JavaScript and TypeScript, and gives you a clear framework for deciding when to mock and when to use real dependencies instead.
What Is Mocking?
Mocking replaces a real dependency with a controlled substitute during testing. The substitute behaves predictably, responds instantly, and lets you verify how the code under test interacts with its dependencies. However, “mocking” is often used as a catch-all term for several distinct techniques, each with a different purpose.
The formal terminology comes from Gerard Meszaros’s book xUnit Test Patterns, which defines four types of test doubles:
| Test Double | Purpose | Tracks Calls? | Has Logic? |
|---|---|---|---|
| Stub | Returns predetermined responses | No | No |
| Mock | Verifies specific interactions occurred | Yes | No |
| Spy | Wraps the real implementation, tracks calls | Yes | Yes (real) |
| Fake | Working but simplified implementation | No | Yes (simplified) |
In practice, most testing frameworks blur these boundaries. Jest’s jest.fn() creates an object that can act as a stub, mock, or spy depending on how you use it. Understanding the conceptual difference still matters because it changes how you write assertions — stubs verify output, mocks verify interactions.
Why Mocking Exists: The Problem It Solves
Consider a function that creates a user account. It validates the input, hashes the password, saves to a database, and sends a welcome email. Testing this function without mocks requires a running database and an email server, making the test slow, non-deterministic, and dependent on external infrastructure.
// src/services/userService.ts
import { hashPassword } from '../utils/crypto';
import { UserRepository } from '../repositories/userRepository';
import { EmailService } from '../services/emailService';
export class UserService {
constructor(
private userRepo: UserRepository,
private emailService: EmailService
) {}
async createUser(email: string, password: string): Promise<User> {
const existing = await this.userRepo.findByEmail(email);
if (existing) {
throw new Error('Email already registered');
}
const hashedPassword = await hashPassword(password);
const user = await this.userRepo.save({
email,
password: hashedPassword,
});
await this.emailService.sendWelcome(user.email);
return user;
}
}
Mocking lets you replace UserRepository and EmailService with test doubles that respond instantly and predictably. The test verifies that createUser orchestrates its dependencies correctly without needing any real infrastructure.
Mocking in Practice with Jest
Jest is the most common testing framework for JavaScript and TypeScript projects, and its mocking API covers most use cases. These patterns apply similarly to Vitest, which shares Jest’s API. For a deeper look at both frameworks, see our guide on unit testing with Jest and Vitest.
Manual Mocks with Dependency Injection
The cleanest mocking approach uses constructor injection. You create mock objects that satisfy the dependency interface and pass them directly to the class under test.
// tests/userService.test.ts
import { UserService } from '../src/services/userService';
describe('UserService.createUser', () => {
// Create mock implementations for each dependency
const mockUserRepo = {
findByEmail: jest.fn(),
save: jest.fn(),
};
const mockEmailService = {
sendWelcome: jest.fn(),
};
let userService: UserService;
beforeEach(() => {
// Reset all mocks between tests to prevent state leakage
jest.clearAllMocks();
userService = new UserService(mockUserRepo, mockEmailService);
});
it('should create a user and send welcome email', async () => {
// Arrange: configure mock responses
mockUserRepo.findByEmail.mockResolvedValue(null);
mockUserRepo.save.mockResolvedValue({
id: 1,
email: 'alice@example.com',
password: 'hashed_password',
});
mockEmailService.sendWelcome.mockResolvedValue(undefined);
// Act
const user = await userService.createUser(
'alice@example.com',
'securepass123'
);
// Assert: verify the result
expect(user.email).toBe('alice@example.com');
// Assert: verify interactions with dependencies
expect(mockUserRepo.findByEmail).toHaveBeenCalledWith(
'alice@example.com'
);
expect(mockUserRepo.save).toHaveBeenCalledTimes(1);
expect(mockEmailService.sendWelcome).toHaveBeenCalledWith(
'alice@example.com'
);
});
it('should throw if email already exists', async () => {
// Arrange: user already exists
mockUserRepo.findByEmail.mockResolvedValue({
id: 1,
email: 'alice@example.com',
});
// Act & Assert
await expect(
userService.createUser('alice@example.com', 'securepass123')
).rejects.toThrow('Email already registered');
// Verify save was never called
expect(mockUserRepo.save).not.toHaveBeenCalled();
expect(mockEmailService.sendWelcome).not.toHaveBeenCalled();
});
});
This approach works well because the mocks are explicit and visible in the test file. Each test configures exactly what it needs, making the setup-to-assertion relationship clear. The jest.clearAllMocks() in beforeEach prevents one test’s mock configuration from leaking into the next.
Module Mocking with jest.mock
When dependency injection isn’t available — for example, when testing a function that imports modules directly — Jest can replace entire modules.
// src/utils/analytics.ts
import { sendEvent } from '../lib/analyticsClient';
export function trackUserSignup(userId: number): void {
sendEvent('user_signup', { userId, timestamp: Date.now() });
}
// tests/analytics.test.ts
import { trackUserSignup } from '../src/utils/analytics';
import { sendEvent } from '../src/lib/analyticsClient';
// Replace the entire module with auto-mocked version
jest.mock('../src/lib/analyticsClient');
const mockedSendEvent = sendEvent as jest.MockedFunction<typeof sendEvent>;
describe('trackUserSignup', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock Date.now for deterministic timestamps
jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
});
it('should send a signup event with user ID', () => {
trackUserSignup(42);
expect(mockedSendEvent).toHaveBeenCalledWith('user_signup', {
userId: 42,
timestamp: 1700000000000,
});
});
});
Module mocking is powerful but carries a risk: it couples your test to the import structure of the code under test. If someone refactors the analytics module to use a different import path, the test breaks even though the behavior didn’t change. Use module mocking sparingly — prefer dependency injection when the code structure allows it.
Spies: Wrapping Real Implementations
Sometimes you want to use the real implementation but verify it was called correctly. jest.spyOn wraps a method with tracking without replacing its behavior.
// Test that logs are written during error handling
import { Logger } from '../src/utils/logger';
import { processPayment } from '../src/services/paymentService';
describe('processPayment error handling', () => {
it('should log the error when payment fails', async () => {
// Spy on the real logger without replacing it
const logSpy = jest.spyOn(Logger, 'error').mockImplementation();
await processPayment({ amount: -100, currency: 'USD' });
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid payment amount')
);
logSpy.mockRestore(); // Restore original implementation
});
});
Spies are ideal for verifying side effects like logging, analytics, or event emission where you care that the call happened but don’t need to replace the behavior. Always call mockRestore() afterward to prevent the spy from affecting other tests.
Fakes: Simplified Real Implementations
Fakes provide a working implementation that’s simpler than the real thing. An in-memory repository is the most common example — it behaves like a real database but stores data in a plain object.
// tests/fakes/fakeUserRepository.ts
import { User, UserRepository } from '../../src/repositories/userRepository';
export class FakeUserRepository implements UserRepository {
private users: Map<number, User> = new Map();
private nextId = 1;
async findByEmail(email: string): Promise<User | null> {
for (const user of this.users.values()) {
if (user.email === email) return user;
}
return null;
}
async save(data: Omit<User, 'id'>): Promise<User> {
const user = { ...data, id: this.nextId++ };
this.users.set(user.id, user);
return user;
}
async findById(id: number): Promise<User | null> {
return this.users.get(id) || null;
}
}
Fakes test more realistic behavior than mocks because they maintain state across calls. A test can save a user and then verify it’s retrievable — something a mock configured with mockResolvedValue can’t do without duplicating the logic. However, fakes require more upfront effort to write and maintain. They work best for core interfaces like repositories or caches that many tests depend on.
When Mocking Helps
Mocking in tests provides the most value in these specific situations:
External services and APIs. HTTP calls to third-party services are slow, unreliable, and may cost money. Mock external API clients to test how your code handles different responses without depending on external availability.
Non-deterministic behavior. Functions involving Date.now(), Math.random(), or UUIDs produce different results on every run. Mock these to make tests deterministic and assertions precise.
Error conditions. Triggering a database connection timeout or a disk full error in a real environment is impractical. Mocks let you simulate any error condition to verify your error handling code.
Slow dependencies. A test that hits a real database takes 50-200ms per query. A mock responds in microseconds. For a test suite with hundreds of tests, this difference determines whether developers run tests frequently or skip them.
Third-party side effects. Sending emails, processing payments, or posting to Slack during tests is disruptive and potentially expensive. Mock these to prevent real side effects while still verifying your code triggers them correctly.
When Mocking Hurts
Mocking becomes counterproductive in several common scenarios:
Mocking the thing you’re testing. If you mock so many internals that the test only exercises mock behavior, you’re testing your mocks rather than your code. The test passes regardless of bugs in the real implementation.
Mocking simple value objects or utilities. Pure functions that transform data (string formatters, math calculations, data mappers) don’t need mocking. They’re fast, deterministic, and their real behavior is exactly what you want to test.
Implementation-detail mocking. When tests mock internal methods or private functions, they couple to the implementation rather than the behavior. Any refactor that changes internal structure breaks these tests even when the external behavior remains correct. This is the single most common mocking mistake.
Over-mocking integration boundaries. If you mock the database in every test, you never verify that your SQL queries actually work. A query might have a syntax error that mocks would never catch. For database interactions, tools like Testcontainers provide real databases in Docker containers that are nearly as fast as mocks but test real behavior.
The Mocking Spectrum: Finding the Right Balance
Think of testing as a spectrum from fully mocked to fully integrated. The right position depends on what you’re testing.
Fully mocked (unit tests): Every dependency is a mock. Tests run in milliseconds. You verify orchestration logic — the code calls the right dependencies in the right order with the right arguments. Best for business logic and coordination code.
Partially mocked (integration tests): Some dependencies are real (like the database), while others are mocked (like external APIs). Tests run in seconds. You verify that your code works with real infrastructure while isolating from external systems. Best for repository layers and API endpoints.
No mocks (end-to-end tests): All dependencies are real. Tests run in minutes. You verify that the entire system works together. Best for critical user flows.
A healthy test suite uses all three levels. The common mistake is defaulting to one level for everything. Teams that mock everything miss integration bugs. Teams that mock nothing have slow, flaky suites that nobody runs. For a practical example of testing without mocks using the TDD approach, see our guide on TDD and clean architecture in Flutter.
Mocking Patterns That Scale
Pattern 1: Mock at the Boundary, Not the Internals
Mock the outermost dependency, not intermediate layers. If your service calls a repository that calls a database driver, mock the repository — not the database driver.
// Good: mock the repository (your boundary)
const mockRepo = { findById: jest.fn().mockResolvedValue(user) };
const service = new UserService(mockRepo);
// Bad: mock the database driver (implementation detail)
jest.mock('pg', () => ({
Pool: jest.fn().mockImplementation(() => ({
query: jest.fn().mockResolvedValue({ rows: [user] }),
})),
}));
The first approach survives a migration from PostgreSQL to MongoDB. The second breaks immediately because it’s coupled to the pg driver’s API.
Pattern 2: Use Factory Functions for Test Data
Instead of repeating mock data across tests, create factory functions that generate realistic test objects with sensible defaults.
// tests/factories/userFactory.ts
interface UserOverrides {
id?: number;
email?: string;
name?: string;
active?: boolean;
}
export function createUser(overrides: UserOverrides = {}): User {
return {
id: 1,
email: 'test@example.com',
name: 'Test User',
active: true,
...overrides,
};
}
// In tests: create specific variations without boilerplate
const activeUser = createUser({ active: true });
const inactiveUser = createUser({ active: false, name: 'Inactive' });
This pattern keeps tests focused on the specific values that matter for each scenario while hiding irrelevant defaults.
Pattern 3: Verify Behavior, Not Implementation
Write assertions about what the code does, not how it does it internally.
// Good: verify the observable outcome
it('should not send welcome email if user creation fails', async () => {
mockUserRepo.save.mockRejectedValue(new Error('DB error'));
await expect(
userService.createUser('alice@example.com', 'pass')
).rejects.toThrow();
expect(mockEmailService.sendWelcome).not.toHaveBeenCalled();
});
// Bad: verify internal sequencing that could change
it('should call findByEmail before save', async () => {
// This test breaks if someone reorders the internal logic
// even if the behavior is still correct
const callOrder: string[] = [];
mockUserRepo.findByEmail.mockImplementation(() => {
callOrder.push('find');
return Promise.resolve(null);
});
mockUserRepo.save.mockImplementation(() => {
callOrder.push('save');
return Promise.resolve(createUser());
});
await userService.createUser('alice@example.com', 'pass');
expect(callOrder).toEqual(['find', 'save']);
});
The first test verifies a meaningful business rule. The second tests internal ordering that could change without affecting correctness. Dependency injection makes clean mocking possible — for a deeper look at structuring code for testability, see our guide on clean architecture and dependency injection.
Real-World Scenario: Fixing an Over-Mocked Test Suite
Consider a mid-sized Node.js API with 40-50 endpoints and a test suite of 300 unit tests. Every test mocks the database, the cache, the message queue, and all external API clients. Test coverage sits at 85%, and the team feels confident. However, production bugs keep appearing in areas with high test coverage.
The root cause: the mocks don’t reflect real behavior. The database mock returns perfectly formatted data that the real database sometimes delivers differently — null values where the mock returns empty strings, timestamps as Date objects where the mock returns ISO strings. The tests pass because they test the mock’s behavior, not the application’s behavior against real data shapes.
The team takes a layered approach to fix this. They identify three categories of tests. For business logic and orchestration code, they keep mocks but switch to factory functions that produce data matching the actual database schema. For repository and data access code, they replace mocks with Testcontainers running a real PostgreSQL instance — these tests are slower but catch the data shape mismatches that mocks hide. For external API clients, they keep mocks but add contract tests to verify the mocks match the real API responses.
The result: 15% fewer tests (they remove redundant mock-heavy tests), but the remaining tests catch bugs the old suite missed. The key insight is that test count and coverage percentage don’t measure test quality — a test that verifies mock behavior is worse than no test because it provides false confidence.
When to Use Mocking in Tests
- You need to isolate business logic from infrastructure dependencies like databases, file systems, and network calls
- The real dependency is slow, expensive, or non-deterministic and would make tests unreliable
- You want to verify that your code handles specific error conditions that are hard to reproduce with real dependencies
- The dependency has side effects (sending emails, charging credit cards) that should not occur during tests
- You’re testing orchestration logic that coordinates multiple dependencies and you need to verify the coordination is correct
When NOT to Use Mocking in Tests
- You’re testing the interaction between your code and a database — use a real test database or Testcontainers instead
- The dependency is a pure function or utility with no side effects — test with the real implementation for better confidence
- You find yourself mocking more than two layers deep — this usually signals that the code needs restructuring, not more mocks
- Your mocks have become so complex that they contain business logic — at that point you’re maintaining two implementations
- The test would be simpler and more readable without mocks — simplicity in tests matters more than strict isolation
Common Mistakes with Mocking in Tests
- Mocking what you’re testing. When the class under test has its own methods mocked, the test verifies mock behavior rather than real behavior. Only mock dependencies, never the subject of the test.
- Not resetting mocks between tests. Without
jest.clearAllMocks()or equivalent cleanup, mock state leaks between tests. One test’smockReturnValuepersists into the next, causing confusing failures that depend on test execution order. - Writing mocks that don’t match real behavior. If your mock returns
{ data: [] }but the real API returns{ data: null }for empty results, your code handles the mock correctly but crashes on the real response. Validate mock shapes against real API responses using contract tests or shared type definitions. - Over-specifying mock assertions. Asserting that a function was called with exact arguments including irrelevant fields creates brittle tests. Use
expect.objectContaining()to assert only the fields that matter for the specific test case. - Using
jest.mockfor everything instead of dependency injection. Module-level mocking couples tests to import paths and file structure. Dependency injection produces cleaner tests that survive refactoring and make dependencies explicit. - Mocking third-party libraries directly. Instead of mocking
axiosorpgthroughout your codebase, wrap them in your own thin adapter and mock the adapter. This isolates the third-party API to one place and makes mocking simpler across all tests.
Conclusion
Mocking in tests is a tool for isolating code under test from its dependencies, not a default approach for every test. Mock external services, non-deterministic behavior, and slow infrastructure dependencies. Use real implementations for pure functions, data transformations, and database interactions where real behavior matters more than speed. The key principle is to mock at the boundary of your system, verify behavior rather than implementation details, and use factory functions to keep mock data realistic.
When your tests break only because the behavior actually changed — not because you refactored internal code — your mocking strategy is working. For your next step, explore generating unit tests with LLMs to accelerate writing tests with the right mocking patterns, or dive into unit testing with Jest and Vitest for framework-specific patterns.