When to Use Mocks vs Stubs in Unit Testing

When to Use Mocks vs Stubs in Unit Testing

"Just mock it" is the most common piece of testing advice—and one of the most misused. Mocks and stubs both replace real dependencies in tests, but they have fundamentally different purposes. Using one when you need the other leads to tests that pass while hiding real problems.

This post breaks down when to reach for a stub, when to reach for a mock, and how to recognize which one your test actually needs.


The Core Distinction

Stubs control what comes into your code. They answer questions your code asks.

Mocks verify what goes out of your code. They check whether your code sent the right messages to its dependencies.

Martin Fowler calls this the distinction between state verification and behavior verification.

A stub-based test:

// Stub: controls what the repo returns
const repo = { findUser: () => ({ id: 1, plan: "pro" }) };
const billing = new BillingService(repo);

const canExport = await billing.canExport(1);
expect(canExport).toBe(true); // Verify the resulting state

A mock-based test:

// Mock: verifies a specific call was made
const chargeService = { charge: jest.fn() };
const billing = new BillingService(chargeService);

await billing.processMonthlyBilling(customerId);

expect(chargeService.charge).toHaveBeenCalledWith(customerId, 99.00); // Verify behavior

Use a Stub When

You're testing output based on input

If your test is answering "given this data, what does my code return?", you need a stub to control the data.

// Testing: does getDiscount() return 20% for premium users?
const userStub = { getUser: () => ({ tier: "premium" }) };
const pricing = new PricingService(userStub);

const discount = await pricing.getDiscount(userId);
expect(discount).toBe(0.20);

The stub puts the system in a known state. Your assertion is about the return value.

You need to simulate edge cases

Stubs excel at simulating conditions that are hard to reproduce with real dependencies.

// Simulate network timeout
const stub = { fetchInventory: () => Promise.reject(new Error("timeout")) };
const store = new StoreService(stub);

await expect(store.checkStock("SKU-001")).rejects.toThrow("Service unavailable");

The dependency has no observable side effects in this scenario

If you don't care whether the dependency was called—only about what your function returns—use a stub.


Use a Mock When

The interaction itself is what you're testing

When the call to a dependency is the meaningful behavior, use a mock to verify it happened.

// Testing: does placing an order send a confirmation email?
const emailMock = { send: jest.fn() };
const orders = new OrderService(emailMock);

await orders.place({ userId: 42, items: ["Laptop"] });

expect(emailMock.send).toHaveBeenCalledOnce();
expect(emailMock.send).toHaveBeenCalledWith(
  expect.objectContaining({ to: "user@example.com", subject: "Order confirmed" })
);

The return value of place() isn't the point. Whether the email service was called—that's the point.

You're testing command methods (not query methods)

Bertrand Meyer's command-query separation principle is useful here:

  • Queries return data. Verify results with stubs.
  • Commands cause side effects. Verify behavior with mocks.
// Command: deleteUser should archive, then remove
const archiveMock = { archive: jest.fn() };
const userService = new UserService(archiveMock);

await userService.deleteUser(99);

expect(archiveMock.archive).toHaveBeenCalledWith(99, expect.any(Date));

Call order or call count matters

const auditLog = { record: jest.fn() };
const service = new TransactionService(auditLog);

await service.transfer(from, to, amount);

expect(auditLog.record).toHaveBeenCalledTimes(2); // start + complete
expect(auditLog.record.mock.calls[0][0]).toBe("transfer_started");
expect(auditLog.record.mock.calls[1][0]).toBe("transfer_completed");

The Overuse Problem

Overusing mocks is one of the most common unit testing mistakes. Signs you've gone too far:

  1. Tests break on refactoring even when behavior doesn't change. If you mock every internal method, any restructuring—even improving code quality—breaks tests. Tests should validate behavior, not implementation.
  2. Test setup is longer than the assertion. Three screens of mock configuration for one assertion is a smell.
  3. You're mocking things your own code owns. Mocking internal collaborators couples tests to implementation. Prefer mocking at the boundary (external services, I/O).
// Bad: mocking internal collaborator
const validator = jest.spyOn(userService, '_validateEmail');
// Now any internal refactor breaks this test

// Better: mock at the real boundary
const emailProvider = { verify: jest.fn().mockResolvedValue(true) };

Quick Decision Guide

Question Use
Testing what my function returns? Stub
Need to simulate errors or edge cases? Stub
Verifying a service/method was called? Mock
Verifying call arguments? Mock
Verifying call count or order? Mock
Replacing a complex stateful dependency? Fake

In Practice: The Same Scenario, Two Tests

// Scenario: PaymentService.charge()
// Should call gateway.debit() and return a receipt

// Test 1: State verification (stub approach)
// "What does charge() return?"
const gatewayStub = { debit: async () => ({ transactionId: "TX-001", status: "success" }) };
const service = new PaymentService(gatewayStub);
const receipt = await service.charge(customerId, 49.99);
expect(receipt.transactionId).toBe("TX-001");

// Test 2: Behavior verification (mock approach)
// "Did charge() call gateway.debit() with the right arguments?"
const gatewayMock = { debit: jest.fn().mockResolvedValue({ transactionId: "TX-001" }) };
const service = new PaymentService(gatewayMock);
await service.charge(customerId, 49.99);
expect(gatewayMock.debit).toHaveBeenCalledWith(customerId, 49.99, expect.any(String));

Both tests are valid. Together, they give you solid coverage. The key is knowing which question you're answering with each test.


Beyond Unit Tests

Mocks and stubs isolate units well, but they create a gap: your stubs may not reflect how real dependencies actually behave. Integration tests and end-to-end tests fill that gap.

HelpMeTest runs plain-English test scenarios against your real application—no mocking the browser, no stubbing the DOM. When your unit tests (with stubs) pass but real users hit bugs, end-to-end tests catch it.


Summary

  • Stub: controls inputs, verify state (what did my code return?)
  • Mock: verifies outputs, verify behavior (did my code call the right thing?)
  • Don't mock what you own; mock at the system boundary
  • Overusing mocks leads to brittle tests that break on refactoring
  • Use both deliberately—each answers a different question

Read more

Start now free