a corresponding specification.
Domain Example: SessionManager
// src/session/SessionManager.ts
export interface SessionConfig {
maxAgeMs: number;
refreshTokenTTL: number;
}
export class SessionManager {
private sessions: Map<string, { createdAt: number; data: Record<string, unknown> }> = new Map();
constructor(private config: SessionConfig) {}
createSession(userId: string, metadata: Record<string, unknown>): string {
const sessionId = crypto.randomUUID();
this.sessions.set(sessionId, {
createdAt: Date.now(),
data: { userId, ...metadata },
});
return sessionId;
}
isSessionValid(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
const age = Date.now() - session.createdAt;
return age <= this.config.maxAgeMs;
}
invalidateSession(sessionId: string): boolean {
return this.sessions.delete(sessionId);
}
}
TDD Execution:
- RED: Write failing test for session creation
// tests/unit/SessionManager.test.ts
import { SessionManager } from '../../src/session/SessionManager';
describe('SessionManager', () => {
const config = { maxAgeMs: 3600000, refreshTokenTTL: 86400000 };
let manager: SessionManager;
beforeEach(() => { manager = new SessionManager(config); });
it('should generate a unique session ID and store metadata', () => {
const id = manager.createSession('usr_42', { role: 'admin' });
expect(id).toBeDefined();
expect(typeof id).toBe('string');
expect(id.length).toBeGreaterThan(0);
});
});
- GREEN: Implement minimum viable logic (already shown above)
- REFACTOR: Extract validation logic, add type safety, optimize storage
- REPEAT: Add
isSessionValid and invalidateSession tests, drive implementation forward
This cycle guarantees that code is written to satisfy requirements, not assumptions. Tests become living documentation that evolves alongside the codebase.
Step 3: Integration & E2E Patterns
Integration tests verify module collaboration without hitting real infrastructure. E2E tests validate HTTP contracts against a running server instance.
Integration Example: InvoiceProcessor
// src/billing/InvoiceProcessor.ts
import { TaxCalculator } from './TaxCalculator';
import { InvoiceRepository } from '../repositories/InvoiceRepository';
export class InvoiceProcessor {
constructor(
private taxCalc: TaxCalculator,
private repo: InvoiceRepository
) {}
async generateInvoice(userId: string, items: Array<{ id: string; amount: number }>) {
const subtotal = items.reduce((sum, i) => sum + i.amount, 0);
const tax = this.taxCalc.compute(subtotal);
const total = subtotal + tax;
const invoice = await this.repo.save({ userId, items, total, status: 'draft' });
return invoice;
}
}
// tests/integration/InvoiceProcessor.test.ts
import { InvoiceProcessor } from '../../src/billing/InvoiceProcessor';
import { TaxCalculator } from '../../src/billing/TaxCalculator';
import { InvoiceRepository } from '../../src/repositories/InvoiceRepository';
jest.mock('../../src/billing/TaxCalculator');
jest.mock('../../src/repositories/InvoiceRepository');
describe('InvoiceProcessor Integration', () => {
let processor: InvoiceProcessor;
let mockTaxCalc: jest.Mocked<TaxCalculator>;
let mockRepo: jest.Mocked<InvoiceRepository>;
beforeEach(() => {
mockTaxCalc = new TaxCalculator() as jest.Mocked<TaxCalculator>;
mockRepo = new InvoiceRepository() as jest.Mocked<InvoiceRepository>;
processor = new InvoiceProcessor(mockTaxCalc, mockRepo);
jest.clearAllMocks();
});
it('should calculate tax and persist invoice correctly', async () => {
mockTaxCalc.compute.mockReturnValue(15.5);
mockRepo.save.mockResolvedValue({ id: 'inv_99', total: 115.5, status: 'draft' });
const result = await processor.generateInvoice('usr_10', [
{ id: 'item_a', amount: 50 },
{ id: 'item_b', amount: 50 },
]);
expect(mockTaxCalc.compute).toHaveBeenCalledWith(100);
expect(mockRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ total: 115.5, status: 'draft' })
);
expect(result.total).toBe(15.5 + 100);
});
});
E2E Example: NotificationRouter API
// tests/e2e/notifications.e2e.test.ts
import request from 'supertest';
import { createTestServer } from '../../src/server';
const app = createTestServer();
describe('POST /api/v1/notifications', () => {
it('should accept valid payload and return 201', async () => {
const res = await request(app)
.post('/api/v1/notifications')
.send({ userId: 'usr_55', channel: 'email', template: 'welcome' })
.expect('Content-Type', /json/)
.expect(201);
expect(res.body).toMatchObject({
id: expect.any(String),
status: 'queued',
channel: 'email',
});
});
it('should reject missing required fields with 400', async () => {
const res = await request(app)
.post('/api/v1/notifications')
.send({ channel: 'sms' })
.expect(400);
expect(res.body.errors).toBeDefined();
expect(res.body.errors).toContain('userId is required');
});
});
Architecture Decisions & Rationale
- TypeScript over JavaScript: Enforces contract stability, reduces runtime type errors, and improves IDE autocomplete for test assertions.
- Class-based domain models: Encapsulate behavior, enable dependency injection, and simplify mocking boundaries.
- Explicit dependency injection: Services receive collaborators via constructor, making unit testing trivial and avoiding hidden global state.
- Jest module mocking:
jest.mock() hoists automatically, ensuring dependencies are replaced before import evaluation. This prevents accidental real network calls during integration tests.
- Separation of test directories:
tests/unit, tests/integration, tests/e2e enforces execution boundaries and enables selective CI pipelines.
Pitfall Guide
1. Testing Implementation Details Instead of Behavior
Explanation: Asserting internal state, private methods, or exact function call counts couples tests to code structure. Refactoring breaks tests even when behavior remains correct.
Fix: Assert outputs, side effects, and public contracts. Use expect(fn).toHaveBeenCalledWith() only when the call itself is the business requirement, not an implementation artifact.
2. Over-Mocking External Dependencies
Explanation: Mocking everything creates a "mock world" that diverges from reality. Tests pass locally but fail in staging because mocked contracts don't match actual APIs.
Fix: Mock only at system boundaries. Use contract testing or provider packages for third-party services. Keep integration tests close to production behavior.
3. Ignoring Asynchronous Boundaries
Explanation: Forgetting await, missing return, or mixing callbacks with promises causes tests to complete before assertions run. False positives mask real failures.
Fix: Always await async functions. Use expect(asyncFn()).rejects.toThrow() for error paths. Enable --detectOpenHandles in CI to catch unresolved promises.
4. Flaky Timer & Race Condition Tests
Explanation: Real setTimeout/setInterval introduces timing variance. Tests pass on fast machines but fail under CI load.
Fix: Use jest.useFakeTimers() and jest.advanceTimersByTime(). Control time deterministically. Never rely on sleep() or arbitrary delays.
5. Neglecting Coverage Thresholds in CI
Explanation: Running tests without enforcement allows coverage to decay. Teams accumulate untested critical paths over time.
Fix: Configure coverageThreshold in jest.config.ts. Fail CI on threshold breaches. Track coverage trends, not just absolute numbers.
6. Callback/Promise Interop Conflicts
Explanation: Mixing done() callbacks with async/await creates unpredictable execution order. Jest may report false passes or timeout errors.
Fix: Standardize on async/await. Remove done entirely unless testing legacy callback-only libraries. Convert callbacks with util.promisify or wrapper functions.
7. Shared Mutable State Across Test Suites
Explanation: Global variables, singleton instances, or uncleaned databases cause test interference. One test's side effect breaks another's assumptions.
Fix: Use beforeEach/afterEach for state reset. Instantiate fresh objects per test. Clean databases between suites. Avoid module-level mutable state.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Pure business logic (calculations, transformations) | Unit Tests | Fast, isolated, zero infrastructure | Low (milliseconds per test) |
| Service β Repository β External API contract | Integration Tests | Validates data flow and boundary behavior | Medium (requires mock/test DB) |
| Full HTTP request/response cycle | E2E/API Tests | Catches routing, middleware, and serialization bugs | High (requires running server) |
| Third-party SDK integration | Contract Tests + Integration | Prevents mock drift; verifies real API compatibility | Medium-High (network latency) |
| Legacy callback-based modules | Wrapper + Async/Await | Standardizes testing; removes done() flakiness | Low (one-time refactor) |
Configuration Template
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: [
'**/unit/**/*.test.ts',
'**/integration/**/*.test.ts',
'**/e2e/**/*.e2e.test.ts',
],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/index.ts',
'!src/server.ts',
],
coverageThreshold: {
global: {
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
},
coverageReporters: ['text', 'lcov', 'clover'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
moduleNameMapper: {
'^@src/(.*)$': '<rootDir>/src/$1',
'^@tests/(.*)$': '<rootDir>/tests/$1',
},
detectOpenHandles: true,
forceExit: true,
maxWorkers: '50%',
};
export default config;
Quick Start Guide
- Initialize project:
npm init -y && npm install -D jest ts-jest @types/jest typescript supertest
- Create config: Add
jest.config.ts using the template above; run npx ts-jest config:init to generate tsconfig.json
- Add scripts: Update
package.json with "test": "jest --passWithNoTests", "test:watch": "jest --watch", "test:coverage": "jest --coverage"
- Write first test: Create
tests/unit/SessionManager.test.ts, run npm test, verify failure, implement minimum, verify pass
- Integrate CI: Add
npm test -- --ci --coverage to your pipeline; enforce coverage thresholds on merge requests
This framework transforms testing from an afterthought into a structural guarantee. By enforcing boundaries, automating verification, and treating tests as executable contracts, teams ship faster, debug less, and maintain codebases that scale with confidence.