easy-to-test-code
v1.0.0
Published
A demonstration of testable code design with dependency injection
Maintainers
Readme
Code That's Easy to Test
A demonstration of testable code design principles, inspired by The Pragmatic Programmer's advice on writing "Code That's Easy to Test."
Overview
This project showcases a Notification Service built with testability as a first-class concern. It demonstrates how dependency injection, pure functions, and interface-based design make code easy to test, maintain, and reason about.
Tech Stack
- Node.js + TypeScript
- Jest for testing
- Pure dependency injection (no frameworks)
Key Design Principles
1. Dependency Injection
Problem: Hard-coded dependencies (like Date.now(), Math.random(), or HTTP libraries) make code impossible to test in isolation.
Solution: All external dependencies are injected as interfaces.
// ❌ BAD: Hard-coded dependencies
class NotificationService {
sendNotification() {
const now = Date.now(); // Can't control time in tests!
const delay = Math.random(); // Can't predict randomness!
await fetch(url); // Can't mock HTTP calls easily!
}
}
// ✅ GOOD: Dependencies injected
class NotificationService {
constructor(
private clock: Clock,
private random: Random,
private httpClient: HttpClient
) {}
sendNotification() {
const now = this.clock.now(); // Fully controllable in tests!
const delay = this.random.random(); // Predictable in tests!
await this.httpClient.request(...); // Easy to mock!
}
}Benefits:
- Testability: Replace real implementations with fakes/mocks
- Flexibility: Swap implementations without changing core logic
- Test speed: Fake clock doesn't actually wait, tests run instantly
- Determinism: Predictable random values enable exact assertions
2. Interface Segregation
Dependencies are defined as interfaces, not concrete classes:
interface Clock {
now(): number;
sleep(ms: number): Promise<void>;
}This allows:
- Multiple implementations:
SystemClockfor production,FakeClockfor testing - Loose coupling: Service doesn't know or care about implementation details
- Easy mocking: Create test doubles that only implement what's needed
3. Pure Functions Where Possible
Some functions have no side effects and are deterministic:
// Pure function: no side effects, predictable output
private calculateBackoffDelay(attempt: number): number {
const exponentialDelay = this.config.baseRetryDelayMs * Math.pow(2, attempt);
const jitter = this.random.random() * this.config.baseRetryDelayMs;
return Math.min(exponentialDelay + jitter, this.config.maxRetryDelayMs);
}Benefits:
- Easier to test: No need to set up complex state
- Easier to reason about: Input → Output, no hidden behavior
- Reusable: Can be extracted and tested independently
4. Test Doubles (Fakes, Not Just Mocks)
We use fakes - working implementations with simplified behavior:
class FakeClock implements Clock {
private currentTime = 1000;
now() { return this.currentTime; }
async sleep(ms: number) { this.currentTime += ms; } // Instant, controllable
advance(ms: number) { this.currentTime += ms; } // Test helper
}Why fakes over mocks?
- More realistic: Actually implement the interface contract
- Easier to maintain: Don't need to mock every method call
- Better for integration tests: Can use real implementations alongside fakes
Project Structure
src/
├── interfaces/ # Dependency interfaces
│ ├── Clock.ts
│ ├── Random.ts
│ └── HttpClient.ts
├── implementations/ # Real implementations
│ ├── SystemClock.ts
│ └── SystemRandom.ts
├── types/
│ └── Notification.ts
├── NotificationService.ts # Core service
└── __tests__/
├── fakes/ # Test doubles
│ ├── FakeClock.ts
│ ├── FakeRandom.ts
│ └── FakeHttpClient.ts
├── NotificationService.test.ts # Unit tests
└── NotificationService.integration.test.ts # Integration testRunning Tests
# Install dependencies
npm install
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm run test:coverageExample: Testing Retry Logic
Without dependency injection, testing retry logic with exponential backoff is painful:
// ❌ Hard to test
it('should retry with exponential backoff', async () => {
// How do you test this without waiting real seconds?
// How do you verify the exact delay without flakiness?
await service.sendNotification(...);
});With dependency injection, it's straightforward:
// ✅ Easy to test
it('should retry with exponential backoff', async () => {
const clock = new FakeClock(1000);
const random = new FakeRandom();
random.setSequence([0.1, 0.2]); // Predictable jitter
httpClient.setError(new Error('Fail'));
httpClient.setError(new Error('Fail'));
httpClient.setResponse({ status: 200 });
await service.sendNotification(notification);
// Verify exact timing without waiting
expect(clock.getTime()).toBe(3300); // Exact assertion!
});What Makes This Code Easy to Test?
- No Hidden Dependencies: Everything is explicit in the constructor
- Controllable Time: Fake clock allows testing time-dependent logic instantly
- Deterministic Randomness: Fake random enables exact assertions
- Isolated HTTP: Fake HTTP client enables testing without network calls
- Pure Functions: Backoff calculation is testable in isolation
- Clear Interfaces: Well-defined contracts make fakes easy to create
Connection to "The Pragmatic Programmer"
This code follows several principles from The Pragmatic Programmer:
- "Design by Contract": Interfaces define clear contracts
- "Decoupling": Dependencies are injected, not hard-coded
- "Orthogonality": Pure functions are independent and reusable
- "Reversibility": Can swap implementations without changing core logic
- "Testing": "Test early, test often, test automatically" - made possible by testable design
The book emphasizes that code should be easy to test, not testable as an afterthought. By designing with testability in mind from the start, we get:
- Faster tests (no real I/O)
- More reliable tests (deterministic)
- Easier debugging (isolated, controlled)
- Better design (loose coupling, clear contracts)
Real-World Application
While this is a demo, these patterns are used in production systems:
- Microservices: Services often need to test timeouts, retries, rate limiting
- API clients: Need to test error handling, retries, circuit breakers
- Scheduled jobs: Need to test timing logic without waiting
- Event systems: Need to test ordering and timing
Next Steps
- Add more test scenarios (circuit breakers, rate limiting edge cases)
- Implement a real HTTP client (e.g., using
node-fetch) - Add performance tests
- Explore property-based testing with deterministic random
License
MIT
