Skip to main content
โšก Calmops

Unit Testing Best Practices: Comprehensive Guide

Unit testing is the foundation of software quality. This comprehensive guide covers best practices for writing effective, maintainable unit tests.

What is Unit Testing?

Unit testing involves testing individual components in isolation to verify they work correctly.

// Unit test example (Jest)
describe('UserService', () => {
  it('should create a user with valid data', async () => {
    const user = await UserService.create({
      email: '[email protected]',
      name: 'Test User'
    });
    
    expect(user.id).toBeDefined();
    expect(user.email).toBe('[email protected]');
  });
});

Test Organization

Arrange-Act-Assert Pattern

describe('Calculator', () => {
  it('should add two numbers correctly', () => {
    // Arrange
    const calculator = new Calculator();
    
    // Act
    const result = calculator.add(2, 3);
    
    // Assert
    expect(result).toBe(5);
  });
});

Grouping Tests

describe('UserService', () => {
  describe('createUser', () => {
    it('should create user with valid email', async () => {
      // Test creating user with valid email
    });
    
    it('should throw error for invalid email', async () => {
      // Test invalid email handling
    });
  });
  
  describe('deleteUser', () => {
    it('should delete existing user', async () => {
      // Test deletion
    });
    
    it('should throw error for non-existent user', async () => {
      // Test non-existent user handling
    });
  });
});

Naming Conventions

Descriptive Test Names

// Bad test names
it('test1', () => { });
it('user test', () => { });
it('create', () => { });

// Good test names - describe what should happen
it('should create a new user when valid data is provided', () => { });
it('should throw ValidationError when email is invalid', () => { });
it('should return null when user does not exist', () => { });
it('should update user profile with correct permissions', () => { });

BDD Style Naming

describe('ShoppingCart', () => {
  describe('addItem', () => {
    it('should add item to cart', () => { });
    it('should increase item quantity if already in cart', () => { });
    it('should throw error for out-of-stock items', () => { });
  });
  
  describe('removeItem', () => {
    it('should remove item from cart', () => { });
    it('should do nothing if item not in cart', () => { });
  });
  
  describe('checkout', () => {
    it('should process payment successfully', () => { });
    it('should clear cart after successful checkout', () => { });
    it('should handle payment failure gracefully', () => { });
  });
});

AAA in Different Languages

JavaScript/TypeScript

describe('StringUtils', () => {
  describe('truncate', () => {
    it('should truncate string longer than max length', () => {
      // Arrange
      const str = 'This is a very long string that needs truncation';
      
      // Act
      const result = StringUtils.truncate(str, 10);
      
      // Assert
      expect(result).toBe('This is a ...');
    });
    
    it('should not modify string shorter than max length', () => {
      // Arrange
      const str = 'Short';
      
      // Act
      const result = StringUtils.truncate(str, 10);
      
      // Assert
      expect(result).toBe('Short');
    });
  });
});

Python

import pytest
from myapp.utils import StringUtils

class TestStringUtils:
    def test_truncate_long_string(self):
        # Arrange
        s = "This is a very long string"
        
        # Act
        result = StringUtils.truncate(s, 10)
        
        # Assert
        assert result == "This is..."
    
    def test_truncate_short_string(self):
        # Arrange
        s = "Short"
        
        # Act
        result = StringUtils.truncate(s, 10)
        
        # Assert
        assert result == "Short"

Java

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class StringUtilsTest {
    
    @Test
    void shouldTruncateLongString() {
        // Arrange
        String input = "This is a very long string";
        
        // Act
        String result = StringUtils.truncate(input, 10);
        
        // Assert
        assertEquals("This is...", result);
    }
}

Mocking Strategies

Using Mocks

// Mock external service
const mockEmailService = {
  send: jest.fn().mockResolvedValue(true),
  sendBatch: jest.fn().mockResolvedValue([true, true, false])
};

// Use mock in test
describe('UserService', () => {
  it('should send welcome email after registration', async () => {
    // Arrange
    const userService = new UserService(mockEmailService);
    
    // Act
    await userService.register({ email: '[email protected]' });
    
    // Assert
    expect(mockEmailService.send).toHaveBeenCalledWith(
      '[email protected]',
      'Welcome!'
    );
  });
});

Mocking Modules

// __mocks__/database.js
export const db = {
  query: jest.fn(),
  insert: jest.fn(),
  update: jest.fn(),
  delete: jest.fn()
};

// Test file
jest.mock('../database', () => require('../__mocks__/database'));

import { db } from '../database';
import { UserRepository } from './UserRepository';

describe('UserRepository', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });
  
  it('should find user by email', async () => {
    // Arrange
    db.query.mockResolvedValue([{ id: 1, email: '[email protected]' }]);
    
    // Act
    const user = await UserRepository.findByEmail('[email protected]');
    
    // Assert
    expect(user).toEqual({ id: 1, email: '[email protected]' });
    expect(db.query).toHaveBeenCalledWith(
      expect.stringContaining('SELECT'),
      ['[email protected]']
    );
  });
});

Spy on Methods

describe('PaymentProcessor', () => {
  it('should log payment attempt', () => {
    // Arrange
    const logger = {
      info: jest.fn(),
      error: jest.fn()
    };
    const processor = new PaymentProcessor(logger);
    
    // Act
    processor.process({ amount: 100 });
    
    // Assert
    expect(logger.info).toHaveBeenCalledWith(
      'Processing payment',
      { amount: 100 }
    );
  });
});

Test Coverage

Meaningful Coverage

// Don't chase 100% coverage - focus on meaningful tests
describe('OrderService', () => {
  // High value tests
  it('should create order with valid items', () => { });
  it('should apply discount code correctly', () => { });
  it('should calculate tax properly', () => { });
  it('should handle out-of-stock items', () => { });
  
  // Edge cases
  it('should handle empty cart', () => { });
  it('should handle negative quantities', () => { });
  it('should handle maximum item limit', () => { });
});

Coverage Reports

# Jest coverage
npm test -- --coverage

# Jest config in package.json
{
  "jest": {
    "collectCoverage": true,
    "coverageThreshold": {
      "global": {
        "branches": 70,
        "functions": 70,
        "lines": 70,
        "statements": 70
      }
    }
  }
}

Test Doubles

Dummy

// Dummy - passed but never used
const dummyUser = null;
const result = calculateDiscount(dummyUser, 100);

Fake

// Fake - has working implementation but simplified
class FakeUserRepository {
  constructor() {
    this.users = new Map();
  }
  
  findById(id) {
    return Promise.resolve(this.users.get(id) || null);
  }
  
  save(user) {
    this.users.set(user.id, user);
    return Promise.resolve(user);
  }
}

Stub

// Stub - provides canned answers
const stubUserRepository = {
  findById: () => Promise.resolve({ id: 1, name: 'Test' })
};

Mock

// Mock - pre-programmed with expectations
const mockLogger = jest.fn();
mockLogger.expect('info').toBeCalledWith('User created');

Testing Edge Cases

Error Handling

describe('UserService', () => {
  it('should throw error for duplicate email', async () => {
    // Arrange
    const existingUser = { email: '[email protected]' };
    mockDb.findByEmail.mockResolvedValue(existingUser);
    
    // Act & Assert
    await expect(
      UserService.create({ email: '[email protected]' })
    ).rejects.toThrow('Email already exists');
  });
  
  it('should handle database connection failure', async () => {
    mockDb.connect.mockRejectedValue(new Error('Connection refused'));
    
    await expect(UserService.getUsers()).rejects.toThrow(
      'Failed to connect to database'
    );
  });
});

Boundary Conditions

describe('ArrayUtils', () => {
  it('should handle empty array', () => {
    expect(ArrayUtils.average([])).toBe(0);
  });
  
  it('should handle single element', () => {
    expect(ArrayUtils.average([5])).toBe(5);
  });
  
  it('should handle negative numbers', () => {
    expect(ArrayUtils.average([-1, 0, 1])).toBe(0);
  });
  
  it('should handle very large numbers', () => {
    expect(ArrayUtils.average([Number.MAX_SAFE_INTEGER])).toBe(Number.MAX_SAFE_INTEGER);
  });
});

Test-Driven Development (TDD)

Red-Green-Refactor

// 1. RED - Write failing test first
describe('PasswordValidator', () => {
  it('should reject password shorter than 8 characters', () => {
    expect(PasswordValidator.validate('abc')).toBe(false);
  });
});

// 2. GREEN - Write minimal code to pass
class PasswordValidator {
  static validate(password) {
    return password.length >= 8;
  }
}

// 3. REFACTOR - Improve while keeping tests passing
class PasswordValidator {
  static MIN_LENGTH = 8;
  
  static validate(password) {
    if (!password) return false;
    return password.length >= PasswordValidator.MIN_LENGTH;
  }
}

TDD Cycle

// Step 1: Write failing test
it('should validate password has uppercase', () => {
  expect(validatePassword('password')).toBe(false);
  expect(validatePassword('Password')).toBe(true);
});

// Step 2: Make it pass
function validatePassword(password) {
  return password.length >= 8 && /[A-Z]/.test(password);
}

// Step 3: Refactor
const validatePassword = (password) => 
  password.length >= 8 && /[A-Z]/.test(password);

Best Practices

Do’s

// DO: Test one thing per test
it('should create user with correct name', () => {
  const user = UserService.create({ name: 'John' });
  expect(user.name).toBe('John');
});

it('should create user with correct email', () => {
  const user = UserService.create({ email: '[email protected]' });
  expect(user.email).toBe('[email protected]');
});

// DO: Keep tests independent
beforeEach(() => {
  jest.clearAllMocks();
});

// DO: Use meaningful assertions
expect(user).toBeDefined();
expect(user.id).toBeDefined();
expect(user.email).toMatch(/@/);

Don’ts

// DON'T: Test multiple things
it('should create user with all fields', () => {
  const user = UserService.create({
    name: 'John',
    email: '[email protected]',
    age: 25
  });
  // This tests too much!
});

// DON'T: Test implementation details
it('should call save() then emit event()', () => {
  // Don't test internal method calls
});

// DON'T: Use magic numbers
expect(result).toBe(42); // What is 42?

// DO: Use constants
const MAX_RETRY_COUNT = 3;
expect(attempts).toBe(MAX_RETRY_COUNT);

Integration Tests vs Unit Tests

Unit Test

// Tests single function in isolation
describe('calculateDiscount', () => {
  it('should apply 10% discount for premium users', () => {
    const discount = calculateDiscount({
      amount: 100,
      userTier: 'premium'
    });
    expect(discount).toBe(10);
  });
});

Integration Test

// Tests multiple components working together
describe('Order API', () => {
  it('should create order and send confirmation', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ items: [{ productId: 1, quantity: 2 }] });
    
    expect(response.status).toBe(201);
    expect(emailService.send).toHaveBeenCalled();
  });
});

External Resources

Conclusion

Effective unit testing requires:

  • Clear test organization using AAA pattern
  • Descriptive naming that explains intent
  • Proper mocking of external dependencies
  • Focus on behavior, not implementation
  • Independent, repeatable tests
  • Meaningful coverage goals

Good tests give confidence to refactor and catch regressions early.

Comments