Part 2: Unit Testing with TypeScript

Introduction

Unit testing is the foundation of a solid test suite. In my TypeScript microservices, unit tests make up 70% of my test coverage and catch most bugs before they reach integration testing. They run in seconds, giving instant feedback during development.

This part covers practical unit testing techniques I use daily in production microservices—no theoretical examples, just real patterns that work.

What Makes a Good Unit Test?

A unit test should be:

  1. Fast - Milliseconds, not seconds

  2. Isolated - No external dependencies

  3. Repeatable - Same result every time

  4. Self-validating - Clear pass/fail

  5. Timely - Written with (or before) production code

Example: Order Calculation Service

// src/services/order-calculator.ts
export interface OrderItem {
  productId: string;
  price: number;
  quantity: number;
}

export interface TaxRate {
  state: string;
  rate: number;
}

export class OrderCalculator {
  calculateSubtotal(items: OrderItem[]): number {
    return items.reduce((sum, item) => {
      return sum + (item.price * item.quantity);
    }, 0);
  }

  calculateTax(subtotal: number, taxRate: TaxRate): number {
    return subtotal * taxRate.rate;
  }

  calculateTotal(items: OrderItem[], taxRate: TaxRate): number {
    const subtotal = this.calculateSubtotal(items);
    const tax = this.calculateTax(subtotal, taxRate);
    return subtotal + tax;
  }

  applyDiscount(total: number, discountPercent: number): number {
    if (discountPercent < 0 || discountPercent > 100) {
      throw new Error('Discount must be between 0 and 100');
    }
    return total * (1 - discountPercent / 100);
  }
}

Comprehensive Unit Tests:

Testing Async Code

Most real-world TypeScript code is asynchronous. Here's how I test it effectively.

Example: User Service with Database

Unit Tests with Mocks:

Mocking and Stubbing

When to Mock

I mock:

  • External services (APIs, databases)

  • Time-dependent functions

  • Random number generators

  • File system operations

  • Network calls

Example: Testing Time-Dependent Code

Testing with mocked dates:

Testing Error Handling

Proper error handling is critical in production systems. I always test error paths.

Example: Payment Service

Error handling tests:

Parameterized Tests

When testing multiple similar scenarios, use parameterized tests to reduce duplication.

Parameterized tests:

Test Helpers and Factories

For complex objects, use factory functions to create test data.

Using factories in tests:

Test-Driven Development (TDD)

TDD means writing tests before implementation. In my experience, TDD works best for:

  • Business logic with clear requirements

  • Utility functions with defined inputs/outputs

  • Bug fixes (write failing test, then fix)

TDD Example: Email Validator

Step 1: Write failing test

Step 2: Run tests (they fail)

Step 3: Implement minimum code

Step 4: Tests pass

Step 5: Refactor if needed

Best Practices from Production

1. Keep Tests Fast

2. Test Edge Cases

Always test:

  • Empty inputs

  • Null/undefined

  • Boundary values

  • Maximum/minimum values

  • Special characters

3. Use Descriptive Assertions

4. Don't Test Third-Party Code

Common Pitfalls

1. Over-Mocking

2. Brittle Tests

Key Takeaways

  1. Unit tests are fast and isolated - No external dependencies

  2. Mock external services - Database, APIs, file system

  3. Test error cases - Not just happy paths

  4. Use AAA pattern - Arrange, Act, Assert

  5. Leverage factories - Create test data easily

  6. Test time-dependent code - Use fake timers

  7. TDD when it makes sense - Especially for business logic

  8. Keep tests maintainable - They're code too

What's Next?

In Part 3: Integration Testing, we'll cover:

  • Testing with real databases

  • API integration tests

  • Testing message queues

  • Container-based testing

  • Integration test patterns


This article is part of the Software Testing 101 series.

Last updated