Loading learning content...
In the previous page, we learned that stubs control what our code receives from dependencies. They're perfect when we need to test how our code responds to different inputs. But consider this scenario:
The notification problem:
Your OrderProcessor class should send a confirmation email when an order is completed. The EmailService dependency doesn't return anything meaningful—it just sends the email (void return). How do you test that the email was actually sent?
class OrderProcessor {
constructor(private emailService: EmailService) {}
completeOrder(order: Order): void {
// ... process order logic ...
this.emailService.sendConfirmation(order.customerEmail, order.id);
}
}
A stub won't help here. The stub would return nothing (or a mock success), but we have no way to verify that sendConfirmation was actually called with the correct arguments. The behavior we need to verify is the interaction itself, not the response.
By the end of this page, you will understand mocks as test doubles that verify interactions. You'll learn how mocks differ fundamentally from stubs, when mocks are essential for correct testing, and how to design mock-based tests that remain maintainable and meaningful.
A mock is a test double that verifies interactions between the system under test and its dependencies. Unlike stubs, which focus on what the dependency returns, mocks focus on how the dependency is called.
Key characteristics of mocks:
The fundamental difference:
| Aspect | Stub | Mock |
|---|---|---|
| Primary purpose | Control inputs | Verify outputs (calls) |
| Direction | Inbound to SUT | Outbound from SUT |
| Question answered | "What should SUT receive?" | "Did SUT call correctly?" |
| Assertion timing | On SUT's returned value | On mock's received calls |
| Failure mode | SUT produces wrong output | SUT makes wrong call |
Another way to think about it: Stubs replace incoming dependencies; mocks verify outgoing interactions.
This maps to Command-Query Separation (CQS): Queries return data without side effects—use stubs. Commands perform side effects without returning data—use mocks. When a method does both (a query with side effects), consider whether stubbing or mocking is more important for that specific test.
Mock-based tests follow a specific structure that differs from state-based testing. Understanding this structure is essential for writing effective mock tests.
The mock test lifecycle:
This is sometimes called "Record and Verify" or "Expect-Run-Verify" pattern.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
// Interface for the dependency we'll mockinterface NotificationService { sendEmail(to: string, subject: string, body: string): void; sendSms(to: string, message: string): void; sendPush(userId: string, notification: PushNotification): void;} // Hand-coded mock for educational purposesclass MockNotificationService implements NotificationService { // Record all calls private emailCalls: { to: string; subject: string; body: string }[] = []; private smsCalls: { to: string; message: string }[] = []; private pushCalls: { userId: string; notification: PushNotification }[] = []; // Implementation records the call sendEmail(to: string, subject: string, body: string): void { this.emailCalls.push({ to, subject, body }); } sendSms(to: string, message: string): void { this.smsCalls.push({ to, message }); } sendPush(userId: string, notification: PushNotification): void { this.pushCalls.push({ userId, notification }); } // Verification methods verifyEmailSent(to: string, subject: string): void { const found = this.emailCalls.some( call => call.to === to && call.subject === subject ); if (!found) { throw new Error( `Expected email to ${to} with subject "${subject}" was not sent.\n` + `Actual calls: ${JSON.stringify(this.emailCalls)}` ); } } verifyNoEmailsSent(): void { if (this.emailCalls.length > 0) { throw new Error( `Expected no emails but ${this.emailCalls.length} were sent` ); } } verifyEmailCount(expected: number): void { if (this.emailCalls.length !== expected) { throw new Error( `Expected ${expected} emails but ${this.emailCalls.length} were sent` ); } } // Get recorded calls for complex assertions getEmailCalls() { return [...this.emailCalls]; } getSmsCalls() { return [...this.smsCalls]; } getPushCalls() { return [...this.pushCalls]; }} // Test using the mockdescribe('OrderProcessor', () => { it('should send confirmation email when order completes', () => { // ARRANGE: Create mock and set up the system const notificationMock = new MockNotificationService(); const processor = new OrderProcessor(notificationMock); const order: Order = { id: 'order-123', customerEmail: 'customer@example.com', items: [{ product: 'Widget', quantity: 2 }], }; // ACT: Execute the behavior processor.completeOrder(order); // ASSERT: Verify the mock received expected calls notificationMock.verifyEmailSent( 'customer@example.com', 'Order Confirmation: order-123' ); }); it('should not send email for cancelled orders', () => { const notificationMock = new MockNotificationService(); const processor = new OrderProcessor(notificationMock); const order: Order = { id: 'order-456', status: 'CANCELLED', ... }; processor.handleOrder(order); // Verify NO interaction occurred notificationMock.verifyNoEmailsSent(); });});Notice how the test assertions are on the mock object, not on the return value of the system under test. We're verifying that the OrderProcessor called the NotificationService correctly, which is the actual behavior we care about.
Mocks are the right choice when the correctness of your system depends on it making the right calls to its collaborators. The interaction itself is the behavior being tested.
Use mocks when:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// USE CASE 1: Verifying side effects (notification sent)describe('PasswordResetService', () => { it('should send reset email with token', () => { const emailMock = new MockEmailService(); const tokenService = new StubTokenService().willGenerate('token-abc-123'); const service = new PasswordResetService(emailMock, tokenService); service.initiateReset('user@example.com'); // Verify the side effect occurred correctly emailMock.verifySentTo('user@example.com'); emailMock.verifyBodyContains('token-abc-123'); });}); // USE CASE 2: Verifying correct arguments are passeddescribe('AuditLogger', () => { it('should log user actions with correct details', () => { const auditMock = new MockAuditStore(); const logger = new AuditLogger(auditMock); logger.logAction({ userId: 'user-1', action: 'DELETE_ACCOUNT', resourceId: 'account-789', timestamp: new Date('2024-01-15T10:30:00Z'), }); // Verify correct data was passed to audit store auditMock.verifyRecordedWith({ userId: 'user-1', action: 'DELETE_ACCOUNT', resourceId: 'account-789', }); });}); // USE CASE 3: Verifying calls are NOT made (conditional behavior)describe('EmailThrottler', () => { it('should not send email when rate limit exceeded', () => { const emailMock = new MockEmailService(); const rateLimiter = new StubRateLimiter().willReturnLimitExceeded(); const throttler = new EmailThrottler(emailMock, rateLimiter); throttler.trySendEmail('user@example.com', 'Subject', 'Body'); // Verify email was NOT sent emailMock.verifyNeverCalled(); });}); // USE CASE 4: Verifying call orderdescribe('TransactionProcessor', () => { it('should validate before committing', () => { const dbMock = new MockDatabase(); const processor = new TransactionProcessor(dbMock); processor.execute(transaction); // Verify correct sequence dbMock.verifyCallOrder([ 'beginTransaction', 'validate', 'persist', 'commit', ]); }); it('should rollback on validation failure', () => { const dbMock = new MockDatabase(); const validator = new StubValidator().willFail(); const processor = new TransactionProcessor(dbMock, validator); processor.execute(invalidTransaction); // Verify rollback was called, NOT commit dbMock.verifyCallOrder([ 'beginTransaction', 'validate', 'rollback', ]); dbMock.verifyNeverCalled('commit'); });}); // USE CASE 5: Verifying event publishingdescribe('OrderService', () => { it('should publish OrderCompleted event', () => { const eventBusMock = new MockEventBus(); const orderService = new OrderService(eventBusMock); orderService.completeOrder(order); // Verify correct event was published eventBusMock.verifyPublished( OrderCompletedEvent, event => event.orderId === order.id && event.customerId === order.customerId ); });});Avoid mocks when you can verify behavior through state inspection or return values. If a method returns a result that reflects correct behavior, test the result—don't mock the internals. Over-mocking leads to tests coupled to implementation details, which break when you refactor.
Just as with stubs, several patterns exist for implementing mocks. The choice depends on complexity and reusability needs.
Pattern 1: The Recording Mock
The most common pattern—records all calls for later verification:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Pattern 1: Recording Mock// Records all calls and provides verification methods interface EventPublisher { publish<T>(event: T): void; publishAll<T>(events: T[]): void;} class RecordingMockEventPublisher implements EventPublisher { private calls: Array<{ method: string; args: unknown[] }> = []; publish<T>(event: T): void { this.calls.push({ method: 'publish', args: [event] }); } publishAll<T>(events: T[]): void { this.calls.push({ method: 'publishAll', args: [events] }); } // Verification: Was this event type published? verifyPublished<T>(eventType: new (...args: any[]) => T): void { const found = this.calls.some(call => call.method === 'publish' && call.args[0] instanceof eventType ); if (!found) { throw new Error(`Expected event of type ${eventType.name} was not published`); } } // Verification: Was this specific event published? verifyPublishedMatching<T>(predicate: (event: T) => boolean): void { const found = this.calls.some(call => call.method === 'publish' && predicate(call.args[0] as T) ); if (!found) { throw new Error('No published event matched the predicate'); } } // Verification: Exact call count verifyPublishCalledTimes(expected: number): void { const actual = this.calls.filter(c => c.method === 'publish').length; if (actual !== expected) { throw new Error(`Expected ${expected} publish calls, got ${actual}`); } } // Get all calls for complex custom verification getCalls() { return [...this.calls]; }}Pattern 2: The Expectation Mock
Pre-programmed with expectations before the action executes:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// Pattern 2: Expectation Mock// Set up expectations before execution, verify they were met class ExpectationMockLogger implements Logger { private expectations: Array<{ method: string; argMatcher: (args: unknown[]) => boolean; satisfied: boolean; }> = []; private calls: Array<{ method: string; args: unknown[] }> = []; // Set up expectations expectLogInfo(messageMatcher: (msg: string) => boolean): this { this.expectations.push({ method: 'info', argMatcher: (args) => messageMatcher(args[0] as string), satisfied: false, }); return this; } expectLogError(messageMatcher: (msg: string) => boolean): this { this.expectations.push({ method: 'error', argMatcher: (args) => messageMatcher(args[0] as string), satisfied: false, }); return this; } // Implementation marks expectations as satisfied info(message: string): void { this.calls.push({ method: 'info', args: [message] }); this.satisfyMatchingExpectations('info', [message]); } error(message: string): void { this.calls.push({ method: 'error', args: [message] }); this.satisfyMatchingExpectations('error', [message]); } private satisfyMatchingExpectations(method: string, args: unknown[]): void { for (const exp of this.expectations) { if (exp.method === method && exp.argMatcher(args)) { exp.satisfied = true; } } } // Verify all expectations were met verifyAllExpectations(): void { const unsatisfied = this.expectations.filter(e => !e.satisfied); if (unsatisfied.length > 0) { throw new Error( `Unsatisfied expectations: ${unsatisfied.length}\n` + `Actual calls: ${JSON.stringify(this.calls)}` ); } }} // Usagedescribe('PaymentProcessor', () => { it('should log payment attempts', () => { const loggerMock = new ExpectationMockLogger() .expectLogInfo(msg => msg.includes('Processing payment')) .expectLogInfo(msg => msg.includes('Payment successful')); const processor = new PaymentProcessor(loggerMock); processor.processPayment(100, 'USD'); loggerMock.verifyAllExpectations(); // Fails if any expectation not met });});Pattern 3: The Strict Mock
Fails immediately on any unexpected call:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// Pattern 3: Strict Mock// Fails immediately on unexpected calls class StrictMockPaymentGateway implements PaymentGateway { private allowedMethods: Set<string> = new Set(); // Configure which methods are allowed allowProcessPayment(): this { this.allowedMethods.add('processPayment'); return this; } allowRefund(): this { this.allowedMethods.add('refund'); return this; } processPayment(amount: number, currency: string): PaymentResult { if (!this.allowedMethods.has('processPayment')) { throw new Error( 'Unexpected call to processPayment. ' + 'Did you forget to set up this expectation?' ); } return { success: true, transactionId: 'txn-mock' }; } refund(transactionId: string): RefundResult { if (!this.allowedMethods.has('refund')) { throw new Error( 'Unexpected call to refund. ' + 'Did you forget to set up this expectation?' ); } return { success: true, refundId: 'ref-mock' }; }} // Usage - test will FAIL if unauthorized method is calleddescribe('OrderService', () => { it('should only process payment, never refund, for new orders', () => { const paymentMock = new StrictMockPaymentGateway() .allowProcessPayment(); // Only this method is allowed const service = new OrderService(paymentMock); // If OrderService accidentally calls refund(), test fails immediately service.createOrder(order); });});Mocking frameworks provide powerful syntax for creating mocks, setting expectations, and verifying interactions with minimal boilerplate.
Popular mocking frameworks:
| Language | Framework | Verification Syntax |
|---|---|---|
| JavaScript/TS | Jest, Vitest | expect(mock).toHaveBeenCalledWith(...) |
| Java | Mockito | verify(mock).method(args) |
| Python | unittest.mock | mock.assert_called_with(...) |
| C# | Moq | mock.Verify(x => x.Method(...)) |
| Go | gomock | ctrl.EXPECT().Method(...) |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
import { describe, it, expect, vi, beforeEach } from 'vitest'; describe('OrderProcessor with Vitest mocks', () => { // Create typed mock from interface const emailService: EmailService = { sendConfirmation: vi.fn(), sendShippingNotification: vi.fn(), sendCancellation: vi.fn(), }; beforeEach(() => { // Reset mock between tests vi.clearAllMocks(); }); it('should send confirmation email with order details', () => { const processor = new OrderProcessor(emailService); const order: Order = { id: 'order-123', customerEmail: 'alice@example.com', total: 149.99, }; processor.completeOrder(order); // Verify the mock was called correctly expect(emailService.sendConfirmation).toHaveBeenCalledTimes(1); expect(emailService.sendConfirmation).toHaveBeenCalledWith( 'alice@example.com', expect.objectContaining({ orderId: 'order-123', total: 149.99, }) ); }); it('should send shipping notification when order ships', () => { const processor = new OrderProcessor(emailService); const order = { id: 'order-456', customerEmail: 'bob@example.com' }; const trackingNumber = 'TRACK-789'; processor.shipOrder(order, trackingNumber); expect(emailService.sendShippingNotification).toHaveBeenCalledWith( 'bob@example.com', expect.objectContaining({ trackingNumber: 'TRACK-789', }) ); }); it('should not send any email for draft orders', () => { const processor = new OrderProcessor(emailService); const draftOrder = { id: 'order-789', status: 'DRAFT' }; processor.handleOrder(draftOrder); // Verify NO emails sent expect(emailService.sendConfirmation).not.toHaveBeenCalled(); expect(emailService.sendShippingNotification).not.toHaveBeenCalled(); expect(emailService.sendCancellation).not.toHaveBeenCalled(); });}); // Advanced: Verifying call arguments in detaildescribe('AuditService with detailed verification', () => { it('should log audit event with timestamp and user context', () => { const auditStore = { record: vi.fn(), }; const service = new AuditService(auditStore); service.logUserAction('user-1', 'LOGIN', { ip: '192.168.1.1' }); // Detailed argument verification expect(auditStore.record).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1', action: 'LOGIN', metadata: expect.objectContaining({ ip: '192.168.1.1' }), timestamp: expect.any(Date), }) ); });}); // Verifying call orderdescribe('TransactionManager', () => { it('should begin before commit', () => { const db = { beginTransaction: vi.fn(), commit: vi.fn(), rollback: vi.fn(), }; const manager = new TransactionManager(db); manager.executeInTransaction(() => { /* work */ }); // Get call order const beginOrder = db.beginTransaction.mock.invocationCallOrder[0]; const commitOrder = db.commit.mock.invocationCallOrder[0]; expect(beginOrder).toBeLessThan(commitOrder); });});Different scenarios require different levels of verification precision. Understanding these strategies helps you write tests that are precise enough to catch bugs but flexible enough to withstand refactoring.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
// VERIFICATION STRATEGY 1: Exact Verification// Use when the exact interaction is part of the contract it('should call API with exact payload structure', () => { const apiMock = vi.fn(); const client = new ApiClient(apiMock); client.createUser({ name: 'Alice', email: 'alice@test.com' }); // Exact verification - any difference fails expect(apiMock).toHaveBeenCalledWith({ method: 'POST', endpoint: '/users', body: { name: 'Alice', email: 'alice@test.com', }, headers: { 'Content-Type': 'application/json', }, });}); // VERIFICATION STRATEGY 2: Partial Verification// Use when some fields are essential, others are implementation details it('should include userId in audit log', () => { const auditMock = vi.fn(); const service = new AuditService(auditMock); service.logAction('user-123', 'DELETE_FILE', '/path/to/file'); // Only verify what matters - timestamp is an implementation detail expect(auditMock).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-123', action: 'DELETE_FILE', }) );}); // VERIFICATION STRATEGY 3: Argument Matchers// Use for dynamic or unpredictable values it('should publish event with generated ID', () => { const eventBusMock = vi.fn(); const publisher = new EventPublisher(eventBusMock); publisher.publishOrderCreated(order); expect(eventBusMock).toHaveBeenCalledWith( expect.objectContaining({ type: 'ORDER_CREATED', orderId: order.id, eventId: expect.stringMatching(/^evt-[a-z0-9]+$/), // Pattern match timestamp: expect.any(Date), }) );}); // VERIFICATION STRATEGY 4: Call Count Verification// Verify exact, minimum, or maximum call counts it('should only process each item once', () => { const processorMock = vi.fn(); const batch = new BatchProcessor(processorMock); batch.process(['a', 'b', 'c']); expect(processorMock).toHaveBeenCalledTimes(3); // Exactly 3}); it('should retry failed items', () => { const processorMock = vi.fn() .mockRejectedValueOnce(new Error('Fail')) .mockResolvedValue('ok'); const batch = new RetryingProcessor(processorMock, { maxRetries: 3 }); await batch.process(['item']); // At least 2 calls (original + 1 retry) expect(processorMock.mock.calls.length).toBeGreaterThanOrEqual(2);}); // VERIFICATION STRATEGY 5: Never Called Verification// Critical for testing conditional logic it('should not notify when user has opted out', () => { const notifierMock = vi.fn(); const preferences = new StubPreferences().userOptedOut(); const service = new NotificationService(notifierMock, preferences); service.notifyUser('user-123', 'Hello'); expect(notifierMock).not.toHaveBeenCalled();});Mocks are powerful but easy to misuse. The most common mistake is over-mocking—verifying interactions that don't matter, which couples tests to implementation details.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// ❌ PITFALL 1: Over-specification// This test is too strict - any small change breaks it it('BAD: verifies too many details', () => { const repo = vi.fn(); const logger = vi.fn(); const service = new UserService(repo, logger); service.createUser({ name: 'Alice' }); // Verifying EVERYTHING - way too specific expect(repo).toHaveBeenCalledTimes(1); expect(repo).toHaveBeenCalledWith('INSERT', 'users', { name: 'Alice' }); expect(logger).toHaveBeenCalledTimes(2); expect(logger).toHaveBeenNthCalledWith(1, 'debug', 'Creating user'); expect(logger).toHaveBeenNthCalledWith(2, 'info', 'User created');}); // ✅ BETTER: Verify what mattersit('GOOD: verifies essential behavior', () => { const repo = vi.fn(); const service = new UserService(repo, new NoOpLogger()); service.createUser({ name: 'Alice' }); // Only verify the important interaction expect(repo).toHaveBeenCalledWith( expect.anything(), // Don't care about internal SQL 'users', expect.objectContaining({ name: 'Alice' }) );}); // ❌ PITFALL 2: Implementation coupling// Test breaks when implementation changes, even if behavior is correct it('BAD: tests implementation not behavior', () => { const cache = vi.fn(); const db = vi.fn().mockReturnValue({ id: 1, name: 'Alice' }); const service = new CachedUserService(cache, db); service.getUser(1); // This tests the caching strategy - implementation detail expect(cache).toHaveBeenCalledWith('get', 'user:1'); expect(db).toHaveBeenCalledTimes(1); expect(cache).toHaveBeenCalledWith('set', 'user:1', expect.anything());}); // ✅ BETTER: Test observable behaviorit('GOOD: tests that correct user is returned', () => { // Use a fake cache + stub DB, don't mock the interactions const fakeCache = new InMemoryCache(); const stubDb = { getUser: () => ({ id: 1, name: 'Alice' }) }; const service = new CachedUserService(fakeCache, stubDb); const user = service.getUser(1); expect(user.name).toBe('Alice'); // Test BEHAVIOR}); // ❌ PITFALL 3: Mocking what you don't own// axios internals might change between versions import axios from 'axios';vi.mock('axios'); it('BAD: mocks third-party library internals', () => { (axios.get as any).mockResolvedValue({ data: { name: 'Alice' } }); const client = new ApiClient(); const user = await client.getUser(1); expect(axios.get).toHaveBeenCalledWith('/users/1'); // Coupled to axios API}); // ✅ BETTER: Create your own interface and mock THATinterface HttpClient { get<T>(url: string): Promise<T>;} it('GOOD: mocks your own abstraction', () => { const httpMock: HttpClient = { get: vi.fn().mockResolvedValue({ name: 'Alice' }), }; const client = new ApiClient(httpMock); const user = await client.getUser(1); expect(httpMock.get).toHaveBeenCalledWith('/users/1');});Ask yourself: 'If I refactor the implementation without changing behavior, should this test break?' If the answer is 'no,' then you may be over-mocking. Mocks should verify behavior contracts, not implementation details.
We've comprehensively covered mocks—test doubles that verify interactions between your code and its collaborators. Let's consolidate the insights:
What's next:
Stubs control inputs; mocks verify outputs. But sometimes you need something in between—a test double with realistic behavior that actually works, just without production-level complexity.
In the next page, we explore fakes—working implementations that are unsuitable for production but provide realistic behavior for testing. Fakes bridge the gap between simple test doubles and full integration testing.
You now have a comprehensive understanding of mocks—their purpose, when to use them, implementation patterns, and common pitfalls. You can confidently verify that your code interacts correctly with its dependencies, testing behaviors that can't be observed through return values alone.