TL;DR

  • Unit testing: Testing individual functions/methods in isolation
  • Why it matters: Catches bugs early, enables safe refactoring, documents code behavior
  • Key principle: Each test verifies ONE thing works correctly
  • Popular frameworks: Jest (JavaScript), pytest (Python), JUnit (Java)
  • Best practice: Write tests before fixing bugs to prevent regression
  • ROI: Bugs caught at unit level cost 10-100x less to fix than in production

Reading time: 10 minutes

Unit testing is the foundation of software quality. It tests individual pieces of code in isolation, catching bugs before they spread to the rest of the system.

What is Unit Testing?

Unit testing tests the smallest testable parts of your code — functions and methods. Each unit test verifies that a specific piece of code produces the expected output for given inputs.

Integration Test: Login → API → Database → Response
                  (Many components working together)

Unit Test: validateEmail("test@example.com") → true
           (Single function, isolated)

Unit tests run fast because they don’t involve databases, networks, or external services.

Why Unit Testing Matters

1. Catch Bugs Early

Bugs found during unit testing cost far less to fix:

Stage FoundRelative Cost
Unit testing1x
Integration testing10x
System testing40x
Production100x+

Finding a bug in a unit test takes minutes. Finding it in production takes days.

2. Enable Safe Refactoring

With unit tests, you can refactor confidently:

// Original function
function calculatePrice(price, quantity) {
  return price * quantity;
}

// Tests protect the refactoring
test('calculates total price', () => {
  expect(calculatePrice(10, 3)).toBe(30);
});

// Safe to refactor - tests will catch mistakes
function calculatePrice(price, quantity, discount = 0) {
  return (price * quantity) * (1 - discount);
}

Tests verify the function still works after changes.

3. Document Code Behavior

Tests show exactly how code should be used:

// Tests document expected behavior
test('returns empty array for null input', () => {
  expect(filterUsers(null)).toEqual([]);
});

test('filters users by active status', () => {
  const users = [
    { name: 'John', active: true },
    { name: 'Jane', active: false }
  ];
  expect(filterUsers(users)).toEqual([{ name: 'John', active: true }]);
});

New developers understand behavior by reading tests.

4. Speed Up Development

Counterintuitively, writing tests speeds up development:

Without tests: Code → Manual test → Bug → Debug → Fix → Manual test
With tests:    Code → Run tests → Bug → Fix → Run tests (seconds)

Automated tests give instant feedback.

Unit Test Structure

The AAA Pattern

Every unit test follows three steps:

test('adds two numbers correctly', () => {
  // Arrange: Set up test data
  const a = 5;
  const b = 3;

  // Act: Call the function
  const result = add(a, b);

  // Assert: Verify the result
  expect(result).toBe(8);
});

This pattern makes tests readable and maintainable.

What to Test

  1. Happy path: Normal expected behavior
  2. Edge cases: Boundary conditions
  3. Error handling: Invalid inputs
describe('divide function', () => {
  // Happy path
  test('divides two numbers', () => {
    expect(divide(10, 2)).toBe(5);
  });

  // Edge case
  test('handles decimal results', () => {
    expect(divide(10, 3)).toBeCloseTo(3.33, 2);
  });

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

Writing Your First Unit Test

JavaScript (Jest)

// math.js
function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

module.exports = { add, multiply };
// math.test.js
const { add, multiply } = require('./math');

describe('Math functions', () => {
  test('adds positive numbers', () => {
    expect(add(2, 3)).toBe(5);
  });

  test('adds negative numbers', () => {
    expect(add(-2, -3)).toBe(-5);
  });

  test('multiplies numbers', () => {
    expect(multiply(4, 5)).toBe(20);
  });

  test('multiplies by zero', () => {
    expect(multiply(4, 0)).toBe(0);
  });
});

Run with npm test.

Python (pytest)

# calculator.py
def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b
# test_calculator.py
import pytest
from calculator import add, divide

def test_add_positive():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-2, -3) == -5

def test_divide():
    assert divide(10, 2) == 5

def test_divide_by_zero():
    with pytest.raises(ValueError):
        divide(10, 0)

Run with pytest.

Java (JUnit)

// Calculator.java
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }

    public int divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("Division by zero");
        }
        return a / b;
    }
}
// CalculatorTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {
    private Calculator calc = new Calculator();

    @Test
    void addPositiveNumbers() {
        assertEquals(5, calc.add(2, 3));
    }

    @Test
    void addNegativeNumbers() {
        assertEquals(-5, calc.add(-2, -3));
    }

    @Test
    void divideNumbers() {
        assertEquals(5, calc.divide(10, 2));
    }

    @Test
    void divideByZeroThrows() {
        assertThrows(IllegalArgumentException.class,
            () -> calc.divide(10, 0));
    }
}

Mocking Dependencies

Unit tests should be isolated. Mock external dependencies:

// userService.js
async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}
// userService.test.js
jest.mock('node-fetch');

test('fetches user by id', async () => {
  // Mock the fetch response
  fetch.mockResolvedValue({
    json: () => Promise.resolve({ id: 1, name: 'John' })
  });

  const user = await getUser(1);

  expect(user.name).toBe('John');
  expect(fetch).toHaveBeenCalledWith('/api/users/1');
});

Mocking ensures tests run fast and don’t depend on external services.

Test Coverage

Understanding Coverage

Coverage measures how much code tests exercise:

Line coverage:   What % of lines executed
Branch coverage: What % of if/else branches tested
Function coverage: What % of functions called

Practical Coverage Goals

Code TypeTarget Coverage
Business logic80-90%
Utility functions90%+
UI components60-70%
Generated code0% (skip)

Coverage is a guide, not a goal. High coverage doesn’t mean good tests.

// 100% coverage but useless test
test('covers the function', () => {
  const result = complexCalculation(1, 2, 3);
  expect(result).toBeDefined(); // Weak assertion
});

// Lower coverage but valuable test
test('calculates discount correctly', () => {
  expect(calculateDiscount(100, 0.1)).toBe(90);
  expect(calculateDiscount(100, 0.5)).toBe(50);
});

Test behavior, not coverage numbers.

Best Practices

1. Test One Thing

Each test should verify one behavior:

// Bad: Testing multiple things
test('user validation', () => {
  expect(validateEmail('test@example.com')).toBe(true);
  expect(validateEmail('')).toBe(false);
  expect(validatePassword('abc')).toBe(false);
  expect(validatePassword('abcd1234')).toBe(true);
});

// Good: One test per behavior
test('validates correct email', () => {
  expect(validateEmail('test@example.com')).toBe(true);
});

test('rejects empty email', () => {
  expect(validateEmail('')).toBe(false);
});

2. Use Descriptive Names

Test names should describe expected behavior:

// Bad names
test('test1', () => { ... });
test('validateEmail', () => { ... });

// Good names
test('rejects email without @ symbol', () => { ... });
test('accepts valid email with subdomain', () => { ... });

3. Keep Tests Independent

Tests should not depend on each other:

// Bad: Tests depend on shared state
let counter = 0;

test('increments counter', () => {
  counter++;
  expect(counter).toBe(1);
});

test('counter is one', () => {
  expect(counter).toBe(1); // Fails if first test doesn't run
});

// Good: Each test is independent
test('increments counter', () => {
  const counter = new Counter();
  counter.increment();
  expect(counter.value).toBe(1);
});

4. Write Tests First for Bug Fixes

When fixing bugs, write a test that reproduces it:

// Bug: calculateTax returns NaN for negative values
// Step 1: Write failing test
test('handles negative values', () => {
  expect(calculateTax(-100)).toBe(0);
});

// Step 2: Fix the bug
function calculateTax(amount) {
  if (amount < 0) return 0;
  return amount * 0.1;
}

// Step 3: Test passes, bug won't return

Unit Testing vs Other Testing

TypeWhat it TestsSpeedIsolation
UnitSingle functionFastestComplete
IntegrationComponent interactionsMediumPartial
E2EFull user flowsSlowestNone

Unit tests form the base of the testing pyramid:

        /\
       /  \     E2E (few)
      /----\
     /      \   Integration (some)
    /--------\
   /          \ Unit (many)
  /____________\

More unit tests, fewer integration tests, fewest E2E tests.

FAQ

What is unit testing?

Unit testing tests individual functions or methods in complete isolation from the rest of the system. Each test focuses on one small piece of code — typically a single function — and verifies it produces the correct output for given inputs. The key characteristic is isolation: unit tests don’t involve databases, APIs, or other external dependencies. This isolation makes them fast (milliseconds) and reliable.

Why is unit testing important?

Unit testing catches bugs at the earliest and cheapest stage of development. A bug found during unit testing costs roughly 10-100x less to fix than one found in production. Beyond bug detection, unit tests enable confident refactoring (change code knowing tests will catch mistakes), serve as living documentation (showing exactly how code should behave), and speed up development through instant feedback loops.

What makes a good unit test?

Good unit tests follow the FIRST principles: Fast (run in milliseconds), Isolated (no external dependencies), Repeatable (same result every run), Self-validating (clear pass/fail), and Timely (written close to the code). They also follow the AAA pattern: Arrange test data, Act by calling the function, Assert the expected result. Each test should verify one specific behavior with a descriptive name.

How much unit test coverage do I need?

Aim for 70-80% code coverage on critical business logic, 90%+ on utility functions, and 60-70% on UI components. However, coverage is a guide, not a goal. 100% coverage doesn’t mean good tests — you can have complete coverage with weak assertions. Focus on testing meaningful behaviors rather than hitting coverage numbers. Skip testing generated code, getters/setters, and framework boilerplate.

See Also