Loading learning content...
Every non-trivial software system interacts with the outside world. Classes call databases, services invoke APIs, components query external systems. These interactions are essential for production functionality—but they create a fundamental tension with testing.
The testing dilemma:
When your OrderService class calls a PaymentGateway to process transactions, how do you test the order logic independently? You face two unpalatable options:
Neither option is acceptable. What we need is a way to substitute the real dependency with something we control—something that behaves predictably without incurring the costs of the real implementation.
By the end of this page, you will understand stubs as the foundational test double for providing pre-programmed responses. You'll learn when stubs are the right choice, how to design them effectively, and the patterns that make stub-based testing maintainable at scale.
The term test double comes from the film industry concept of a "stunt double"—a person who substitutes for an actor in scenes requiring special skills or involving risk. In software testing, a test double is any object that substitutes for a real component during testing.
Gerard Meszaros, in his seminal work xUnit Test Patterns, categorized test doubles into five types:
In this module, we focus on three of the most important: stubs, mocks, and fakes. Understanding the distinctions between them—and when to use each—is essential for writing effective, maintainable tests.
Many developers use 'mock' as a generic term for any test double. This imprecision causes confusion and leads to suboptimal testing strategies. A stub is fundamentally different from a mock—they serve different purposes and are used in different testing scenarios. Precision in terminology leads to precision in testing.
The fundamental question each double answers:
| Test Double | Primary Question |
|---|---|
| Stub | "What should happen when my code calls this dependency?" |
| Mock | "Did my code call this dependency correctly?" |
| Fake | "How do I get realistic behavior without production complexity?" |
This page focuses entirely on stubs—understanding their purpose, design, implementation, and best practices.
A stub is a test double that provides predetermined responses to method calls. When you stub a dependency, you're saying: "When my code under test calls this method, return this specific value—and don't do anything else."
Stubs are fundamentally about controlling inputs to your system under test. They allow you to:
Key characteristic: Stubs are passive. They respond when called but don't verify anything about the interaction. A stub doesn't care how many times it was called, in what order, or with what arguments (unless that affects the return value).
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// Real dependency - talks to external payment processorinterface PaymentGateway { processPayment(amount: number, currency: string): PaymentResult; refund(transactionId: string): RefundResult;} // Production implementation - expensive, slow, requires networkclass StripePaymentGateway implements PaymentGateway { processPayment(amount: number, currency: string): PaymentResult { // Makes HTTP call to Stripe API // Takes 200-500ms // Costs money // Requires valid credentials return stripeClient.charges.create({ amount, currency }); } refund(transactionId: string): RefundResult { return stripeClient.refunds.create({ charge: transactionId }); }} // STUB - provides pre-programmed responsesclass StubPaymentGateway implements PaymentGateway { private nextPaymentResult: PaymentResult = { success: true, transactionId: "txn_stub_123" }; // Configure what the stub will return willReturnPaymentResult(result: PaymentResult): void { this.nextPaymentResult = result; } processPayment(amount: number, currency: string): PaymentResult { // Ignores arguments (or could use them) // Returns exactly what we configured // No network call, no cost, instant return this.nextPaymentResult; } refund(transactionId: string): RefundResult { return { success: true, refundId: "ref_stub_456" }; }}In this example, the StubPaymentGateway implements the same interface as the real implementation but returns pre-configured values instead of calling external services. The stub is:
Not all stubs are created equal. A poorly designed stub can make tests brittle, confusing, and hard to maintain. A well-designed stub enables clear, focused testing.
Principles of stub design:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// ✅ Well-designed stub: Implements interface, configurable, sensible defaultsinterface UserRepository { findById(id: string): User | null; findByEmail(email: string): User | null; save(user: User): User; delete(id: string): void;} class StubUserRepository implements UserRepository { // Configurable storage for test data private users: Map<string, User> = new Map(); private emailIndex: Map<string, User> = new Map(); // Clear, explicit API for test setup givenUserExists(user: User): this { this.users.set(user.id, user); this.emailIndex.set(user.email, user); return this; // Fluent interface for chaining } givenNoUserExists(): this { this.users.clear(); this.emailIndex.clear(); return this; } // Interface implementation with minimal behavior findById(id: string): User | null { return this.users.get(id) ?? null; // Returns configured data or null } findByEmail(email: string): User | null { return this.emailIndex.get(email) ?? null; } save(user: User): User { // Minimal behavior: just store and return this.users.set(user.id, user); this.emailIndex.set(user.email, user); return user; } delete(id: string): void { const user = this.users.get(id); if (user) { this.emailIndex.delete(user.email); } this.users.delete(id); }} // Usage in testdescribe('UserService', () => { it('should return user profile when user exists', () => { // ARRANGE - Configure stub with test data const stubRepository = new StubUserRepository() .givenUserExists({ id: 'user-123', email: 'alice@example.com', name: 'Alice Smith' }); const userService = new UserService(stubRepository); // ACT const profile = userService.getProfile('user-123'); // ASSERT expect(profile.name).toBe('Alice Smith'); }); it('should throw when user does not exist', () => { // ARRANGE - Stub configured to return no users const stubRepository = new StubUserRepository() .givenNoUserExists(); const userService = new UserService(stubRepository); // ACT & ASSERT expect(() => userService.getProfile('nonexistent')) .toThrow(UserNotFoundException); });});Notice how the stub:
givenUserExists) making tests read like specificationsStubs are the right choice when you need to control what your code receives from a dependency, but you don't care about how your code interacts with that dependency. The focus is on testing your code's logic given certain inputs.
Use stubs when:
Avoid stubs when you need to verify your code calls a dependency correctly. If the correctness of your system depends on making specific calls with specific arguments, you need a mock (which we'll cover next). Similarly, if you need realistic behavior including persistence and complex logic, consider a fake.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
// USE CASE 1: Testing conditional logic based on dependency responseinterface InventoryService { checkStock(productId: string): number;} class StubInventoryService implements InventoryService { private stockLevel: number = 0; givenStockLevel(level: number): this { this.stockLevel = level; return this; } checkStock(productId: string): number { return this.stockLevel; }} describe('OrderProcessor - stock handling', () => { it('should process order when stock is available', () => { const inventory = new StubInventoryService().givenStockLevel(10); const processor = new OrderProcessor(inventory); const result = processor.processOrder({ productId: 'P1', quantity: 5 }); expect(result.status).toBe('CONFIRMED'); }); it('should reject order when stock is insufficient', () => { const inventory = new StubInventoryService().givenStockLevel(2); const processor = new OrderProcessor(inventory); const result = processor.processOrder({ productId: 'P1', quantity: 5 }); expect(result.status).toBe('REJECTED'); expect(result.reason).toBe('Insufficient stock'); });}); // USE CASE 2: Testing error handlinginterface ExternalPricingApi { getPrice(productId: string): Promise<number>;} class StubPricingApi implements ExternalPricingApi { private priceToReturn: number = 0; private shouldThrow: boolean = false; private errorToThrow: Error | null = null; givenPrice(price: number): this { this.priceToReturn = price; this.shouldThrow = false; return this; } givenNetworkError(): this { this.shouldThrow = true; this.errorToThrow = new Error('Network timeout'); return this; } async getPrice(productId: string): Promise<number> { if (this.shouldThrow) { throw this.errorToThrow; } return this.priceToReturn; }} describe('PricingService - error handling', () => { it('should use cached price when API fails', async () => { const api = new StubPricingApi().givenNetworkError(); const cache = new InMemoryCache(); cache.set('P1', 99.99); const service = new PricingService(api, cache); const price = await service.getCurrentPrice('P1'); expect(price).toBe(99.99); // Falls back to cached value });}); // USE CASE 3: Replacing non-deterministic dependenciesinterface TimeProvider { now(): Date;} class StubTimeProvider implements TimeProvider { private frozenTime: Date = new Date(); givenCurrentTime(time: Date): this { this.frozenTime = time; return this; } now(): Date { return this.frozenTime; }} describe('SubscriptionService - expiration', () => { it('should mark subscription as expired after end date', () => { const time = new StubTimeProvider() .givenCurrentTime(new Date('2024-02-01')); const subscription = new Subscription({ startDate: new Date('2024-01-01'), endDate: new Date('2024-01-31'), }); const service = new SubscriptionService(time); expect(service.isActive(subscription)).toBe(false); });});Several patterns have emerged for implementing stubs effectively. Each has its place depending on the complexity of your testing needs.
Pattern 1: The Configurable Stub
The most common pattern—a class with setter methods to configure responses:
1234567891011121314151617181920212223242526272829303132333435363738394041
// Pattern 1: Configurable Stub// Best for: Multiple test scenarios with different configurations class ConfigurableEmailService implements EmailService { private sendResult: SendResult = { success: true, messageId: 'msg-1' }; private shouldThrow: boolean = false; private capturedEmails: Email[] = []; // Optional: capture for verification // Configuration methods willSucceed(): this { this.sendResult = { success: true, messageId: 'msg-' + Date.now() }; this.shouldThrow = false; return this; } willFail(reason: string): this { this.sendResult = { success: false, error: reason }; this.shouldThrow = false; return this; } willThrowError(error: Error): this { this.shouldThrow = true; return this; } // Interface implementation async send(email: Email): Promise<SendResult> { this.capturedEmails.push(email); // Capture for optional inspection if (this.shouldThrow) { throw new Error('Email service unavailable'); } return this.sendResult; } // Helper for tests that need to verify sent emails getSentEmails(): Email[] { return [...this.capturedEmails]; }}Pattern 2: The Inline Anonymous Stub
For simple, one-off scenarios, you can inline the stub implementation:
123456789101112131415161718192021
// Pattern 2: Inline Anonymous Stub// Best for: Simple, one-off test scenarios describe('DiscountCalculator', () => { it('should apply loyalty discount for premium members', () => { // Inline stub - no separate class needed const membershipService: MembershipService = { getMemberTier: (userId: string) => 'PREMIUM', getMemberSince: (userId: string) => new Date('2020-01-01'), }; const calculator = new DiscountCalculator(membershipService); const discount = calculator.calculateDiscount('user-123', 100); expect(discount).toBe(15); // Premium members get 15% });}); // TypeScript's structural typing makes this particularly elegant.// As long as the object has methods matching the interface, it works.Pattern 3: The State Machine Stub
For testing workflows where dependency responses change over time:
1234567891011121314151617181920212223242526272829303132333435363738394041
// Pattern 3: State Machine Stub// Best for: Testing sequences/workflows where responses change class StatefulPaymentGateway implements PaymentGateway { private responses: Queue<PaymentResult> = new Queue(); // Configure a sequence of responses thenReturn(result: PaymentResult): this { this.responses.enqueue(result); return this; } // Shorthand for common sequences firstSucceedsThenFails(): this { return this .thenReturn({ success: true, transactionId: 'first' }) .thenReturn({ success: false, error: 'Declined' }); } processPayment(amount: number, currency: string): PaymentResult { if (this.responses.isEmpty()) { throw new Error('Stub exhausted: no more configured responses'); } return this.responses.dequeue(); }} describe('RetryablePaymentProcessor', () => { it('should retry once on failure then succeed', () => { const gateway = new StatefulPaymentGateway() .thenReturn({ success: false, error: 'Temporary failure' }) .thenReturn({ success: true, transactionId: 'txn-456' }); const processor = new RetryablePaymentProcessor(gateway, maxRetries: 2); const result = processor.processWithRetry(100, 'USD'); expect(result.success).toBe(true); expect(result.transactionId).toBe('txn-456'); });});Pattern 4: The Argument-Matching Stub
When the response should vary based on the arguments received:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// Pattern 4: Argument-Matching Stub// Best for: When different inputs should produce different outputs class ArgumentMatchingUserRepository implements UserRepository { private usersByIdResponse: Map<string, User | null> = new Map(); private defaultResponse: User | null = null; // Configure specific responses for specific inputs givenUserForId(id: string, user: User | null): this { this.usersByIdResponse.set(id, user); return this; } // Configure default when no specific match givenDefaultUser(user: User | null): this { this.defaultResponse = user; return this; } findById(id: string): User | null { // Check for specific match first if (this.usersByIdResponse.has(id)) { return this.usersByIdResponse.get(id) ?? null; } // Fall back to default return this.defaultResponse; }} describe('AuthorizationService', () => { it('should authorize admin actions for admin users only', () => { const adminUser: User = { id: 'admin-1', role: 'ADMIN', ... }; const regularUser: User = { id: 'user-1', role: 'USER', ... }; const repository = new ArgumentMatchingUserRepository() .givenUserForId('admin-1', adminUser) .givenUserForId('user-1', regularUser); const authService = new AuthorizationService(repository); expect(authService.canDeleteUser('admin-1')).toBe(true); expect(authService.canDeleteUser('user-1')).toBe(false); });});While hand-written stubs offer complete control, stubbing libraries reduce boilerplate and provide powerful utilities for common scenarios. Most testing frameworks include stubbing capabilities.
Popular stubbing libraries:
| Language | Library | Key Features |
|---|---|---|
| JavaScript/TypeScript | Jest, Sinon.JS, Vitest | Auto-mocking, stub creation, argument matching |
| Java | Mockito, EasyMock | when().thenReturn() syntax, argument matchers |
| Python | unittest.mock, pytest-mock | patch decorator, MagicMock |
| C# | Moq, NSubstitute | Fluent API, return value configuration |
| Go | testify/mock, gomock | Interface-based mocking |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Modern stubbing with Jest/Vitest import { describe, it, expect, vi } from 'vitest'; describe('OrderService with library stubs', () => { it('should calculate total with tax from pricing service', async () => { // Create a stub from the interface const pricingService: PricingService = { getBasePrice: vi.fn().mockReturnValue(100), getTaxRate: vi.fn().mockReturnValue(0.08), getDiscountRate: vi.fn().mockReturnValue(0), }; const orderService = new OrderService(pricingService); const total = await orderService.calculateTotal('product-1'); expect(total).toBe(108); // Base price + 8% tax }); it('should apply discount when available', async () => { const pricingService: PricingService = { getBasePrice: vi.fn().mockReturnValue(100), getTaxRate: vi.fn().mockReturnValue(0.08), getDiscountRate: vi.fn().mockReturnValue(0.10), // 10% discount }; const orderService = new OrderService(pricingService); const total = await orderService.calculateTotal('product-1'); // 100 * 0.90 (after 10% discount) * 1.08 (with tax) = 97.20 expect(total).toBe(97.20); });}); // More complex stubbing scenarios with Jest describe('PaymentService with conditional stubs', () => { it('should handle different products differently', async () => { const pricingService = { getBasePrice: vi.fn().mockImplementation((productId: string) => { // Return different prices based on product const prices: Record<string, number> = { 'premium-product': 199.99, 'basic-product': 29.99, 'free-product': 0, }; return prices[productId] ?? 49.99; // Default price }), getTaxRate: vi.fn().mockReturnValue(0.08), getDiscountRate: vi.fn().mockReturnValue(0), }; const orderService = new OrderService(pricingService); expect(await orderService.calculateTotal('premium-product')).toBeCloseTo(215.99); expect(await orderService.calculateTotal('basic-product')).toBeCloseTo(32.39); expect(await orderService.calculateTotal('free-product')).toBe(0); });});Hand-written stubs are more verbose but more explicit and easier to debug. Library stubs are concise but can become opaque when complex. For core business logic testing, many teams prefer hand-written stubs for clarity. For simpler scenarios, library stubs reduce boilerplate effectively.
Stubs are powerful but easy to misuse. Recognizing common pitfalls helps you avoid tests that pass incorrectly or break unexpectedly.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// ❌ PITFALL 1: Stub that lies// This stub returns a scenario the real API would never produceclass LyingPaymentStub implements PaymentGateway { processPayment(): PaymentResult { return { success: true, transactionId: 'txn-123', amount: -500, // ❌ Real API never returns negative amounts! }; }} // Tests pass but code might fail in production when it assumes amount >= 0 // ❌ PITFALL 2: Over-stubbing - testing the stub, not the codedescribe('over-stubbed test', () => { it('returns what the stub returns', () => { const stub = { calculate: () => 42, // This IS the test }; const service = new CalculatorService(stub); expect(service.getResult()).toBe(42); // Testing stub, not service logic });}); // ✅ BETTER: Stub provides input, test verifies actual logicdescribe('properly stubbed test', () => { it('doubles the calculated value', () => { const stub = { calculate: () => 21, // Stub provides raw input }; const service = new DoublerService(stub); expect(service.getDoubledResult()).toBe(42); // Tests actual doubling logic });}); // ❌ PITFALL 3: Stub maintenance burden// When interface changes, ALL these must update:class StubUserRepoV1 implements UserRepository { ... }class StubUserRepoV2 implements UserRepository { ... }class TestableUserRepoStub implements UserRepository { ... }class AnotherModuleUserStub implements UserRepository { ... } // ✅ BETTER: Single shared stub, configured per-testclass SharedUserRepositoryStub implements UserRepository { // One stub, many configurations} // ❌ PITFALL 4: Stub state leaking between testslet sharedStub = new ConfigurableStub(); // Danger: shared state describe('test suite with state leak', () => { it('test 1 configures stub', () => { sharedStub.willReturn('value-1'); // ... test ... }); it('test 2 assumes default', () => { // This test might fail or pass depending on test order! // sharedStub still has test 1's configuration });}); // ✅ BETTER: Fresh stub per testdescribe('isolated tests', () => { let stub: ConfigurableStub; beforeEach(() => { stub = new ConfigurableStub(); // Fresh instance each time }); it('test 1', () => { ... }); it('test 2', () => { ... }); // Guaranteed isolated});We've comprehensively covered stubs—the foundational test double for controlling what your code receives from dependencies. Let's consolidate the key insights:
What's next:
Stubs answer the question "What should my code receive from this dependency?" But sometimes we need to answer a different question: "Did my code call this dependency correctly?"
In the next page, we explore mocks—test doubles that verify interactions. Mocks are essential when the correctness of your system depends not just on producing correct outputs, but on making correct calls to its collaborators.
You now have a comprehensive understanding of stubs—their purpose, design, implementation patterns, and pitfalls. You can confidently use stubs to control dependency behavior in your tests, enabling isolated, deterministic verification of your code's logic.