Skip to content

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

  1. The Unit Test Confusion
  2. Why Mocking is Usually a Smell
  3. Testing Doesn't Force Good Design
  4. The Test Pyramid Debate
  5. Practical Unit Testing Strategy
  6. Creating Testable Architecture
  7. 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:

  1. Speed is objective and measurable - A test either runs fast or it doesn't
  2. Sociable tests are more valuable - They test real behavior, not mocked interactions
  3. Less brittle tests - Changing implementation doesn't break tests
  4. 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:

  1. Fast feedback - Most tests should run in seconds
  2. Clear failures - Know immediately what broke and why
  3. Maintainability - Tests should be easy to update
  4. Confidence - Tests should catch real bugs

A Pragmatic Approach

Instead of obsessing over percentages:

  1. Write unit tests for pure logic - Algorithms, calculations, transformations
  2. Write integration tests for I/O - Database, network, file system
  3. Write E2E tests for critical paths - Key user interactions
  4. 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:

  1. Too many integration tests - Convert some to unit tests
  2. Inefficient test setup - Share expensive resources
  3. Testing too much - Focus on valuable tests
  4. 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:

  1. Writing fast tests that provide rapid feedback
  2. Recognizing design problems when testing is difficult
  3. Isolating side effects to create naturally testable code
  4. Using the right test type for each scenario
  5. 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.