Writing Effective Unit Tests

Best Practices and Patterns for Maintainable Tests

The Art of Unit Testing

Writing unit tests is like being a detective. You need to think of all possible scenarios where your code might behave unexpectedly, then create controlled experiments to verify it works correctly. Good unit tests are not just about finding bugs—they're about documenting behavior, enabling refactoring, and building confidence in your code.

graph TD A[Unit Testing] --> B[Test Individual Units] A --> C[Isolate Dependencies] A --> D[Fast Execution] A --> E[Deterministic Results] B --> B1[Functions] B --> B2[Classes] B --> B3[Modules] C --> C1[Mock External Services] C --> C2[Stub Dependencies] C --> C3[Control Test Environment] D --> D1[No Network Calls] D --> D2[No Database Access] D --> D3[No File System] E --> E1[Same Input = Same Output] E --> E2[No Random Values] E --> E3[No Time Dependencies] style A fill:#f9f,stroke:#333,stroke-width:4px style B fill:#bbf,stroke:#333 style C fill:#bbf,stroke:#333 style D fill:#bbf,stroke:#333 style E fill:#bbf,stroke:#333

What Makes a Good Unit Test?

Good unit tests share several characteristics. Think of them as the FIRST principles:

Example: Good vs Bad Unit Tests

// Bad: Slow, dependent on external service, not isolated
test('user service fetches data from API', async () => {
  const userService = new UserService();
  const user = await userService.getUser(1); // Makes real API call
  expect(user.name).toBe('John Doe');
});

// Good: Fast, isolated, repeatable
test('user service returns user data', async () => {
  const mockApi = {
    fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'John Doe' })
  };
  const userService = new UserService(mockApi);
  
  const user = await userService.getUser(1);
  
  expect(user.name).toBe('John Doe');
  expect(mockApi.fetchUser).toHaveBeenCalledWith(1);
});

Anatomy of a Unit Test

Every unit test should follow a clear structure that makes it easy to understand what's being tested and why:

describe('ShoppingCart', () => {
  // Describe the unit being tested
  describe('addItem', () => {
    // Describe the specific method/behavior
    
    it('should add an item to the cart', () => {
      // Arrange: Set up the test data and conditions
      const cart = new ShoppingCart();
      const item = { id: 1, name: 'Book', price: 29.99 };
      
      // Act: Execute the behavior being tested
      cart.addItem(item);
      
      // Assert: Verify the expected outcome
      expect(cart.items).toHaveLength(1);
      expect(cart.items[0]).toEqual(item);
    });
    
    it('should update quantity when adding existing item', () => {
      // Each test should test ONE specific behavior
      const cart = new ShoppingCart();
      const item = { id: 1, name: 'Book', price: 29.99 };
      
      cart.addItem(item, 1);
      cart.addItem(item, 2);
      
      expect(cart.items).toHaveLength(1);
      expect(cart.items[0].quantity).toBe(3);
    });
  });
});
graph LR A[Unit Test] --> B[Arrange] A --> C[Act] A --> D[Assert] B --> B1[Set up test data] B --> B2[Create instances] B --> B3[Configure mocks] C --> C1[Call the method] C --> C2[Trigger the event] C --> C3[Execute the behavior] D --> D1[Verify output] D --> D2[Check side effects] D --> D3[Validate mock calls] style A fill:#f9f,stroke:#333 style B fill:#bbf,stroke:#333 style C fill:#bfb,stroke:#333 style D fill:#ffb,stroke:#333

Testing Pure Functions

Pure functions are the easiest to test because they always produce the same output for the same input:

// Pure function to test
export function calculateDiscount(price, discountPercentage) {
  if (price < 0 || discountPercentage < 0 || discountPercentage > 100) {
    throw new Error('Invalid input');
  }
  
  const discount = price * (discountPercentage / 100);
  return Number((price - discount).toFixed(2));
}

// Tests for pure function
describe('calculateDiscount', () => {
  it('should calculate discount correctly', () => {
    expect(calculateDiscount(100, 20)).toBe(80);
    expect(calculateDiscount(50, 10)).toBe(45);
    expect(calculateDiscount(75.50, 15)).toBe(64.18);
  });
  
  it('should handle zero discount', () => {
    expect(calculateDiscount(100, 0)).toBe(100);
  });
  
  it('should handle 100% discount', () => {
    expect(calculateDiscount(100, 100)).toBe(0);
  });
  
  it('should throw error for negative price', () => {
    expect(() => calculateDiscount(-50, 10)).toThrow('Invalid input');
  });
  
  it('should throw error for invalid discount percentage', () => {
    expect(() => calculateDiscount(100, -10)).toThrow('Invalid input');
    expect(() => calculateDiscount(100, 150)).toThrow('Invalid input');
  });
});

// Testing with multiple test cases
describe('calculateDiscount with test cases', () => {
  const testCases = [
    { price: 100, discount: 20, expected: 80 },
    { price: 50, discount: 10, expected: 45 },
    { price: 75.50, discount: 15, expected: 64.18 },
    { price: 100, discount: 0, expected: 100 },
    { price: 100, discount: 100, expected: 0 },
  ];
  
  testCases.forEach(({ price, discount, expected }) => {
    it(`should return ${expected} for price ${price} with ${discount}% discount`, () => {
      expect(calculateDiscount(price, discount)).toBe(expected);
    });
  });
});

Testing Classes and Objects

When testing classes, focus on the public interface and behavior, not implementation details:

// Class to test
export class BankAccount {
  constructor(initialBalance = 0) {
    this._balance = initialBalance;
    this._transactions = [];
  }
  
  get balance() {
    return this._balance;
  }
  
  deposit(amount) {
    if (amount <= 0) {
      throw new Error('Deposit amount must be positive');
    }
    
    this._balance += amount;
    this._transactions.push({ type: 'deposit', amount, date: new Date() });
    return this._balance;
  }
  
  withdraw(amount) {
    if (amount <= 0) {
      throw new Error('Withdrawal amount must be positive');
    }
    
    if (amount > this._balance) {
      throw new Error('Insufficient funds');
    }
    
    this._balance -= amount;
    this._transactions.push({ type: 'withdrawal', amount, date: new Date() });
    return this._balance;
  }
  
  getTransactionHistory() {
    return [...this._transactions];
  }
}

// Tests for the class
describe('BankAccount', () => {
  let account;
  
  beforeEach(() => {
    account = new BankAccount(100); // Start with $100
  });
  
  describe('constructor', () => {
    it('should create account with initial balance', () => {
      expect(account.balance).toBe(100);
    });
    
    it('should create account with zero balance by default', () => {
      const emptyAccount = new BankAccount();
      expect(emptyAccount.balance).toBe(0);
    });
  });
  
  describe('deposit', () => {
    it('should increase balance by deposit amount', () => {
      account.deposit(50);
      expect(account.balance).toBe(150);
    });
    
    it('should record deposit transaction', () => {
      account.deposit(50);
      const transactions = account.getTransactionHistory();
      
      expect(transactions).toHaveLength(1);
      expect(transactions[0]).toMatchObject({
        type: 'deposit',
        amount: 50,
        date: expect.any(Date)
      });
    });
    
    it('should throw error for non-positive amount', () => {
      expect(() => account.deposit(0)).toThrow('Deposit amount must be positive');
      expect(() => account.deposit(-50)).toThrow('Deposit amount must be positive');
    });
  });
  
  describe('withdraw', () => {
    it('should decrease balance by withdrawal amount', () => {
      account.withdraw(30);
      expect(account.balance).toBe(70);
    });
    
    it('should record withdrawal transaction', () => {
      account.withdraw(30);
      const transactions = account.getTransactionHistory();
      
      expect(transactions).toHaveLength(1);
      expect(transactions[0]).toMatchObject({
        type: 'withdrawal',
        amount: 30,
        date: expect.any(Date)
      });
    });
    
    it('should throw error for insufficient funds', () => {
      expect(() => account.withdraw(150)).toThrow('Insufficient funds');
    });
    
    it('should throw error for non-positive amount', () => {
      expect(() => account.withdraw(0)).toThrow('Withdrawal amount must be positive');
      expect(() => account.withdraw(-50)).toThrow('Withdrawal amount must be positive');
    });
  });
  
  describe('getTransactionHistory', () => {
    it('should return copy of transactions', () => {
      account.deposit(50);
      const history = account.getTransactionHistory();
      
      // Verify it's a copy, not the original array
      history.push({ type: 'fake', amount: 1000 });
      
      expect(account.getTransactionHistory()).toHaveLength(1);
    });
  });
});

Testing Asynchronous Code

Modern JavaScript is full of asynchronous operations. Here's how to test them effectively:

// Async functions to test
export class UserService {
  constructor(apiClient) {
    this.apiClient = apiClient;
    this.cache = new Map();
  }
  
  async getUser(id) {
    // Check cache first
    if (this.cache.has(id)) {
      return this.cache.get(id);
    }
    
    // Fetch from API
    const user = await this.apiClient.fetchUser(id);
    this.cache.set(id, user);
    return user;
  }
  
  async createUser(userData) {
    const user = await this.apiClient.createUser(userData);
    this.cache.set(user.id, user);
    return user;
  }
  
  async searchUsers(query) {
    const users = await this.apiClient.searchUsers(query);
    return users.map(user => ({
      id: user.id,
      displayName: `${user.firstName} ${user.lastName}`,
      email: user.email
    }));
  }
}

// Tests for async code
describe('UserService', () => {
  let userService;
  let mockApiClient;
  
  beforeEach(() => {
    mockApiClient = {
      fetchUser: jest.fn(),
      createUser: jest.fn(),
      searchUsers: jest.fn()
    };
    userService = new UserService(mockApiClient);
  });
  
  describe('getUser', () => {
    it('should fetch user from API', async () => {
      const mockUser = { id: 1, name: 'John Doe' };
      mockApiClient.fetchUser.mockResolvedValue(mockUser);
      
      const user = await userService.getUser(1);
      
      expect(user).toEqual(mockUser);
      expect(mockApiClient.fetchUser).toHaveBeenCalledWith(1);
    });
    
    it('should cache user after first fetch', async () => {
      const mockUser = { id: 1, name: 'John Doe' };
      mockApiClient.fetchUser.mockResolvedValue(mockUser);
      
      // First call
      await userService.getUser(1);
      // Second call
      const user = await userService.getUser(1);
      
      expect(user).toEqual(mockUser);
      expect(mockApiClient.fetchUser).toHaveBeenCalledTimes(1);
    });
    
    it('should handle API errors', async () => {
      mockApiClient.fetchUser.mockRejectedValue(new Error('API Error'));
      
      await expect(userService.getUser(1)).rejects.toThrow('API Error');
    });
  });
  
  describe('createUser', () => {
    it('should create user and add to cache', async () => {
      const userData = { firstName: 'John', lastName: 'Doe' };
      const createdUser = { id: 1, ...userData };
      mockApiClient.createUser.mockResolvedValue(createdUser);
      
      const user = await userService.createUser(userData);
      
      expect(user).toEqual(createdUser);
      expect(mockApiClient.createUser).toHaveBeenCalledWith(userData);
      
      // Verify user is cached
      mockApiClient.fetchUser.mockRejectedValue(new Error('Should not be called'));
      const cachedUser = await userService.getUser(1);
      expect(cachedUser).toEqual(createdUser);
    });
  });
  
  describe('searchUsers', () => {
    it('should transform search results', async () => {
      const apiResults = [
        { id: 1, firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
        { id: 2, firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com' }
      ];
      mockApiClient.searchUsers.mockResolvedValue(apiResults);
      
      const results = await userService.searchUsers('john');
      
      expect(results).toEqual([
        { id: 1, displayName: 'John Doe', email: 'john@example.com' },
        { id: 2, displayName: 'Jane Smith', email: 'jane@example.com' }
      ]);
    });
  });
});

Testing Error Scenarios

Don't just test the happy path—make sure your code handles errors gracefully:

// Error handling function to test
export class PaymentProcessor {
  constructor(paymentGateway, logger) {
    this.paymentGateway = paymentGateway;
    this.logger = logger;
  }
  
  async processPayment(amount, cardDetails) {
    try {
      // Validate input
      this.validateAmount(amount);
      this.validateCardDetails(cardDetails);
      
      // Process payment
      const result = await this.paymentGateway.charge(amount, cardDetails);
      
      // Log successful payment
      this.logger.info(`Payment processed: ${result.transactionId}`);
      
      return {
        success: true,
        transactionId: result.transactionId
      };
    } catch (error) {
      // Log error
      this.logger.error(`Payment failed: ${error.message}`);
      
      // Categorize error
      if (error.name === 'ValidationError') {
        throw new Error(`Invalid input: ${error.message}`);
      } else if (error.name === 'NetworkError') {
        throw new Error('Payment gateway unavailable');
      } else {
        throw new Error('Payment processing failed');
      }
    }
  }
  
  validateAmount(amount) {
    if (typeof amount !== 'number' || amount <= 0) {
      const error = new Error('Amount must be a positive number');
      error.name = 'ValidationError';
      throw error;
    }
  }
  
  validateCardDetails(cardDetails) {
    if (!cardDetails || !cardDetails.number || !cardDetails.expiry || !cardDetails.cvv) {
      const error = new Error('Invalid card details');
      error.name = 'ValidationError';
      throw error;
    }
  }
}

// Testing error scenarios
describe('PaymentProcessor', () => {
  let processor;
  let mockGateway;
  let mockLogger;
  
  beforeEach(() => {
    mockGateway = {
      charge: jest.fn()
    };
    mockLogger = {
      info: jest.fn(),
      error: jest.fn()
    };
    processor = new PaymentProcessor(mockGateway, mockLogger);
  });
  
  describe('processPayment - error handling', () => {
    it('should handle invalid amount', async () => {
      await expect(processor.processPayment(0, { number: '4111' }))
        .rejects.toThrow('Invalid input: Amount must be a positive number');
      
      expect(mockLogger.error).toHaveBeenCalled();
      expect(mockGateway.charge).not.toHaveBeenCalled();
    });
    
    it('should handle invalid card details', async () => {
      await expect(processor.processPayment(100, {}))
        .rejects.toThrow('Invalid input: Invalid card details');
    });
    
    it('should handle payment gateway errors', async () => {
      const networkError = new Error('Connection timeout');
      networkError.name = 'NetworkError';
      mockGateway.charge.mockRejectedValue(networkError);
      
      await expect(processor.processPayment(100, { 
        number: '4111', 
        expiry: '12/25', 
        cvv: '123' 
      })).rejects.toThrow('Payment gateway unavailable');
      
      expect(mockLogger.error).toHaveBeenCalledWith(
        'Payment failed: Connection timeout'
      );
    });
    
    it('should handle unexpected errors', async () => {
      mockGateway.charge.mockRejectedValue(new Error('Unknown error'));
      
      await expect(processor.processPayment(100, { 
        number: '4111', 
        expiry: '12/25', 
        cvv: '123' 
      })).rejects.toThrow('Payment processing failed');
    });
  });
  
  describe('processPayment - success path', () => {
    it('should process valid payment', async () => {
      const mockResult = { transactionId: 'txn_123' };
      mockGateway.charge.mockResolvedValue(mockResult);
      
      const result = await processor.processPayment(100, { 
        number: '4111', 
        expiry: '12/25', 
        cvv: '123' 
      });
      
      expect(result).toEqual({
        success: true,
        transactionId: 'txn_123'
      });
      
      expect(mockLogger.info).toHaveBeenCalledWith(
        'Payment processed: txn_123'
      );
    });
  });
});

Mocking Dependencies

Effective mocking is crucial for isolating units of code. Here are different mocking strategies:

// Dependencies to mock
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
import { EventEmitter } from 'events';

export class NotificationService extends EventEmitter {
  constructor(apiKey) {
    super();
    this.apiKey = apiKey;
    this.queue = [];
    this.processing = false;
  }
  
  async sendNotification(userId, message) {
    const notification = {
      id: uuidv4(),
      userId,
      message,
      timestamp: new Date().toISOString(),
      status: 'pending'
    };
    
    this.queue.push(notification);
    this.emit('notificationQueued', notification);
    
    if (!this.processing) {
      this.processQueue();
    }
    
    return notification.id;
  }
  
  async processQueue() {
    this.processing = true;
    
    while (this.queue.length > 0) {
      const notification = this.queue.shift();
      
      try {
        await this.sendToProvider(notification);
        notification.status = 'sent';
        this.emit('notificationSent', notification);
      } catch (error) {
        notification.status = 'failed';
        notification.error = error.message;
        this.emit('notificationFailed', notification);
      }
    }
    
    this.processing = false;
  }
  
  async sendToProvider(notification) {
    const response = await axios.post('https://api.notifications.com/send', {
      key: this.apiKey,
      to: notification.userId,
      message: notification.message
    });
    
    return response.data;
  }
}

// Comprehensive mocking examples
jest.mock('axios');
jest.mock('uuid');

describe('NotificationService', () => {
  let service;
  const mockApiKey = 'test-api-key';
  
  beforeEach(() => {
    // Reset all mocks
    jest.clearAllMocks();
    
    // Setup UUID mock
    uuidv4.mockReturnValue('test-uuid-123');
    
    // Setup axios mock
    axios.post.mockResolvedValue({ data: { success: true } });
    
    // Create fresh instance
    service = new NotificationService(mockApiKey);
  });
  
  describe('sendNotification', () => {
    it('should create notification with unique ID', async () => {
      const id = await service.sendNotification('user1', 'Hello');
      
      expect(id).toBe('test-uuid-123');
      expect(uuidv4).toHaveBeenCalled();
    });
    
    it('should emit notificationQueued event', async () => {
      const queuedHandler = jest.fn();
      service.on('notificationQueued', queuedHandler);
      
      await service.sendNotification('user1', 'Hello');
      
      expect(queuedHandler).toHaveBeenCalledWith(
        expect.objectContaining({
          id: 'test-uuid-123',
          userId: 'user1',
          message: 'Hello',
          status: 'pending'
        })
      );
    });
  });
  
  describe('processQueue', () => {
    it('should send notification to provider', async () => {
      const sentHandler = jest.fn();
      service.on('notificationSent', sentHandler);
      
      await service.sendNotification('user1', 'Hello');
      
      // Wait for processing to complete
      await new Promise(resolve => setTimeout(resolve, 0));
      
      expect(axios.post).toHaveBeenCalledWith(
        'https://api.notifications.com/send',
        {
          key: mockApiKey,
          to: 'user1',
          message: 'Hello'
        }
      );
      
      expect(sentHandler).toHaveBeenCalled();
    });
    
    it('should handle provider errors', async () => {
      axios.post.mockRejectedValueOnce(new Error('API Error'));
      
      const failedHandler = jest.fn();
      service.on('notificationFailed', failedHandler);
      
      await service.sendNotification('user1', 'Hello');
      
      // Wait for processing to complete
      await new Promise(resolve => setTimeout(resolve, 0));
      
      expect(failedHandler).toHaveBeenCalledWith(
        expect.objectContaining({
          status: 'failed',
          error: 'API Error'
        })
      );
    });
  });
  
  // Testing with manual mocks
  describe('with manual mocks', () => {
    it('should work with custom mock implementation', async () => {
      // Create a manual mock for axios
      const mockAxios = {
        post: jest.fn().mockImplementation((url, data) => {
          if (data.to === 'invalid-user') {
            return Promise.reject(new Error('Invalid user'));
          }
          return Promise.resolve({ data: { success: true, messageId: 'msg-123' } });
        })
      };
      
      // Replace the module mock with our manual mock
      jest.mock('axios', () => mockAxios);
      
      // Test the implementation
      const service = new NotificationService('api-key');
      await expect(service.sendToProvider({ userId: 'invalid-user', message: 'Hi' }))
        .rejects.toThrow('Invalid user');
    });
  });
});

Testing Edge Cases

Good tests explore the boundaries of your code's behavior:

// Function with edge cases
export class StringProcessor {
  static truncate(str, maxLength, suffix = '...') {
    if (typeof str !== 'string') {
      throw new TypeError('Input must be a string');
    }
    
    if (typeof maxLength !== 'number' || maxLength < 0) {
      throw new TypeError('Max length must be a non-negative number');
    }
    
    if (str.length <= maxLength) {
      return str;
    }
    
    if (maxLength === 0) {
      return '';
    }
    
    const truncatedLength = maxLength - suffix.length;
    if (truncatedLength <= 0) {
      return suffix.substring(0, maxLength);
    }
    
    return str.substring(0, truncatedLength) + suffix;
  }
  
  static wordWrap(text, maxLineLength) {
    if (typeof text !== 'string') {
      throw new TypeError('Text must be a string');
    }
    
    if (typeof maxLineLength !== 'number' || maxLineLength <= 0) {
      throw new TypeError('Max line length must be a positive number');
    }
    
    const words = text.split(' ');
    const lines = [];
    let currentLine = '';
    
    for (const word of words) {
      if (word.length > maxLineLength) {
        // Handle words longer than max length
        if (currentLine) {
          lines.push(currentLine);
          currentLine = '';
        }
        
        // Break long word into chunks
        for (let i = 0; i < word.length; i += maxLineLength) {
          lines.push(word.substring(i, i + maxLineLength));
        }
      } else if ((currentLine + ' ' + word).trim().length <= maxLineLength) {
        currentLine = (currentLine + ' ' + word).trim();
      } else {
        if (currentLine) {
          lines.push(currentLine);
        }
        currentLine = word;
      }
    }
    
    if (currentLine) {
      lines.push(currentLine);
    }
    
    return lines.join('\n');
  }
}

// Testing edge cases
describe('StringProcessor', () => {
  describe('truncate', () => {
    // Normal cases
    it('should truncate long strings', () => {
      expect(StringProcessor.truncate('Hello World', 8)).toBe('Hello...');
    });
    
    it('should not truncate short strings', () => {
      expect(StringProcessor.truncate('Hello', 10)).toBe('Hello');
    });
    
    // Edge cases
    it('should handle empty strings', () => {
      expect(StringProcessor.truncate('', 5)).toBe('');
    });
    
    it('should handle zero max length', () => {
      expect(StringProcessor.truncate('Hello', 0)).toBe('');
    });
    
    it('should handle max length equal to string length', () => {
      expect(StringProcessor.truncate('Hello', 5)).toBe('Hello');
    });
    
    it('should handle max length less than suffix length', () => {
      expect(StringProcessor.truncate('Hello World', 2, '...')).toBe('..');
    });
    
    it('should handle custom suffix', () => {
      expect(StringProcessor.truncate('Hello World', 8, '…')).toBe('Hello W…');
    });
    
    // Error cases
    it('should throw TypeError for non-string input', () => {
      expect(() => StringProcessor.truncate(123, 5)).toThrow(TypeError);
      expect(() => StringProcessor.truncate(null, 5)).toThrow(TypeError);
    });
    
    it('should throw TypeError for invalid max length', () => {
      expect(() => StringProcessor.truncate('Hello', -1)).toThrow(TypeError);
      expect(() => StringProcessor.truncate('Hello', 'five')).toThrow(TypeError);
    });
  });
  
  describe('wordWrap', () => {
    // Normal cases
    it('should wrap text at word boundaries', () => {
      const text = 'The quick brown fox jumps over the lazy dog';
      const wrapped = StringProcessor.wordWrap(text, 15);
      
      expect(wrapped).toBe(
        'The quick brown\n' +
        'fox jumps over\n' +
        'the lazy dog'
      );
    });
    
    // Edge cases
    it('should handle empty string', () => {
      expect(StringProcessor.wordWrap('', 10)).toBe('');
    });
    
    it('should handle single word longer than max length', () => {
      expect(StringProcessor.wordWrap('supercalifragilisticexpialidocious', 10)).toBe(
        'supercalif\n' +
        'ragilistic\n' +
        'expialidoc\n' +
        'ious'
      );
    });
    
    it('should handle text with multiple spaces', () => {
      expect(StringProcessor.wordWrap('Hello    World', 10)).toBe('Hello\nWorld');
    });
    
    it('should handle text that exactly fits', () => {
      expect(StringProcessor.wordWrap('Hello', 5)).toBe('Hello');
    });
    
    // Error cases
    it('should throw TypeError for non-string text', () => {
      expect(() => StringProcessor.wordWrap(123, 10)).toThrow(TypeError);
    });
    
    it('should throw TypeError for invalid max line length', () => {
      expect(() => StringProcessor.wordWrap('Hello', 0)).toThrow(TypeError);
      expect(() => StringProcessor.wordWrap('Hello', -5)).toThrow(TypeError);
    });
  });
});

Test Organization Patterns

Organize your tests for maintainability and clarity:

// Well-organized test structure
describe('UserRepository', () => {
  let repository;
  let mockDatabase;
  
  // Setup and teardown
  beforeAll(async () => {
    // One-time setup
  });
  
  afterAll(async () => {
    // One-time cleanup
  });
  
  beforeEach(() => {
    // Reset state before each test
    mockDatabase = createMockDatabase();
    repository = new UserRepository(mockDatabase);
  });
  
  afterEach(() => {
    // Clean up after each test
    jest.clearAllMocks();
  });
  
  // Group related tests
  describe('findById', () => {
    describe('when user exists', () => {
      it('should return user data', async () => {
        // Test implementation
      });
      
      it('should cache the result', async () => {
        // Test implementation
      });
    });
    
    describe('when user does not exist', () => {
      it('should return null', async () => {
        // Test implementation
      });
    });
    
    describe('when database error occurs', () => {
      it('should throw repository error', async () => {
        // Test implementation
      });
    });
  });
  
  describe('create', () => {
    describe('with valid data', () => {
      it('should create user in database', async () => {
        // Test implementation
      });
      
      it('should return created user with ID', async () => {
        // Test implementation
      });
    });
    
    describe('with invalid data', () => {
      it('should throw validation error', async () => {
        // Test implementation
      });
    });
    
    describe('when email already exists', () => {
      it('should throw duplicate error', async () => {
        // Test implementation
      });
    });
  });
  
  // Test utilities
  function createMockDatabase() {
    return {
      query: jest.fn(),
      insert: jest.fn(),
      update: jest.fn(),
      delete: jest.fn()
    };
  }
  
  function createTestUser(overrides = {}) {
    return {
      email: 'test@example.com',
      password: 'password123',
      firstName: 'Test',
      lastName: 'User',
      ...overrides
    };
  }
});

Advanced Testing Patterns

Here are some advanced patterns for complex testing scenarios:

1. Testing with Dependency Injection

// Service with dependency injection
export class OrderService {
  constructor(
    private paymentProcessor: PaymentProcessor,
    private inventoryService: InventoryService,
    private notificationService: NotificationService,
    private logger: Logger
  ) {}
  
  async placeOrder(order: Order): Promise {
    try {
      // Check inventory
      const available = await this.inventoryService.checkAvailability(order.items);
      if (!available) {
        throw new Error('Items not available');
      }
      
      // Process payment
      const paymentResult = await this.paymentProcessor.processPayment(
        order.total,
        order.paymentDetails
      );
      
      // Update inventory
      await this.inventoryService.reserveItems(order.items);
      
      // Send notification
      await this.notificationService.sendOrderConfirmation(order);
      
      this.logger.info(`Order placed: ${order.id}`);
      
      return {
        orderId: order.id,
        status: 'completed',
        transactionId: paymentResult.transactionId
      };
    } catch (error) {
      this.logger.error(`Order failed: ${error.message}`);
      throw error;
    }
  }
}

// Test with dependency injection
describe('OrderService', () => {
  let orderService: OrderService;
  let mockPaymentProcessor: jest.Mocked;
  let mockInventoryService: jest.Mocked;
  let mockNotificationService: jest.Mocked;
  let mockLogger: jest.Mocked;
  
  beforeEach(() => {
    // Create mocks
    mockPaymentProcessor = {
      processPayment: jest.fn()
    } as any;
    
    mockInventoryService = {
      checkAvailability: jest.fn(),
      reserveItems: jest.fn()
    } as any;
    
    mockNotificationService = {
      sendOrderConfirmation: jest.fn()
    } as any;
    
    mockLogger = {
      info: jest.fn(),
      error: jest.fn()
    } as any;
    
    // Inject mocks
    orderService = new OrderService(
      mockPaymentProcessor,
      mockInventoryService,
      mockNotificationService,
      mockLogger
    );
  });
  
  it('should place order successfully', async () => {
    // Arrange
    const order = createTestOrder();
    mockInventoryService.checkAvailability.mockResolvedValue(true);
    mockPaymentProcessor.processPayment.mockResolvedValue({
      transactionId: 'txn_123'
    });
    
    // Act
    const result = await orderService.placeOrder(order);
    
    // Assert
    expect(result).toEqual({
      orderId: order.id,
      status: 'completed',
      transactionId: 'txn_123'
    });
    
    expect(mockInventoryService.checkAvailability).toHaveBeenCalledWith(order.items);
    expect(mockPaymentProcessor.processPayment).toHaveBeenCalledWith(
      order.total,
      order.paymentDetails
    );
    expect(mockInventoryService.reserveItems).toHaveBeenCalledWith(order.items);
    expect(mockNotificationService.sendOrderConfirmation).toHaveBeenCalledWith(order);
    expect(mockLogger.info).toHaveBeenCalledWith(`Order placed: ${order.id}`);
  });
});

2. Testing with Test Doubles

// Different types of test doubles
describe('Test Doubles Examples', () => {
  // Dummy - passed but not used
  test('dummy example', () => {
    const dummyLogger = {};
    const service = new ServiceNeedingLogger(dummyLogger);
    
    expect(service.doSomethingNotUsingLogger()).toBe(true);
  });
  
  // Stub - provides canned answers
  test('stub example', () => {
    const stubDatabase = {
      getUser: () => ({ id: 1, name: 'John' })
    };
    const service = new UserService(stubDatabase);
    
    expect(service.getUserName(1)).toBe('John');
  });
  
  // Spy - records information about calls
  test('spy example', () => {
    const spyEmail = {
      send: jest.fn()
    };
    const service = new NotificationService(spyEmail);
    
    service.notifyUser('user@example.com', 'Hello');
    
    expect(spyEmail.send).toHaveBeenCalledWith('user@example.com', 'Hello');
  });
  
  // Mock - pre-programmed with expectations
  test('mock example', () => {
    const mockPayment = {
      charge: jest.fn().mockResolvedValue({ success: true })
    };
    const service = new PaymentService(mockPayment);
    
    return service.processPayment(100).then(result => {
      expect(result.success).toBe(true);
      expect(mockPayment.charge).toHaveBeenCalledWith(100);
    });
  });
  
  // Fake - working implementation with shortcuts
  test('fake example', () => {
    class FakeDatabase {
      private data = new Map();
      
      async save(id, value) {
        this.data.set(id, value);
      }
      
      async find(id) {
        return this.data.get(id);
      }
    }
    
    const fakeDb = new FakeDatabase();
    const service = new RepositoryService(fakeDb);
    
    return service.saveUser({ id: 1, name: 'John' })
      .then(() => service.getUser(1))
      .then(user => expect(user.name).toBe('John'));
  });
});

Testing Best Practices

Follow these best practices for maintainable, reliable tests:

1. Keep Tests Independent

// Bad: Tests depend on execution order
let sharedCounter = 0;

test('first test', () => {
  sharedCounter++;
  expect(sharedCounter).toBe(1);
});

test('second test', () => {
  sharedCounter++;
  expect(sharedCounter).toBe(2); // Fails if tests run in different order
});

// Good: Each test is independent
test('first test', () => {
  const counter = 0;
  expect(counter + 1).toBe(1);
});

test('second test', () => {
  const counter = 0;
  expect(counter + 2).toBe(2);
});

2. Use Descriptive Test Names

// Bad: Unclear test names
test('test1', () => { /* ... */ });
test('error case', () => { /* ... */ });

// Good: Descriptive test names
test('should return null when user ID does not exist', () => { /* ... */ });
test('should throw ValidationError when email format is invalid', () => { /* ... */ });

3. Follow AAA Pattern

test('should calculate order total with tax and shipping', () => {
  // Arrange
  const order = {
    items: [
      { price: 10, quantity: 2 },
      { price: 5, quantity: 1 }
    ],
    shippingMethod: 'express'
  };
  
  // Act
  const total = calculateOrderTotal(order);
  
  // Assert
  expect(total).toBe(25.75); // (20 + 5) * 1.1 (tax) + 3 (shipping)
});

4. Test One Thing Per Test

// Bad: Testing multiple behaviors
test('user validation', () => {
  expect(validateUser({ name: '' })).toBe(false);
  expect(validateUser({ name: 'John', age: -5 })).toBe(false);
  expect(validateUser({ name: 'John', age: 25 })).toBe(true);
});

// Good: Separate tests for each behavior
describe('validateUser', () => {
  test('should return false when name is empty', () => {
    expect(validateUser({ name: '' })).toBe(false);
  });
  
  test('should return false when age is negative', () => {
    expect(validateUser({ name: 'John', age: -5 })).toBe(false);
  });
  
  test('should return true when all fields are valid', () => {
    expect(validateUser({ name: 'John', age: 25 })).toBe(true);
  });
});

5. Avoid Testing Implementation Details

// Bad: Testing implementation details
class UserService {
  private cache = new Map();
  
  getUser(id) {
    if (this.cache.has(id)) {
      return this.cache.get(id);
    }
    // ... fetch user
  }
}

test('should use cache', () => {
  const service = new UserService();
  // Don't test private implementation details
  expect(service.cache.has(1)).toBe(false); // Bad!
});

// Good: Test behavior, not implementation
test('should return cached user on second call', () => {
  const service = new UserService();
  const mockFetch = jest.spyOn(service, 'fetchUser');
  
  service.getUser(1);
  service.getUser(1);
  
  expect(mockFetch).toHaveBeenCalledTimes(1); // Good!
});

Common Anti-Patterns to Avoid

1. Testing Too Much in One Test

// Anti-pattern: Kitchen sink test
test('shopping cart functionality', () => {
  const cart = new ShoppingCart();
  
  // Testing everything in one test
  cart.addItem({ id: 1, price: 10 });
  expect(cart.itemCount).toBe(1);
  
  cart.addItem({ id: 2, price: 20 });
  expect(cart.itemCount).toBe(2);
  
  expect(cart.total).toBe(30);
  
  cart.removeItem(1);
  expect(cart.itemCount).toBe(1);
  
  cart.clear();
  expect(cart.itemCount).toBe(0);
});

// Better: Focused tests
describe('ShoppingCart', () => {
  let cart;
  
  beforeEach(() => {
    cart = new ShoppingCart();
  });
  
  test('should add item to cart', () => {
    cart.addItem({ id: 1, price: 10 });
    expect(cart.itemCount).toBe(1);
  });
  
  test('should calculate total', () => {
    cart.addItem({ id: 1, price: 10 });
    cart.addItem({ id: 2, price: 20 });
    expect(cart.total).toBe(30);
  });
  
  test('should remove item from cart', () => {
    cart.addItem({ id: 1, price: 10 });
    cart.removeItem(1);
    expect(cart.itemCount).toBe(0);
  });
});

2. Excessive Mocking

// Anti-pattern: Over-mocking
test('overly mocked test', () => {
  const mockMath = {
    add: jest.fn().mockReturnValue(5),
    subtract: jest.fn().mockReturnValue(3)
  };
  
  const calculator = new Calculator(mockMath);
  expect(calculator.calculate('2 + 3')).toBe(5);
  // Not actually testing the calculation logic!
});

// Better: Mock only external dependencies
test('calculator with real math', () => {
  const calculator = new Calculator();
  expect(calculator.calculate('2 + 3')).toBe(5);
  // Actually tests the calculation logic
});

3. Testing Framework Code

// Anti-pattern: Testing the framework
test('React renders component', () => {
  const component = render();
  
  fireEvent.click(screen.getByText('Click me'));
  expect(handleClick).toHaveBeenCalledTimes(1);
});

Practical Exercise: Testing a Shopping Cart

Let's apply what we've learned by testing a shopping cart system:

// shoppingCart.js
export class ShoppingCart {
  constructor(pricingService, inventoryService) {
    this.items = [];
    this.pricingService = pricingService;
    this.inventoryService = inventoryService;
  }
  
  async addItem(productId, quantity = 1) {
    // Check inventory
    const available = await this.inventoryService.checkStock(productId);
    if (available < quantity) {
      throw new Error('Insufficient stock');
    }
    
    // Get product details
    const product = await this.inventoryService.getProduct(productId);
    
    // Check if item already in cart
    const existingItem = this.items.find(item => item.productId === productId);
    
    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      this.items.push({
        productId,
        name: product.name,
        price: product.price,
        quantity
      });
    }
    
    return this.getItemCount();
  }
  
  removeItem(productId) {
    const index = this.items.findIndex(item => item.productId === productId);
    if (index !== -1) {
      this.items.splice(index, 1);
    }
  }
  
  updateQuantity(productId, quantity) {
    if (quantity < 0) {
      throw new Error('Quantity cannot be negative');
    }
    
    const item = this.items.find(item => item.productId === productId);
    if (!item) {
      throw new Error('Item not found in cart');
    }
    
    if (quantity === 0) {
      this.removeItem(productId);
    } else {
      item.quantity = quantity;
    }
  }
  
  async getTotal() {
    const subtotal = this.items.reduce((total, item) => {
      return total + (item.price * item.quantity);
    }, 0);
    
    const tax = await this.pricingService.calculateTax(subtotal);
    const shipping = await this.pricingService.calculateShipping(this.items);
    
    return {
      subtotal,
      tax,
      shipping,
      total: subtotal + tax + shipping
    };
  }
  
  getItemCount() {
    return this.items.reduce((count, item) => count + item.quantity, 0);
  }
  
  clear() {
    this.items = [];
  }
}

// shoppingCart.test.js
describe('ShoppingCart', () => {
  let cart;
  let mockPricingService;
  let mockInventoryService;
  
  beforeEach(() => {
    mockPricingService = {
      calculateTax: jest.fn(),
      calculateShipping: jest.fn()
    };
    
    mockInventoryService = {
      checkStock: jest.fn(),
      getProduct: jest.fn()
    };
    
    cart = new ShoppingCart(mockPricingService, mockInventoryService);
  });
  
  describe('addItem', () => {
    const mockProduct = {
      id: 'prod1',
      name: 'Test Product',
      price: 10.99
    };
    
    beforeEach(() => {
      mockInventoryService.checkStock.mockResolvedValue(10);
      mockInventoryService.getProduct.mockResolvedValue(mockProduct);
    });
    
    it('should add new item to cart', async () => {
      const itemCount = await cart.addItem('prod1', 2);
      
      expect(itemCount).toBe(2);
      expect(cart.items).toHaveLength(1);
      expect(cart.items[0]).toEqual({
        productId: 'prod1',
        name: 'Test Product',
        price: 10.99,
        quantity: 2
      });
    });
    
    it('should update quantity for existing item', async () => {
      await cart.addItem('prod1', 2);
      await cart.addItem('prod1', 3);
      
      expect(cart.items).toHaveLength(1);
      expect(cart.items[0].quantity).toBe(5);
    });
    
    it('should throw error for insufficient stock', async () => {
      mockInventoryService.checkStock.mockResolvedValue(1);
      
      await expect(cart.addItem('prod1', 2))
        .rejects.toThrow('Insufficient stock');
    });
  });
  
  describe('removeItem', () => {
    beforeEach(async () => {
      mockInventoryService.checkStock.mockResolvedValue(10);
      mockInventoryService.getProduct.mockResolvedValue({
        id: 'prod1',
        name: 'Test Product',
        price: 10.99
      });
      await cart.addItem('prod1', 2);
    });
    
    it('should remove item from cart', () => {
      cart.removeItem('prod1');
      expect(cart.items).toHaveLength(0);
    });
    
    it('should do nothing if item not found', () => {
      cart.removeItem('nonexistent');
      expect(cart.items).toHaveLength(1);
    });
  });
  
  describe('updateQuantity', () => {
    beforeEach(async () => {
      mockInventoryService.checkStock.mockResolvedValue(10);
      mockInventoryService.getProduct.mockResolvedValue({
        id: 'prod1',
        name: 'Test Product',
        price: 10.99
      });
      await cart.addItem('prod1', 2);
    });
    
    it('should update item quantity', () => {
      cart.updateQuantity('prod1', 5);
      expect(cart.items[0].quantity).toBe(5);
    });
    
    it('should remove item when quantity is 0', () => {
      cart.updateQuantity('prod1', 0);
      expect(cart.items).toHaveLength(0);
    });
    
    it('should throw error for negative quantity', () => {
      expect(() => cart.updateQuantity('prod1', -1))
        .toThrow('Quantity cannot be negative');
    });
    
    it('should throw error for non-existent item', () => {
      expect(() => cart.updateQuantity('nonexistent', 5))
        .toThrow('Item not found in cart');
    });
  });
  
  describe('getTotal', () => {
    beforeEach(async () => {
      mockInventoryService.checkStock.mockResolvedValue(10);
      mockInventoryService.getProduct
        .mockResolvedValueOnce({ id: 'prod1', name: 'Product 1', price: 10 })
        .mockResolvedValueOnce({ id: 'prod2', name: 'Product 2', price: 20 });
      
      await cart.addItem('prod1', 2);
      await cart.addItem('prod2', 1);
      
      mockPricingService.calculateTax.mockResolvedValue(4);
      mockPricingService.calculateShipping.mockResolvedValue(5);
    });
    
    it('should calculate correct totals', async () => {
      const totals = await cart.getTotal();
      
      expect(totals).toEqual({
        subtotal: 40,  // (10 * 2) + (20 * 1)
        tax: 4,
        shipping: 5,
        total: 49
      });
      
      expect(mockPricingService.calculateTax).toHaveBeenCalledWith(40);
      expect(mockPricingService.calculateShipping).toHaveBeenCalledWith(cart.items);
    });
  });
});

Summary and Best Practices Checklist

Use this checklist when writing unit tests:

✓ Test Structure

✓ Test Quality

✓ Mocking and Dependencies

✓ Maintainability

✓ Coverage