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 stateA 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 behaviorUse 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:
- 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.
- Test setup is longer than the assertion. Three screens of mock configuration for one assertion is a smell.
- 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