Writing Effective Unit Tests

Strategies and patterns for writing high-quality, maintainable tests

What Makes a Test Effective?

Effective tests are like good navigation systems - they guide you to your destination without getting in your way or sending you down dead ends. They have several key characteristics:

graph LR A[Effective Tests] --> B[Trustworthy] A --> C[Maintainable] A --> D[Readable] A --> E[Fast] A --> F[Focused] style A fill:#f5f5f5,stroke:#333333 style B fill:#d1e7dd,stroke:#0f5132 style C fill:#d1e7dd,stroke:#0f5132 style D fill:#d1e7dd,stroke:#0f5132 style E fill:#d1e7dd,stroke:#0f5132 style F fill:#d1e7dd,stroke:#0f5132

The Cost of Poor Tests

A well-known online retailer discovered that 30% of their development time was spent maintaining brittle tests that were constantly breaking due to minor changes in implementation details. After refactoring their tests to focus on behavior rather than implementation, this dropped to just 5%.

Test Structure and Organization

The Four-Phase Test Pattern

A well-structured test has four distinct phases, sometimes called the "Four A's":

  1. Arrange: Set up the test conditions and inputs
  2. Act: Execute the code being tested
  3. Assert: Verify the results match expectations
  4. Aftermath: Clean up resources (if necessary)
test('cart calculates correct total with discount', () => {
  // Arrange
  const cart = new ShoppingCart();
  cart.addItem({ id: 1, name: 'Widget', price: 10.00 }, 2);
  cart.addItem({ id: 2, name: 'Gadget', price: 5.00 }, 1);
  const discount = { code: 'SAVE20', percentage: 20 };
  
  // Act
  cart.applyDiscount(discount);
  const total = cart.calculateTotal();
  
  // Assert
  expect(total).toBe(20.00);
  
  // Aftermath (if needed)
  // cart.reset();
});

Logical Grouping with describe()

Group related tests to create a clear hierarchy of test cases:

describe('UserService', () => {
  describe('registration', () => {
    test('creates new user with valid data', () => { /* ... */ });
    test('rejects duplicate usernames', () => { /* ... */ });
    test('requires password with minimum length', () => { /* ... */ });
  });
  
  describe('authentication', () => {
    test('generates token for valid credentials', () => { /* ... */ });
    test('rejects invalid passwords', () => { /* ... */ });
    test('locks account after failed attempts', () => { /* ... */ });
  });
});

Using Setup and Teardown Effectively

Extract common setup and cleanup code to keep tests DRY (Don't Repeat Yourself):

describe('DatabaseService', () => {
  let dbService;
  let testConnection;
  
  // Global setup for all tests in this group
  beforeAll(async () => {
    testConnection = await createTestDatabase();
  });
  
  // Setup for each individual test
  beforeEach(() => {
    dbService = new DatabaseService(testConnection);
    return dbService.clearAllTables();
  });
  
  // Cleanup after each test
  afterEach(() => {
    return dbService.clearAllTables();
  });
  
  // Global teardown
  afterAll(async () => {
    await testConnection.close();
  });
  
  test('stores user in database', async () => {
    const user = { name: 'Alice', email: 'alice@example.com' };
    await dbService.saveUser(user);
    const savedUser = await dbService.findUserByEmail('alice@example.com');
    expect(savedUser.name).toBe('Alice');
  });
  
  // More tests...
});

Scope of Setup and Teardown

Be mindful of what you put in setup and teardown blocks:

  • beforeAll/afterAll: Expensive operations that can be shared (opening database connections, starting servers)
  • beforeEach/afterEach: Creating fresh test data, resetting state between tests

Only move code to setup when it's truly shared across multiple tests. Test-specific setup should remain in the test body for clarity.

Testing Patterns and Techniques

Testing Pure Functions

Pure functions (those that always return the same output for the same input without side effects) are the easiest to test:

// The function to test
function calculateDiscount(price, discountPercentage) {
  if (price <= 0 || discountPercentage < 0 || discountPercentage > 100) {
    throw new Error('Invalid input');
  }
  return price * (1 - discountPercentage / 100);
}

// Tests
describe('calculateDiscount', () => {
  test('applies discount correctly', () => {
    expect(calculateDiscount(100, 20)).toBe(80);
    expect(calculateDiscount(50, 10)).toBe(45);
    expect(calculateDiscount(200, 0)).toBe(200);
  });
  
  test('handles floating point values', () => {
    expect(calculateDiscount(99.99, 15)).toBeCloseTo(84.99);
  });
  
  test('throws error on invalid inputs', () => {
    expect(() => calculateDiscount(-50, 10)).toThrow('Invalid input');
    expect(() => calculateDiscount(50, -10)).toThrow('Invalid input');
    expect(() => calculateDiscount(50, 110)).toThrow('Invalid input');
  });
});

Parameterized Tests

When you need to test a function with multiple inputs and expected outputs, parameterized tests reduce repetition:

describe('validateEmail', () => {
  // Using test.each for parameterized tests
  test.each([
    // [input, expected result]
    ['valid@example.com', true],
    ['firstname.lastname@domain.com', true],
    ['email@subdomain.domain.com', true],
    ['', false],
    ['invalid.email', false],
    ['@missing-username.com', false],
    ['spaces in@email.com', false],
    ['missing@domain', false]
  ])('validateEmail(%s) should return %s', (email, expected) => {
    expect(validateEmail(email)).toBe(expected);
  });
});

Testing State Changes

For functions that change state, test both the state change and the return value:

class Counter {
  constructor() {
    this.count = 0;
  }
  
  increment() {
    this.count += 1;
    return this.count;
  }
  
  decrement() {
    this.count -= 1;
    return this.count;
  }
  
  reset() {
    this.count = 0;
    return this.count;
  }
}

describe('Counter', () => {
  let counter;
  
  beforeEach(() => {
    counter = new Counter();
  });
  
  test('should start at zero', () => {
    expect(counter.count).toBe(0);
  });
  
  test('increment should add one and return new value', () => {
    const returnValue = counter.increment();
    
    expect(counter.count).toBe(1); // Test state change
    expect(returnValue).toBe(1);   // Test return value
  });
  
  test('decrement should subtract one and return new value', () => {
    counter.increment(); // Setup: count = 1
    
    const returnValue = counter.decrement();
    
    expect(counter.count).toBe(0); // Test state change
    expect(returnValue).toBe(0);   // Test return value
  });
  
  test('reset should set count to zero and return zero', () => {
    counter.increment();
    counter.increment(); // Setup: count = 2
    
    const returnValue = counter.reset();
    
    expect(counter.count).toBe(0); // Test state change
    expect(returnValue).toBe(0);   // Test return value
  });
});

Testing Code That Makes API Calls

Use mocks to isolate your code from external dependencies:

// userService.js
class UserService {
  constructor(apiClient) {
    this.apiClient = apiClient;
  }
  
  async getUserProfile(userId) {
    try {
      const response = await this.apiClient.get(`/users/${userId}`);
      return {
        id: response.data.id,
        name: response.data.name,
        email: response.data.email,
        isAdmin: response.data.role === 'admin'
      };
    } catch (error) {
      if (error.response && error.response.status === 404) {
        return null;
      }
      throw new Error(`Failed to get user: ${error.message}`);
    }
  }
}

// userService.test.js
describe('UserService', () => {
  let userService;
  let mockApiClient;
  
  beforeEach(() => {
    mockApiClient = {
      get: jest.fn()
    };
    userService = new UserService(mockApiClient);
  });
  
  describe('getUserProfile', () => {
    test('transforms user data correctly', async () => {
      // Mock the API response
      mockApiClient.get.mockResolvedValue({
        data: {
          id: '123',
          name: 'John Doe',
          email: 'john@example.com',
          role: 'admin',
          created_at: '2023-01-01T00:00:00Z',
          updated_at: '2023-01-02T00:00:00Z',
          // Additional fields not needed by our app
          address: '123 Main St',
          phone: '555-1234'
        }
      });
      
      const profile = await userService.getUserProfile('123');
      
      expect(mockApiClient.get).toHaveBeenCalledWith('/users/123');
      expect(profile).toEqual({
        id: '123',
        name: 'John Doe',
        email: 'john@example.com',
        isAdmin: true
      });
    });
    
    test('returns null for non-existent users', async () => {
      // Mock a 404 response
      const error = new Error('Not found');
      error.response = { status: 404 };
      mockApiClient.get.mockRejectedValue(error);
      
      const profile = await userService.getUserProfile('999');
      
      expect(profile).toBeNull();
    });
    
    test('rethrows other errors', async () => {
      // Mock a network error
      mockApiClient.get.mockRejectedValue(new Error('Network failure'));
      
      await expect(userService.getUserProfile('123'))
        .rejects.toThrow('Failed to get user: Network failure');
    });
  });
});

Testing Edge Cases and Boundaries

Testing isn't just about the happy path - thorough testing includes edge cases and boundary conditions. Think of it like crash-testing a car at different angles, not just head-on.

Common Edge Cases to Test

Example: Testing a Payment Processing Function

// The function to test
function processPayment(amount, cardDetails) {
  if (!amount || typeof amount !== 'number' || amount <= 0) {
    throw new Error('Invalid payment amount');
  }
  
  if (!cardDetails || !cardDetails.number || !cardDetails.expiryDate || !cardDetails.cvv) {
    throw new Error('Missing card details');
  }
  
  if (!/^\d{16}$/.test(cardDetails.number)) {
    throw new Error('Invalid card number');
  }
  
  // Simplified payment processing logic
  return {
    success: true,
    transactionId: 'txn_' + Math.random().toString(36).substr(2, 9),
    amount: amount,
    fee: amount * 0.029 + 0.30, // 2.9% + 30ยข fee
    netAmount: amount - (amount * 0.029 + 0.30)
  };
}

// Tests
describe('processPayment', () => {
  const validCard = {
    number: '4111111111111111',
    expiryDate: '12/25',
    cvv: '123'
  };
  
  test('processes valid payment', () => {
    const result = processPayment(100, validCard);
    
    expect(result.success).toBe(true);
    expect(result.transactionId).toMatch(/^txn_/);
    expect(result.amount).toBe(100);
    expect(result.fee).toBeCloseTo(3.20);
    expect(result.netAmount).toBeCloseTo(96.80);
  });
  
  describe('edge cases', () => {
    test('rejects zero amount', () => {
      expect(() => processPayment(0, validCard)).toThrow('Invalid payment amount');
    });
    
    test('rejects negative amount', () => {
      expect(() => processPayment(-50, validCard)).toThrow('Invalid payment amount');
    });
    
    test('rejects non-number amount', () => {
      expect(() => processPayment('100', validCard)).toThrow('Invalid payment amount');
    });
    
    test('rejects undefined amount', () => {
      expect(() => processPayment(undefined, validCard)).toThrow('Invalid payment amount');
    });
    
    test('handles very small payment amounts', () => {
      const result = processPayment(0.01, validCard);
      expect(result.success).toBe(true);
      expect(result.netAmount).toBeCloseTo(-0.29);
    });
    
    test('handles very large payment amounts', () => {
      const result = processPayment(1000000, validCard);
      expect(result.success).toBe(true);
      expect(result.fee).toBeCloseTo(29000.30);
      expect(result.netAmount).toBeCloseTo(970999.70);
    });
    
    test('rejects missing card details', () => {
      expect(() => processPayment(100)).toThrow('Missing card details');
      expect(() => processPayment(100, {})).toThrow('Missing card details');
      expect(() => processPayment(100, { number: '4111111111111111' })).toThrow('Missing card details');
    });
    
    test('validates card number format', () => {
      expect(() => processPayment(100, { ...validCard, number: '411' })).toThrow('Invalid card number');
      expect(() => processPayment(100, { ...validCard, number: 'abcdefghijklmnop' })).toThrow('Invalid card number');
    });
  });
});

CORRECT Boundary Testing

The CORRECT acronym helps remember important boundary conditions:

  • Conformance - Does the value conform to an expected format?
  • Ordering - Is the set of values ordered or unordered as appropriate?
  • Range - Is the value within acceptable range boundaries?
  • Reference - Does the code reference anything external that isn't controlled by the code itself?
  • Existence - Does the value exist (e.g., non-null, non-zero, present in a set)?
  • Cardinality - Are there exactly enough values?
  • Time - Is everything happening in the right order? At the right time?

Test Doubles: Mocks, Stubs, Spies, and Fakes

Test doubles replace real objects in tests to isolate the code being tested. Each type serves a different purpose:

graph TD A[Test Doubles] --> B[Dummy] A --> C[Stub] A --> D[Spy] A --> E[Mock] A --> F[Fake] style A fill:#f5f5f5,stroke:#333333 style B fill:#cfe2ff,stroke:#084298 style C fill:#d1e7dd,stroke:#0f5132 style D fill:#fff3cd,stroke:#856404 style E fill:#f8d7da,stroke:#721c24 style F fill:#e2e3e5,stroke:#41464b

Dummy

Objects that are passed around but never actually used. They're just used to fill parameter lists.

// Example: A dummy logger that does nothing
const dummyLogger = {
  log: () => {}
};

test('processOrder works without logging', () => {
  const result = orderProcessor.processOrder(order, dummyLogger);
  expect(result.success).toBe(true);
});

Stub

Provides predefined answers to calls made during the test, usually not responding to anything outside what's programmed for the test.

// A stub that simulates a payment gateway
const paymentGatewayStub = {
  processPayment: () => ({
    success: true,
    transactionId: 'test-transaction-123'
  })
};

test('completeOrder succeeds when payment is successful', async () => {
  const order = new Order({ items: [{id: 1, quantity: 1}], total: 100 });
  
  const result = await orderService.completeOrder(order, paymentGatewayStub);
  
  expect(result.status).toBe('completed');
  expect(result.paymentId).toBe('test-transaction-123');
});

Spy

Records calls to functions but lets the original implementation run. Used to verify function calls without changing behavior.

test('confirmOrder calls email service with correct data', async () => {
  // Create a spy on the sendEmail method
  const emailServiceSpy = jest.spyOn(emailService, 'sendEmail');
  
  // Run the code that should call sendEmail
  await orderService.confirmOrder('order-123');
  
  // Verify sendEmail was called correctly
  expect(emailServiceSpy).toHaveBeenCalled();
  expect(emailServiceSpy).toHaveBeenCalledWith(
    expect.objectContaining({
      subject: 'Order Confirmation',
      orderId: 'order-123'
    })
  );
  
  // Restore the original implementation
  emailServiceSpy.mockRestore();
});

Mock

Pre-programmed with expectations about the calls they should receive and can throw exceptions when unexpected calls are made.

test('processRefund validates refund before calling payment gateway', async () => {
  // Create a mock validation service
  const validationService = {
    validateRefund: jest.fn()
  };
  
  // Create a mock payment gateway
  const paymentGateway = {
    refundTransaction: jest.fn()
  };
  
  // Set up the mock to return that the refund is invalid
  validationService.validateRefund.mockReturnValue(false);
  
  // Try to process a refund
  await refundService.processRefund({
    orderId: 'order-123',
    amount: 50,
    reason: 'Customer request'
  }, validationService, paymentGateway);
  
  // Validation should be called
  expect(validationService.validateRefund).toHaveBeenCalled();
  
  // Payment gateway should NOT be called if validation fails
  expect(paymentGateway.refundTransaction).not.toHaveBeenCalled();
});

Fake

Implements the same interface as the real object but in a simplified way, suitable for tests.

// A fake database that stores data in memory
class FakeUserDatabase {
  constructor() {
    this.users = new Map();
  }
  
  async findById(id) {
    return this.users.get(id) || null;
  }
  
  async save(user) {
    this.users.set(user.id, user);
    return user;
  }
  
  async delete(id) {
    return this.users.delete(id);
  }
}

test('userService updates user profile correctly', async () => {
  // Setup the fake database
  const fakeDb = new FakeUserDatabase();
  await fakeDb.save({ id: '123', name: 'Original Name', email: 'test@example.com' });
  
  const userService = new UserService(fakeDb);
  
  // Update the user
  await userService.updateProfile('123', { name: 'New Name' });
  
  // Verify the update was saved
  const updatedUser = await fakeDb.findById('123');
  expect(updatedUser.name).toBe('New Name');
  expect(updatedUser.email).toBe('test@example.com'); // Unchanged
});

Choosing the Right Test Double

The type of test double to use depends on what you're testing:

  • Use stubs when you need specific values to be returned
  • Use spies when you want to verify function calls without changing behavior
  • Use mocks when you need to verify complex interactions and strict expectations
  • Use fakes for complex dependencies that need more realistic behavior than simple stubs

Common Testing Anti-Patterns

Avoid these common testing pitfalls that lead to brittle, slow, or misleading tests:

Testing Implementation Details

Tests should verify behavior, not how that behavior is implemented.

Bad Example

test('saveUser calls database with formatted user', () => {
  const dbSpy = jest.spyOn(database, 'save');
  const formatSpy = jest.spyOn(userFormatter, 'format');
  
  userService.saveUser({ name: 'John', email: 'john@example.com' });
  
  expect(formatSpy).toHaveBeenCalled();
  expect(dbSpy).toHaveBeenCalled();
});

This test breaks if the implementation changes to use a different formatting method or database approach, even if the behavior remains correct.

Good Example

test('saveUser persists user data', async () => {
  await userService.saveUser({ name: 'John', email: 'john@example.com' });
  
  const savedUser = await userService.findUserByEmail('john@example.com');
  expect(savedUser).toBeTruthy();
  expect(savedUser.name).toBe('John');
});

This test verifies the behavior (user is saved and can be retrieved) without caring about the implementation details.

Over-Mocking

Too many mocks make tests complex and brittle.

Bad Example

test('checkout process', async () => {
  // Excessive mocking
  const cartServiceMock = { getItems: jest.fn(), clear: jest.fn() };
  const inventoryServiceMock = { checkAvailability: jest.fn(), updateStock: jest.fn() };
  const paymentServiceMock = { processPayment: jest.fn() };
  const emailServiceMock = { sendConfirmation: jest.fn() };
  const loggerMock = { log: jest.fn() };
  
  cartServiceMock.getItems.mockReturnValue([{ id: 1, quantity: 2 }]);
  inventoryServiceMock.checkAvailability.mockReturnValue(true);
  paymentServiceMock.processPayment.mockReturnValue({ success: true });
  
  const orderService = new OrderService(
    cartServiceMock,
    inventoryServiceMock,
    paymentServiceMock,
    emailServiceMock,
    loggerMock
  );
  
  await orderService.checkout(userId, paymentDetails);
  
  expect(cartServiceMock.getItems).toHaveBeenCalled();
  expect(inventoryServiceMock.checkAvailability).toHaveBeenCalled();
  expect(paymentServiceMock.processPayment).toHaveBeenCalled();
  expect(emailServiceMock.sendConfirmation).toHaveBeenCalled();
  expect(cartServiceMock.clear).toHaveBeenCalled();
});

Better Example

// Create a fake order processor that implements needed functionality
class TestOrderProcessor {
  constructor() {
    this.orders = [];
    this.inventory = new Map([
      [1, 10], // Item 1 has 10 units
      [2, 5]   // Item 2 has 5 units
    ]);
  }
  
  async checkout(userId, cart, paymentDetails) {
    // Check inventory
    for (const item of cart.items) {
      if (!this.inventory.has(item.id) || this.inventory.get(item.id) < item.quantity) {
        throw new Error('Insufficient inventory');
      }
    }
    
    // "Process payment" (just validate it has required fields)
    if (!paymentDetails.cardNumber || !paymentDetails.expiryDate) {
      throw new Error('Invalid payment details');
    }
    
    // Create order
    const order = {
      id: `order-${Date.now()}`,
      userId,
      items: cart.items,
      total: cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
      status: 'completed'
    };
    
    // Save order and update inventory
    this.orders.push(order);
    for (const item of cart.items) {
      this.inventory.set(item.id, this.inventory.get(item.id) - item.quantity);
    }
    
    return order;
  }
  
  getOrder(orderId) {
    return this.orders.find(order => order.id === orderId);
  }
  
  getInventory(itemId) {
    return this.inventory.get(itemId);
  }
}

test('checkout process creates order and updates inventory', async () => {
  const processor = new TestOrderProcessor();
  
  const cart = {
    items: [
      { id: 1, name: 'Widget', price: 10, quantity: 2 }
    ]
  };
  
  const paymentDetails = {
    cardNumber: '4111111111111111',
    expiryDate: '12/25'
  };
  
  const order = await processor.checkout('user-123', cart, paymentDetails);
  
  // Verify order was created
  expect(order.status).toBe('completed');
  expect(order.total).toBe(20);
  
  // Verify inventory was updated
  expect(processor.getInventory(1)).toBe(8);
});

This approach tests the behavior using a simplified implementation rather than excessive mocking.

Brittle Tests

Tests that break when unrelated things change create maintenance burden.

Bad Example

test('user profile renders correctly', () => {
  const wrapper = render(<UserProfile user={user} />);
  
  expect(wrapper.html()).toBe(
    '<div class="profile"><h2>John Doe</h2><p>john@example.com</p></div>'
  );
});

This test will break with any tiny change to the markup, even cosmetic ones like adding a CSS class.

Good Example

test('user profile displays name and email', () => {
  const user = { name: 'John Doe', email: 'john@example.com' };
  const { getByText } = render(<UserProfile user={user} />);
  
  expect(getByText('John Doe')).toBeInTheDocument();
  expect(getByText('john@example.com')).toBeInTheDocument();
});

This test verifies the important behavior without being coupled to the exact HTML structure.

Testing the Same Thing Multiple Times

Redundant tests slow down your test suite without adding value.

Bad Example

test('sum adds two numbers correctly - case 1', () => {
  expect(sum(1, 2)).toBe(3);
});

test('sum adds two numbers correctly - case 2', () => {
  expect(sum(3, 4)).toBe(7);
});

test('sum adds two numbers correctly - case 3', () => {
  expect(sum(5, 6)).toBe(11);
});

Good Example

test('sum adds two numbers correctly', () => {
  expect(sum(1, 2)).toBe(3);
  expect(sum(3, 4)).toBe(7);
  expect(sum(5, 6)).toBe(11);
});

Or even better, use parameterized tests:

test.each([
  [1, 2, 3],
  [3, 4, 7],
  [5, 6, 11]
])('sum(%i, %i) = %i', (a, b, expected) => {
  expect(sum(a, b)).toBe(expected);
});

Testing Error Handling

A robust application must handle errors gracefully. Testing error scenarios is just as important as testing successful operations.

Testing Expected Errors

function divide(a, b) {
  if (b === 0) {
    throw new Error("Cannot divide by zero");
  }
  return a / b;
}

test('divide throws error for division by zero', () => {
  expect(() => divide(10, 0)).toThrow();
  expect(() => divide(10, 0)).toThrow(/divide by zero/i);
});

Testing Async Error Handling

class UserRepository {
  async findById(id) {
    if (!id) {
      throw new Error('User ID is required');
    }
    
    // Simulate database lookup
    if (id === 'not-found') {
      return null;
    }
    
    return { id, name: 'Test User' };
  }
}

describe('UserRepository', () => {
  let repository;
  
  beforeEach(() => {
    repository = new UserRepository();
  });
  
  test('findById returns user when found', async () => {
    const user = await repository.findById('existing-id');
    expect(user).toEqual({ id: 'existing-id', name: 'Test User' });
  });
  
  test('findById returns null for non-existent users', async () => {
    const user = await repository.findById('not-found');
    expect(user).toBeNull();
  });
  
  test('findById throws error when id is missing', async () => {
    await expect(repository.findById()).rejects.toThrow('User ID is required');
  });
});

Testing Error Recovery

class ApiClient {
  constructor(maxRetries = 3) {
    this.maxRetries = maxRetries;
  }
  
  async fetchWithRetry(url) {
    let lastError;
    
    for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
      try {
        // Simulate fetch
        if (url === 'https://api.example.com/success') {
          return { data: 'success' };
        }
        
        if (url === 'https://api.example.com/fail-then-succeed') {
          if (attempt < 3) {
            throw new Error('Temporary failure');
          }
          return { data: 'success after retry' };
        }
        
        throw new Error('Permanent failure');
      } catch (error) {
        lastError = error;
        // In a real implementation, we might wait before retrying
        // await new Promise(resolve => setTimeout(resolve, 100 * attempt));
      }
    }
    
    throw lastError;
  }
}

describe('ApiClient', () => {
  let apiClient;
  
  beforeEach(() => {
    apiClient = new ApiClient();
  });
  
  test('successfully fetches data on first attempt', async () => {
    const result = await apiClient.fetchWithRetry('https://api.example.com/success');
    expect(result).toEqual({ data: 'success' });
  });
  
  test('succeeds after multiple retries', async () => {
    const result = await apiClient.fetchWithRetry('https://api.example.com/fail-then-succeed');
    expect(result).toEqual({ data: 'success after retry' });
  });
  
  test('fails after exhausting all retries', async () => {
    await expect(apiClient.fetchWithRetry('https://api.example.com/always-fail'))
      .rejects.toThrow('Permanent failure');
  });
  
  test('respects custom retry count', async () => {
    const clientWithOneRetry = new ApiClient(1);
    await expect(clientWithOneRetry.fetchWithRetry('https://api.example.com/fail-then-succeed'))
      .rejects.toThrow('Temporary failure');
  });
});

Testing Strategies for Different Types of Code

Testing Pure Functions

// Pure function
function calculateTotalPrice(items, taxRate) {
  const subtotal = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
  return subtotal * (1 + taxRate / 100);
}

// Tests
describe('calculateTotalPrice', () => {
  test('calculates total correctly with tax', () => {
    const items = [
      { name: 'Product 1', price: 10, quantity: 2 },
      { name: 'Product 2', price: 20, quantity: 1 }
    ];
    
    expect(calculateTotalPrice(items, 10)).toBe(44); // (10*2 + 20*1) * 1.1
  });
  
  test('handles empty cart', () => {
    expect(calculateTotalPrice([], 10)).toBe(0);
  });
  
  test('handles zero tax', () => {
    const items = [{ name: 'Product', price: 10, quantity: 1 }];
    expect(calculateTotalPrice(items, 0)).toBe(10);
  });
});

Testing Classes and Objects

class ShoppingCart {
  constructor() {
    this.items = [];
  }
  
  addItem(product, quantity = 1) {
    const existingItem = this.items.find(item => item.productId === product.id);
    
    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      this.items.push({
        productId: product.id,
        name: product.name,
        price: product.price,
        quantity
      });
    }
  }
  
  removeItem(productId) {
    const index = this.items.findIndex(item => item.productId === productId);
    
    if (index !== -1) {
      this.items.splice(index, 1);
      return true;
    }
    
    return false;
  }
  
  updateQuantity(productId, quantity) {
    if (quantity <= 0) {
      return this.removeItem(productId);
    }
    
    const item = this.items.find(item => item.productId === productId);
    
    if (item) {
      item.quantity = quantity;
      return true;
    }
    
    return false;
  }
  
  getTotal() {
    return this.items.reduce((total, item) => {
      return total + (item.price * item.quantity);
    }, 0);
  }
  
  clear() {
    this.items = [];
  }
  
  getItemCount() {
    return this.items.reduce((count, item) => count + item.quantity, 0);
  }
}

// Tests
describe('ShoppingCart', () => {
  let cart;
  let product1;
  let product2;
  
  beforeEach(() => {
    cart = new ShoppingCart();
    product1 = { id: 'p1', name: 'Product 1', price: 10 };
    product2 = { id: 'p2', name: 'Product 2', price: 20 };
  });
  
  test('starts empty', () => {
    expect(cart.items).toHaveLength(0);
    expect(cart.getTotal()).toBe(0);
    expect(cart.getItemCount()).toBe(0);
  });
  
  describe('addItem', () => {
    test('adds new item to cart', () => {
      cart.addItem(product1);
      
      expect(cart.items).toHaveLength(1);
      expect(cart.items[0]).toEqual({
        productId: 'p1',
        name: 'Product 1',
        price: 10,
        quantity: 1
      });
    });
    
    test('increases quantity for existing item', () => {
      cart.addItem(product1);
      cart.addItem(product1);
      
      expect(cart.items).toHaveLength(1);
      expect(cart.items[0].quantity).toBe(2);
    });
    
    test('adds multiple items with custom quantities', () => {
      cart.addItem(product1, 3);
      cart.addItem(product2, 2);
      
      expect(cart.items).toHaveLength(2);
      expect(cart.getItemCount()).toBe(5);
      expect(cart.getTotal()).toBe(70); // 3*10 + 2*20
    });
  });
  
  describe('removeItem', () => {
    test('removes item from cart', () => {
      cart.addItem(product1);
      cart.addItem(product2);
      
      const result = cart.removeItem('p1');
      
      expect(result).toBe(true);
      expect(cart.items).toHaveLength(1);
      expect(cart.items[0].productId).toBe('p2');
    });
    
    test('returns false when removing non-existent item', () => {
      const result = cart.removeItem('nonexistent');
      
      expect(result).toBe(false);
      expect(cart.items).toHaveLength(0);
    });
  });
  
  describe('updateQuantity', () => {
    beforeEach(() => {
      cart.addItem(product1, 2);
    });
    
    test('updates item quantity', () => {
      const result = cart.updateQuantity('p1', 5);
      
      expect(result).toBe(true);
      expect(cart.items[0].quantity).toBe(5);
      expect(cart.getTotal()).toBe(50);
    });
    
    test('removes item when setting quantity to zero', () => {
      const result = cart.updateQuantity('p1', 0);
      
      expect(result).toBe(true);
      expect(cart.items).toHaveLength(0);
    });
    
    test('removes item when setting negative quantity', () => {
      const result = cart.updateQuantity('p1', -1);
      
      expect(result).toBe(true);
      expect(cart.items).toHaveLength(0);
    });
    
    test('returns false for non-existent item', () => {
      const result = cart.updateQuantity('nonexistent', 5);
      
      expect(result).toBe(false);
    });
  });
  
  describe('clear', () => {
    test('removes all items from cart', () => {
      cart.addItem(product1);
      cart.addItem(product2);
      
      cart.clear();
      
      expect(cart.items).toHaveLength(0);
      expect(cart.getTotal()).toBe(0);
      expect(cart.getItemCount()).toBe(0);
    });
  });
});

Testing Async Code and APIs

class ProductService {
  constructor(apiClient) {
    this.apiClient = apiClient;
  }
  
  async getProducts(category) {
    try {
      const url = category 
        ? `/products?category=${encodeURIComponent(category)}`
        : '/products';
        
      const response = await this.apiClient.get(url);
      return response.data;
    } catch (error) {
      if (error.status === 404) {
        return [];
      }
      throw new Error(`Failed to fetch products: ${error.message}`);
    }
  }
  
  async getProductDetails(productId) {
    if (!productId) {
      throw new Error('Product ID is required');
    }
    
    try {
      const response = await this.apiClient.get(`/products/${productId}`);
      return response.data;
    } catch (error) {
      if (error.status === 404) {
        return null;
      }
      throw new Error(`Failed to fetch product details: ${error.message}`);
    }
  }
}

// Tests
describe('ProductService', () => {
  let productService;
  let mockApiClient;
  
  beforeEach(() => {
    mockApiClient = {
      get: jest.fn()
    };
    productService = new ProductService(mockApiClient);
  });
  
  describe('getProducts', () => {
    test('fetches all products when no category specified', async () => {
      const mockProducts = [{ id: 1, name: 'Product 1' }];
      mockApiClient.get.mockResolvedValue({ data: mockProducts });
      
      const result = await productService.getProducts();
      
      expect(mockApiClient.get).toHaveBeenCalledWith('/products');
      expect(result).toEqual(mockProducts);
    });
    
    test('fetches products by category', async () => {
      const mockProducts = [{ id: 1, name: 'Product 1', category: 'electronics' }];
      mockApiClient.get.mockResolvedValue({ data: mockProducts });
      
      const result = await productService.getProducts('electronics');
      
      expect(mockApiClient.get).toHaveBeenCalledWith('/products?category=electronics');
      expect(result).toEqual(mockProducts);
    });
    
    test('encodes category parameter correctly', async () => {
      mockApiClient.get.mockResolvedValue({ data: [] });
      
      await productService.getProducts('home & garden');
      
      expect(mockApiClient.get).toHaveBeenCalledWith('/products?category=home%20%26%20garden');
    });
    
    test('returns empty array for 404 response', async () => {
      const error = new Error('Not found');
      error.status = 404;
      mockApiClient.get.mockRejectedValue(error);
      
      const result = await productService.getProducts();
      
      expect(result).toEqual([]);
    });
    
    test('throws for other errors', async () => {
      const error = new Error('Network error');
      error.status = 500;
      mockApiClient.get.mockRejectedValue(error);
      
      await expect(productService.getProducts()).rejects.toThrow('Failed to fetch products');
    });
  });
  
  describe('getProductDetails', () => {
    test('fetches product details by id', async () => {
      const mockProduct = { id: 1, name: 'Product 1', description: 'Description' };
      mockApiClient.get.mockResolvedValue({ data: mockProduct });
      
      const result = await productService.getProductDetails(1);
      
      expect(mockApiClient.get).toHaveBeenCalledWith('/products/1');
      expect(result).toEqual(mockProduct);
    });
    
    test('throws error when product id is missing', async () => {
      await expect(productService.getProductDetails()).rejects.toThrow('Product ID is required');
      expect(mockApiClient.get).not.toHaveBeenCalled();
    });
    
    test('returns null for non-existent product', async () => {
      const error = new Error('Not found');
      error.status = 404;
      mockApiClient.get.mockRejectedValue(error);
      
      const result = await productService.getProductDetails(999);
      
      expect(result).toBeNull();
    });
  });
});

Test-Driven Development in Practice

Let's work through a complete TDD example to see the Red-Green-Refactor cycle in action.

Problem: Create a Password Validator

We need to create a password validator with the following requirements:

Step 1: Write the First Test (Red)

// password-validator.test.js
describe('PasswordValidator', () => {
  let validator;
  
  beforeEach(() => {
    validator = new PasswordValidator();
  });
  
  test('rejects passwords shorter than 8 characters', () => {
    const result = validator.validate('Abc123!');
    expect(result.isValid).toBe(false);
    expect(result.errors).toContain('Password must be at least 8 characters long');
  });
});

This test fails because we haven't created the PasswordValidator class yet.

Step 2: Write Minimal Implementation (Green)

// password-validator.js
class PasswordValidator {
  validate(password) {
    const errors = [];
    
    if (password.length < 8) {
      errors.push('Password must be at least 8 characters long');
    }
    
    return {
      isValid: errors.length === 0,
      errors
    };
  }
}

module.exports = PasswordValidator;

Now the test passes, but we're only checking password length.

Step 3: Write the Next Test (Red)

test('requires at least one uppercase letter', () => {
  const result = validator.validate('abcdef123!');
  expect(result.isValid).toBe(false);
  expect(result.errors).toContain('Password must contain at least one uppercase letter');
});

Step 4: Update Implementation (Green)

validate(password) {
  const errors = [];
  
  if (password.length < 8) {
    errors.push('Password must be at least 8 characters long');
  }
  
  if (!/[A-Z]/.test(password)) {
    errors.push('Password must contain at least one uppercase letter');
  }
  
  return {
    isValid: errors.length === 0,
    errors
  };
}

Step 5: Continue Adding Tests and Implementation

test('requires at least one lowercase letter', () => {
  const result = validator.validate('ABCDEF123!');
  expect(result.isValid).toBe(false);
  expect(result.errors).toContain('Password must contain at least one lowercase letter');
});

test('requires at least one number', () => {
  const result = validator.validate('ABCDEFabc!');
  expect(result.isValid).toBe(false);
  expect(result.errors).toContain('Password must contain at least one number');
});

test('requires at least one special character', () => {
  const result = validator.validate('ABCDEFabc123');
  expect(result.isValid).toBe(false);
  expect(result.errors).toContain('Password must contain at least one special character');
});
validate(password) {
  const errors = [];
  
  if (password.length < 8) {
    errors.push('Password must be at least 8 characters long');
  }
  
  if (!/[A-Z]/.test(password)) {
    errors.push('Password must contain at least one uppercase letter');
  }
  
  if (!/[a-z]/.test(password)) {
    errors.push('Password must contain at least one lowercase letter');
  }
  
  if (!/[0-9]/.test(password)) {
    errors.push('Password must contain at least one number');
  }
  
  if (!/[!@#$%^&*]/.test(password)) {
    errors.push('Password must contain at least one special character');
  }
  
  return {
    isValid: errors.length === 0,
    errors
  };
}

Step 6: Test Valid Passwords

test('accepts valid passwords', () => {
  const result = validator.validate('Abcdef123!');
  expect(result.isValid).toBe(true);
  expect(result.errors).toHaveLength(0);
});

Step 7: Refactor Code

class PasswordValidator {
  validate(password) {
    const rules = [
      {
        test: pwd => pwd.length >= 8,
        message: 'Password must be at least 8 characters long'
      },
      {
        test: pwd => /[A-Z]/.test(pwd),
        message: 'Password must contain at least one uppercase letter'
      },
      {
        test: pwd => /[a-z]/.test(pwd),
        message: 'Password must contain at least one lowercase letter'
      },
      {
        test: pwd => /[0-9]/.test(pwd),
        message: 'Password must contain at least one number'
      },
      {
        test: pwd => /[!@#$%^&*]/.test(pwd),
        message: 'Password must contain at least one special character'
      }
    ];
    
    const errors = rules
      .filter(rule => !rule.test(password))
      .map(rule => rule.message);
    
    return {
      isValid: errors.length === 0,
      errors
    };
  }
}

module.exports = PasswordValidator;

This refactored implementation is more maintainable and follows the DRY principle, but it still passes all tests.

Final Test Suite

describe('PasswordValidator', () => {
  let validator;
  
  beforeEach(() => {
    validator = new PasswordValidator();
  });
  
  test('rejects passwords shorter than 8 characters', () => {
    const result = validator.validate('Abc123!');
    expect(result.isValid).toBe(false);
    expect(result.errors).toContain('Password must be at least 8 characters long');
  });
  
  test('requires at least one uppercase letter', () => {
    const result = validator.validate('abcdef123!');
    expect(result.isValid).toBe(false);
    expect(result.errors).toContain('Password must contain at least one uppercase letter');
  });
  
  test('requires at least one lowercase letter', () => {
    const result = validator.validate('ABCDEF123!');
    expect(result.isValid).toBe(false);
    expect(result.errors).toContain('Password must contain at least one lowercase letter');
  });
  
  test('requires at least one number', () => {
    const result = validator.validate('ABCDEFabc!');
    expect(result.isValid).toBe(false);
    expect(result.errors).toContain('Password must contain at least one number');
  });
  
  test('requires at least one special character', () => {
    const result = validator.validate('ABCDEFabc123');
    expect(result.isValid).toBe(false);
    expect(result.errors).toContain('Password must contain at least one special character');
  });
  
  test('accepts valid passwords', () => {
    const result = validator.validate('Abcdef123!');
    expect(result.isValid).toBe(true);
    expect(result.errors).toHaveLength(0);
  });
  
  test('returns all errors for invalid passwords', () => {
    const result = validator.validate('a');
    expect(result.errors).toHaveLength(4);
  });
});

TDD Benefits Demonstrated

Through this example, we can see several TDD benefits:

  • Focused Development: We built only what we needed, one requirement at a time
  • High Test Coverage: We have 100% test coverage by definition
  • Clean Design: The refactoring step led to a more maintainable implementation
  • Documentation: The tests clearly document all requirements
  • Confidence in Refactoring: We could refactor with confidence because the tests verified that behavior stayed the same

Practice Activities

Activity 1: Test a URL Shortener Service

Implement and test a URL shortener service with the following features:

  • Generate a short code for a given URL
  • Retrieve the original URL using the short code
  • Handle invalid URLs
  • Check for expired short codes

Use TDD to implement the service one requirement at a time.

Activity 2: Refactor and Test Legacy Code

Given a poorly written utility function, write tests for it and then refactor it to be more maintainable:

function formatPhoneNumber(input) {
  var output = "";
  for (var i = 0; i < input.length; i++) {
    if (input[i] >= "0" && input[i] <= "9") {
      output += input[i];
    }
  }
  if (output.length == 10) {
    return "(" + output.substring(0, 3) + ") " + output.substring(3, 6) + "-" + output.substring(6);
  } else if (output.length == 11 && output[0] == "1") {
    return "(" + output.substring(1, 4) + ") " + output.substring(4, 7) + "-" + output.substring(7);
  } else {
    return input;
  }
}

Create tests that verify the current behavior, and then refactor the function to be more maintainable.

Activity 3: Create a Testing Strategy for a Blog Application

Design a testing strategy for a blog application with the following components:

  • User authentication service
  • Post management (create, update, delete)
  • Comments and reactions
  • Search functionality
  • Admin dashboard

For each component:

  1. Identify what types of tests would be most appropriate
  2. List key test cases to verify
  3. Describe how you would mock dependencies
  4. Identify edge cases to test

Further Reading