Appearance
Unit Testing
Pragmatic approaches to unit testing that focus on design feedback and fast test execution
Related Concepts: Test-Driven Development | Clean Architecture | Dependency Inversion | Backend Integration Testing
Table of Contents
- The Unit Test Confusion
- Why Mocking is Usually a Smell
- Testing Doesn't Force Good Design
- The Test Pyramid Debate
- Practical Unit Testing Strategy
- Creating Testable Architecture
- Key Principles
The Unit Test Confusion
Three Definitions, One Problem
The software industry can't agree on what a "unit test" actually is. This confusion leads to endless debates and poor testing strategies. Here are the three main schools of thought:
1. Speed-Based Definition (Michael Feathers)
"Unit tests run fast. If they don't run fast, they aren't unit tests."
A unit test:
- Runs in milliseconds
- Doesn't talk to databases
- Doesn't communicate over networks
- Doesn't touch the file system
- Doesn't require special environment configuration
2. Solitary Tests (Mockist School)
Unit tests must:
- Test a single class in complete isolation
- Mock all dependencies
- Never use real collaborator objects
- Focus on implementation details
3. Sociable Tests (Classic School/Martin Fowler)
Unit tests can:
- Use real collaborator objects
- Test a cluster of related classes
- Focus on behavior rather than implementation
- Only mock external dependencies (I/O)
The Pragmatic Choice
We advocate for the speed-based definition with sociable testing. Why? Because:
- Speed is objective and measurable - A test either runs fast or it doesn't
- Sociable tests are more valuable - They test real behavior, not mocked interactions
- Less brittle tests - Changing implementation doesn't break tests
- Better design feedback - Excessive setup reveals actual coupling problems
typescript
// ❌ Solitary test with excessive mocking
it("should calculate order total", () => {
const mockTaxCalculator = jest.fn().mockReturnValue(10);
const mockDiscountService = jest.fn().mockReturnValue(5);
const mockInventory = jest.fn().mockReturnValue(true);
const orderService = new OrderService(
mockTaxCalculator,
mockDiscountService,
mockInventory
);
const total = orderService.calculateTotal(items);
expect(mockTaxCalculator).toHaveBeenCalledWith(items);
expect(mockDiscountService).toHaveBeenCalledWith(items);
expect(total).toBe(105);
});
// ✅ Sociable test focusing on behavior
it("should calculate order total with tax and discount", () => {
const order = new Order(items);
const total = order.calculateTotal();
expect(total).toBe(105);
});Why Mocking is Usually a Smell
Eric Elliott puts it bluntly: "Mocking is a code smell." When you find yourself mocking extensively, it's not a testing problem—it's a design problem.
The Litmus Test for Coupling
Can your unit be tested without mocking dependencies? If not, it's tightly coupled to those dependencies, regardless of whether you use dependency injection.
Dependency Injection Isn't the Solution
Many developers believe dependency injection solves coupling problems. It doesn't:
typescript
// ❌ Still tightly coupled despite dependency injection
class OrderService {
constructor(
private db: Database,
private emailer: EmailService,
private inventory: InventoryService,
private payment: PaymentGateway
) {}
async processOrder(order: Order) {
await this.inventory.reserve(order.items);
await this.payment.charge(order.total);
await this.db.save(order);
await this.emailer.sendConfirmation(order);
}
}
// Testing this requires mocking 4 dependencies!The problem isn't how dependencies are provided—it's that the business logic is entangled with infrastructure concerns.
The Real Solution: Isolate Side Effects
typescript
// ✅ Pure business logic, no mocking needed
class OrderCalculator {
calculateTotal(items: Item[]): OrderTotal {
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
const tax = this.calculateTax(subtotal);
const discount = this.calculateDiscount(items);
return {
subtotal,
tax,
discount,
total: subtotal + tax - discount,
};
}
private calculateTax(amount: number): number {
return amount * 0.08;
}
private calculateDiscount(items: Item[]): number {
// Pure business logic for discounts
return items.length > 5 ? 10 : 0;
}
}
// Separate class handles side effects
class OrderProcessor {
async process(order: Order) {
const total = this.calculator.calculateTotal(order.items);
// Side effects isolated here - test with integration tests
await this.saveOrder(order, total);
await this.sendNotification(order);
}
}Testing Doesn't Force Good Design
The Uncomfortable Truth
There's a popular claim that TDD forces good design. It doesn't. As one of us wrote:
"Testing doesn't force good design, it enables it."
Testing is like a mirror—it shows you what's there, but it doesn't fix what's wrong. If you don't understand design principles, your tests will be as poorly designed as your code.
When Testing Becomes Painful
If you find testing difficult, it's telling you something important:
- Too many mocks? Your code has too many dependencies
- Complex setup? Your objects do too much
- Brittle tests? You're testing implementation, not behavior
- Slow tests? You haven't isolated side effects
The Design Feedback Loop
Good developers use testing pain as a signal:
typescript
// If this test is hard to write...
it("should process payment", () => {
// 50 lines of mock setup
// 20 lines of test
// 30 lines of verification
});
// ...it's telling you to refactor:
it("should calculate payment amount", () => {
const amount = calculatePayment(order);
expect(amount).toBe(100);
});
it("should handle payment processing", () => {
// Integration test for the side effect
});The Test Pyramid Debate
The Classic Pyramid
The traditional test pyramid suggests:
- Many unit tests (70%)
- Some integration tests (20%)
- Few E2E tests (10%)
Alternative Shapes
Recently, alternatives have emerged:
- Test Trophy: More integration tests than unit tests
- Test Honeycomb: Emphasis on integration tests
- Test Diamond: Balanced unit and integration tests
Missing the Point
Martin Fowler observes:
"People love debating what percentage of which type of tests to write, but it's a distraction."
The shape doesn't matter. What matters is:
- Fast feedback - Most tests should run in seconds
- Clear failures - Know immediately what broke and why
- Maintainability - Tests should be easy to update
- Confidence - Tests should catch real bugs
A Pragmatic Approach
Instead of obsessing over percentages:
- Write unit tests for pure logic - Algorithms, calculations, transformations
- Write integration tests for I/O - Database, network, file system
- Write E2E tests for critical paths - Key user interactions
- Delete tests that don't provide value - Maintenance cost > benefit
Practical Unit Testing Strategy
What to Unit Test
Focus unit tests on pure business logic:
typescript
// ✅ Perfect for unit testing
class PriceCalculator {
calculateDiscount(items: Item[], customer: Customer): number {
// Pure business logic
const baseDiscount = this.getVolumeDiscount(items);
const loyaltyBonus = this.getLoyaltyBonus(customer);
return Math.min(baseDiscount + loyaltyBonus, 0.5);
}
}
// ✅ Data transformation
class OrderMapper {
toDTO(order: Order): OrderDTO {
return {
id: order.id,
items: order.items.map(this.mapItem),
total: order.calculateTotal(),
};
}
}
// ✅ Complex algorithms
class RouteOptimizer {
findShortestPath(
start: Location,
end: Location,
constraints: Constraint[]
): Route {
// Complex algorithm worth testing
}
}What NOT to Unit Test
Skip unit tests for:
typescript
// ❌ Simple getters/setters
class User {
private name: string;
getName() {
return this.name;
}
setName(name: string) {
this.name = name;
}
}
// ❌ Framework configuration
@Controller("/users")
class UserController {
@Get("/:id")
getUser(@Param("id") id: string) {
// Just wiring - test in integration tests
}
}
// ❌ Simple delegation
class UserService {
constructor(private repo: UserRepository) {}
findById(id: string) {
return this.repo.findById(id); // No logic to test
}
}The 80/20 Rule
Focus on the 20% of code that provides 80% of the value:
- Core business logic
- Complex calculations
- Critical algorithms
- Data validation rules
Creating Testable Architecture
The Key: Isolate Side Effects
The secret to testable code is separating pure logic from side effects:
typescript
// Domain layer - pure business logic
class OrderDomain {
validateOrder(order: Order): ValidationResult {
// Pure validation logic
}
calculatePricing(order: Order): Pricing {
// Pure calculation
}
applyBusinessRules(order: Order): ProcessedOrder {
// Pure transformation
}
}
// Application layer - orchestrates side effects
class OrderUseCase {
async createOrder(request: CreateOrderRequest) {
// Validate with pure logic
const validation = this.domain.validateOrder(request);
if (!validation.isValid) return validation.errors;
// Calculate with pure logic
const pricing = this.domain.calculatePricing(request);
// Side effects isolated here
const order = await this.repository.save(order);
await this.notifier.notify(order);
return order;
}
}Use Ports and Adapters
Define interfaces based on what your application needs:
typescript
// Port (defined by domain)
interface OrderRepository {
save(order: Order): Promise<Order>;
findById(id: string): Promise<Order | null>;
}
// Adapter (implements infrastructure)
class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<Order> {
// PostgreSQL-specific implementation
}
}
// Easy to test with a simple in-memory adapter
class InMemoryOrderRepository implements OrderRepository {
private orders = new Map<string, Order>();
async save(order: Order): Promise<Order> {
this.orders.set(order.id, order);
return order;
}
}Compose with Pure Functions
Favor composition of pure functions over complex class hierarchies:
typescript
// ✅ Composable pure functions
const calculateTotal = (items: Item[]) =>
items.reduce((sum, item) => sum + item.price, 0);
const applyDiscount = (total: number, discount: number) =>
total * (1 - discount);
const addTax = (amount: number, rate: number) => amount * (1 + rate);
const calculateFinalPrice = (
items: Item[],
discount: number,
taxRate: number
) =>
pipe(
calculateTotal,
(amount) => applyDiscount(amount, discount),
(amount) => addTax(amount, taxRate)
)(items);
// Each function is trivial to test
it("should calculate total", () => {
expect(calculateTotal([{ price: 10 }, { price: 20 }])).toBe(30);
});Key Principles
The 10-Minute Rule
Martin Fowler suggests: "A commit test suite should ideally run in under 10 minutes."
If your tests take longer:
- Too many integration tests - Convert some to unit tests
- Inefficient test setup - Share expensive resources
- Testing too much - Focus on valuable tests
- Poor test design - Refactor for speed
Prefer Sociable Tests
Unless you have a specific reason for isolation:
- Let objects use their real collaborators
- Only mock I/O operations
- Test behavior clusters, not individual classes
- Focus on outcomes, not interactions
Test Behavior, Not Implementation
typescript
// ❌ Testing implementation
it("should call calculateTax method", () => {
const spy = jest.spyOn(calculator, "calculateTax");
calculator.getTotal(100);
expect(spy).toHaveBeenCalledWith(100);
});
// ✅ Testing behavior
it("should include 8% tax in total", () => {
const total = calculator.getTotal(100);
expect(total).toBe(108);
});Use Integration Tests for Side Effects
Don't try to unit test I/O operations:
typescript
// ❌ Mocking database calls
it("should save user", async () => {
const mockDb = jest.fn();
await userService.create(userData);
expect(mockDb.insert).toHaveBeenCalledWith("users", userData);
});
// ✅ Integration test with real database
it("should persist user to database", async () => {
await userService.create(userData);
const user = await db.query("SELECT * FROM users WHERE id = ?", [
userData.id,
]);
expect(user).toMatchObject(userData);
});Keep Tests Simple
If a test is hard to understand, it's a bad test:
typescript
// ❌ Complicated test
it("should process order", () => {
const order = new OrderBuilder()
.withCustomer(
new CustomerBuilder()
.withType("premium")
.withHistory(new HistoryBuilder().withPurchases(10).build())
.build()
)
.withItems([
new ItemBuilder().withPrice(100).build(),
new ItemBuilder().withPrice(50).build(),
])
.build();
// ... 20 more lines
});
// ✅ Simple test
it("should apply premium discount", () => {
const order = createPremiumOrder({ subtotal: 100 });
expect(order.discount).toBe(10);
});Conclusion
Effective unit testing isn't about following rigid rules or achieving metric targets. It's about:
- Writing fast tests that provide rapid feedback
- Recognizing design problems when testing is difficult
- Isolating side effects to create naturally testable code
- Using the right test type for each scenario
- Maintaining pragmatism over dogma
Remember: Tests are a tool to help you build better software. When they stop helping, it's time to reconsider your approach.
The goal isn't 100% coverage or perfect TDD adherence—it's confidence that your code works and can be safely changed.
For Test-Driven Development techniques, see our TDD guide. For implementation-specific guidelines, see our NestJS Testing and React Testing guides.