dTransaction(txId: string, amount: number, currency: string): Promise<void>;
getBalance(accountId: string): Promise<number>;
}
export interface AuditDispatcher {
emitEvent(eventName: string, payload: Record<string, unknown>): Promise<void>;
}
export interface NotificationBroker {
sendReceipt(recipient: string, transactionId: string, total: number): Promise<boolean>;
}
### Step 2: Select the Right Double per Boundary
Each dependency serves a different architectural purpose. The double must match that purpose.
- **LedgerClient**: Provides data. Use a **stub**.
- **AuditDispatcher**: Records side-effects. Use a **spy**.
- **NotificationBroker**: Coordinates external communication. Use a **mock**.
### Step 3: Implement the System Under Test with Dependency Injection
```typescript
// TransactionProcessor.ts
import { LedgerClient, AuditDispatcher, NotificationBroker } from './contracts';
export class TransactionProcessor {
constructor(
private readonly ledger: LedgerClient,
private readonly auditor: AuditDispatcher,
private readonly notifier: NotificationBroker
) {}
async processPayment(accountId: string, amount: number): Promise<{ success: boolean; txId: string }> {
const balance = await this.ledger.getBalance(accountId);
if (balance < amount) {
throw new Error('Insufficient funds');
}
const txId = `TX-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await this.ledger.recordTransaction(txId, amount, 'USD');
await this.auditor.emitEvent('payment.processed', { accountId, amount, txId });
const notified = await this.notifier.sendReceipt(`${accountId}@example.com`, txId, amount);
if (!notified) {
await this.auditor.emitEvent('notification.failed', { txId });
}
return { success: true, txId };
}
}
Step 4: Write Verification-Aligned Tests
// TransactionProcessor.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TransactionProcessor } from './TransactionProcessor';
import { LedgerClient, AuditDispatcher, NotificationBroker } from './contracts';
describe('TransactionProcessor', () => {
let ledgerStub: LedgerClient;
let auditSpy: AuditDispatcher;
let notificationMock: NotificationBroker;
let processor: TransactionProcessor;
beforeEach(() => {
// Stub: provides canned data, no interaction verification
ledgerStub = {
getBalance: vi.fn().mockResolvedValue(500),
recordTransaction: vi.fn().mockResolvedValue(undefined),
};
// Spy: wraps real-ish behavior, records calls for verification
auditSpy = {
emitEvent: vi.fn().mockImplementation(async (event, payload) => {
// Simulate async dispatch without network
return Promise.resolve();
}),
};
// Mock: strict expectations, verifies coordination
notificationMock = {
sendReceipt: vi.fn().mockResolvedValue(true),
};
processor = new TransactionProcessor(ledgerStub, auditSpy, notificationMock);
});
it('completes payment and verifies state + behavior boundaries', async () => {
const result = await processor.processPayment('ACC-100', 200);
// State verification: assert on output contract
expect(result.success).toBe(true);
expect(result.txId).toMatch(/^TX-/);
// Stub verification: confirm data was requested, not how it was used
expect(ledgerStub.getBalance).toHaveBeenCalledWith('ACC-100');
expect(ledgerStub.recordTransaction).toHaveBeenCalledWith(
result.txId,
200,
'USD'
);
// Spy verification: audit trail must contain specific events
expect(auditSpy.emitEvent).toHaveBeenCalledWith(
'payment.processed',
expect.objectContaining({ accountId: 'ACC-100', amount: 200 })
);
// Mock verification: external coordination must succeed
expect(notificationMock.sendReceipt).toHaveBeenCalledTimes(1);
expect(notificationMock.sendReceipt).toHaveBeenCalledWith(
'ACC-100@example.com',
result.txId,
200
);
});
it('handles insufficient funds with correct error propagation', async () => {
ledgerStub.getBalance.mockResolvedValueOnce(50);
await expect(processor.processPayment('ACC-100', 200)).rejects.toThrow(
'Insufficient funds'
);
// Verify no downstream side-effects occurred
expect(ledgerStub.recordTransaction).not.toHaveBeenCalled();
expect(auditSpy.emitEvent).not.toHaveBeenCalled();
expect(notificationMock.sendReceipt).not.toHaveBeenCalled();
});
});
Architecture Decisions & Rationale
- Interface Segregation: Each contract exposes only what the processor needs. This prevents test doubles from exposing irrelevant methods that tempt over-verification.
- Double Classification by Responsibility: Stubs handle data retrieval, spies track audit trails, mocks enforce coordination contracts. Mixing these roles creates ambiguous test intent.
- Strict Verification Mode: Production test runners should enforce strict stubbing. Unconfigured calls or unused stubs fail immediately, catching drift before it compounds.
- Async Sequencing Awareness: All dependencies return promises. Tests explicitly await microtask resolution, preventing race conditions where assertions run before async callbacks complete.
Pitfall Guide
1. The "Mock Everything" Reflex
Explanation: Developers mock internal collaborators, service layers, and utility functions alongside external APIs. This turns unit tests into fragile integration tests that break on every refactor.
Fix: Mock only external boundaries (databases, HTTP clients, message brokers, file systems). Internal logic should be tested through state verification or lightweight fakes.
2. Deep Chain Stubbing
Explanation: Stubbing client.getB().getC().getD() to return a nested value. This violates the Law of Demeter and creates tests that mirror implementation structure rather than business contracts.
Fix: Flatten interfaces. If client needs to expose nested data, add a facade method like client.getSubscriptionDetails(). Tests should assert on the facade, not the chain.
3. Verifying Stub Calls
Explanation: Asserting that a stub was called with specific arguments. Stubs exist to provide data, not to enforce behavior. Verifying them couples tests to call order and frequency.
Fix: Assert on the resulting state or output. If the stub's return value influences logic, verify the logic's output, not the stub invocation.
4. Async Mock Race Conditions
Explanation: Tests complete before async callbacks resolve, or promise rejections are swallowed. This creates intermittent flakes that only appear under CI load.
Fix: Always await async operations. Use explicit sequencing (mockResolvedValueOnce) for multiple calls. Flush microtasks if testing event loops, and configure test runners to fail on unhandled rejections.
5. Treating Fakes as Production-Ready
Explanation: In-memory fakes skip latency, concurrency limits, and network partitions. Tests pass, but production fails under real load.
Fix: Use fakes for unit and contract testing. Pair them with integration suites that run against real dependencies or contract test servers. Document fake limitations explicitly.
6. Partial Mocking as a Permanent Fix
Explanation: Using spies to override single methods on a concrete class to avoid refactoring. This masks design flaws and creates hidden dependencies.
Fix: Treat partial mocks as refactoring stepping stones. Extract the overridden method into a separate dependency, inject it, and remove the partial mock once the boundary is clean.
7. Ignoring Mock Lifecycle Cleanup
Explanation: Mock state persists across tests when using shared instances or global test runners. Subsequent tests inherit stale configurations.
Fix: Reset mocks in beforeEach or afterEach. Prefer creating fresh instances per test. Use test runner auto-mock reset features, but verify they cover all custom spy implementations.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal business logic with clear input/output | State Verification | Survives refactoring, low coupling, fast execution | Low |
| Event publishing, webhook triggering, audit logging | Behavior Verification | Side-effects are the requirement; state alone is insufficient | Medium |
| Multi-step workflows with shared dependencies | In-Memory Fakes | Realistic flow without network/disk overhead | High (upfront), Low (long-term) |
| Legacy untestable classes with tight coupling | Partial Mocks (Temporary) | Isolates side-effects while refactoring boundaries | Medium (technical debt) |
| Third-party API with strict contract | Mock + Contract Test | Verifies interaction locally, validates against real schema remotely | Medium |
Configuration Template
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
// Enforce strict stubbing: fail on unused mocks or unmocked calls
mockReset: true,
clearMocks: true,
restoreMocks: true,
// Fail on unhandled promise rejections to catch async leaks
poolOptions: {
threads: {
singleThread: false,
},
},
// Global setup for consistent async behavior
setupFiles: ['./test/setup.ts'],
},
});
// test/setup.ts
import { vi } from 'vitest';
// Ensure all async operations resolve before assertions
vi.useFakeTimers({ shouldAdvanceTime: true });
// Global mock reset policy
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
Quick Start Guide
- Audit Dependencies: List all external calls in your target module. Create explicit TypeScript interfaces for each.
- Configure Test Runner: Enable strict stubbing, auto-reset, and unhandled rejection failure in your test config.
- Implement Doubles by Role: Replace external calls with stubs (data), spies (audit), or mocks (coordination). Avoid mixing roles.
- Write Verification-Aligned Tests: Assert state for internal logic, behavior for external coordination. Sequence async mocks explicitly.
- Validate in CI: Run the suite with
--run to disable watch mode. Verify strict mode catches drift. Add contract tests for third-party boundaries.
Mastering test doubles is not about framework proficiency. It is about architectural discipline. When doubles align with system boundaries, tests become documentation, refactoring becomes safe, and CI pipelines become reliable. The investment in precise test design compounds across every sprint, turning verification from a bottleneck into a velocity multiplier.