Skip to main content

Unit Testing Best Practices: Comprehensive Guide

Published: February 27, 2026 Updated: May 24, 2026 Larry Qu 11 min read

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]');
  });
});

Why Unit Testing Matters

Understanding the value of testing motivates investment in good practices.

Confidence

Tests enable change with safety. Without tests, every refactor risks breaking existing functionality, and every new feature delivery carries uncertainty. With a comprehensive test suite, teams can:

  • Refactor aggressively, knowing the test suite catches regressions
  • Add features without fear of breaking existing behavior
  • Deploy with confidence that core functionality works

Documentation

Well-written tests serve as living documentation. They demonstrate how code is expected to behave, illustrate edge cases the developer considered, and provide concrete examples of API usage. Unlike external documentation, tests cannot become stale — if they don’t match the code, they fail.

Design Improvement

Testing forces better design. Code that is difficult to test often has structural problems: tight coupling, hidden dependencies, or unclear responsibilities. Writing tests first (TDD) or even alongside code naturally drives cleaner architecture, smaller functions, and more explicit dependency management.

// Hard to test — tightly coupled
function processOrder(orderId) {
  const db = new Database();
  const email = new EmailService();
  const user = db.findUser(orderId);
  email.send(user.email, 'Order processed');
}

// Easy to test — dependencies injected
function processOrder(orderId, db, email) {
  const user = db.findUser(orderId);
  email.send(user.email, 'Order processed');
}

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);
    }
}

Testing Frameworks

JavaScript/TypeScript

Popular options for JavaScript testing:

  • Jest: Facebook’s full-featured framework, most popular for React projects
  • Vitest: Vite-native framework with Jest-compatible API, faster for Vite projects
  • Mocha: Flexible, classic framework popular in Node.js ecosystem
  • Jasmine: BDD-style framework with no external dependencies

Python

Common Python testing frameworks:

  • pytest: Pythonic, powerful, with extensive plugin ecosystem
  • unittest: Built-in, classic, included in standard library
  • nose2: Extension of unittest with additional features
  • pytest-django: Django-specific testing utilities

Basic Test Example Across Frameworks

// Vitest (API compatible with Jest)
import { describe, it, expect } from 'vitest';
import { calculateTotal } from './cart';

describe('calculateTotal', () => {
  it('should sum item prices correctly', () => {
    const items = [
      { price: 10, quantity: 2 },
      { price: 5, quantity: 3 }
    ];
    expect(calculateTotal(items)).toBe(35);
  });
});

Assertions

Common Assertion Types

Effective tests use appropriate assertion types:

Assertion Jest/Vitest pytest Purpose
Equality toBe() / toEqual() assert == Value comparison
Truthiness toBeTruthy() assert Boolean checks
Null/undefined toBeNull() / toBeDefined() assertIsNone() Optional values
Arrays toContain() / toHaveLength() assertIn() / len() Collection checks
Exceptions toThrow() pytest.raises() Error handling
Object shape toHaveProperty() / toMatchObject() assertDictContainsSubset() Partial matching

Custom Assertions

Create domain-specific assertions for more readable tests:

expect.extend({
  toBeValidEmail(received) {
    const pass = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(received);
    if (pass) {
      return { pass: true };
    }
    return {
      pass: false,
      message: () => `Expected ${received} to be a valid email address`
    };
  }
});

// Usage
it('should validate email format', () => {
  expect('[email protected]').toBeValidEmail();
  expect('not-an-email').not.toBeValidEmail();
});

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);

Good Test Qualities (FIRST Principles)

Effective tests follow the FIRST principles:

Principle Meaning Why It Matters
Fast Tests run in milliseconds Slow tests discourage frequent execution
Independent No shared state between tests Failures pinpoint real issues, not cascading side effects
Repeatable Same result every time Flaky tests destroy trust in the test suite
Self-validating Clear pass/fail with no manual interpretation Fully automated verification
Timely Written alongside production code Prevents testing debt that never gets paid back

Test Behavior, Not Implementation

Tests should verify what code does, not how it does it. Implementation details change frequently; behavior is stable:

// Good: Tests behavior
test('should calculate discount correctly', () => {
  expect(calculateDiscount(100, 10)).toBe(10);
});

// Bad: Tests implementation
test('should call discountService.calculate', () => {
  expect(discountService.calculate).toHaveBeenCalled();
  // What if we refactor to use a different calculation method?
  // This test breaks even though behavior is correct
});

Avoid Logic in Tests

Keep test code simple and declarative:

// Bad: Logic in tests
it('should detect valid passwords', () => {
  const passwords = ['abc12345', 'Password1', 'short', 'nonumber'];
  passwords.forEach(pwd => {
    if (pwd.length >= 8 && /\d/.test(pwd)) {
      expect(validatePassword(pwd)).toBe(true);
    } else {
      expect(validatePassword(pwd)).toBe(false);
    }
  });
});

// Good: Explicit test cases
it('should accept valid passwords', () => {
  expect(validatePassword('abc12345')).toBe(true);
  expect(validatePassword('Password1')).toBe(true);
});

it('should reject short passwords', () => {
  expect(validatePassword('short')).toBe(false);
});

Common Testing Mistakes

Avoid these pitfalls:

1. Over-testing: Not every function needs a unit test. Focus on business logic and edge cases. Skip trivial getters, setters, and framework-provided functionality.

2. Brittle tests: Tests that break on every refactor waste time. Avoid asserting exact strings, testing private methods, or verifying internal call orders unless those are explicitly part of the contract.

3. Not running tests consistently: A test suite that isn’t run locally, in CI, and before commits provides limited value. Make testing habitual:

# Run tests before every commit
npm test

# Run in CI pipeline
.github/workflows/test.yml

# Run on deploy
argocd app sync --prune

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

👍 Was this article helpful?