Unit Testing - Complete Guide

Master the art of writing effective unit tests for robust, maintainable software

What You'll Learn

Introduction to Unit Testing

Unit testing is a software testing method where individual components or units of source code are tested in isolation to determine if they work as intended. A unit is the smallest testable part of any software—typically a function, method, or class.

Why Unit Testing Matters

Unit testing provides numerous benefits:

  • Early Bug Detection: Catch defects early in development when they're cheaper to fix
  • Documentation: Tests serve as executable documentation showing how code should work
  • Refactoring Confidence: Safely modify code knowing tests will catch regressions
  • Design Improvement: Writing tests often reveals design flaws and encourages better architecture
  • Faster Development: Reduces debugging time and enables rapid feedback

Unit Testing vs Other Testing Types

Aspect Unit Testing Integration Testing End-to-End Testing
Scope Individual units (functions/methods) Multiple components together Entire system workflow
Dependencies Mocked/stubbed Real or partial mocks Real dependencies
Speed Very fast (milliseconds) Moderate Slow (seconds/minutes)
Isolation Complete isolation Partial isolation No isolation
Maintenance Low Medium High
When to Run Every code change Before integration Before release
The Testing Pyramid: A healthy test suite should have many unit tests at the base (70-80%), fewer integration tests in the middle (15-20%), and minimal end-to-end tests at the top (5-10%).

Core Principles

FIRST Principles

Effective unit tests follow the FIRST principles:

Fast

Tests should run quickly (milliseconds). Developers won't run slow tests frequently, reducing their effectiveness.

Target: < 100ms per test

Independent

Tests should not depend on each other. Each test should set up its own data and clean up afterward.

Benefit: Tests can run in any order

Repeatable

Tests should produce the same results every time, in any environment.

Avoid: Time-dependent tests, random data, external dependencies

Self-Validating

Tests should automatically detect pass/fail without manual verification.

Use: Assertions to validate expected outcomes

Timely

Tests should be written at the right time—ideally before or alongside production code (TDD).

Practice: Don't leave tests until the end

The Three A's Pattern

Every unit test should follow this structure:

Arrange-Act-Assert (AAA)

// ARRANGE - Set up test data and conditions
const calculator = new Calculator();
const a = 5;
const b = 3;

// ACT - Execute the unit being tested
const result = calculator.add(a, b);

// ASSERT - Verify the expected outcome
expect(result).toBe(8);
                

Anatomy of a Unit Test

Essential Components

Complete Unit Test Structure

describe('Calculator', () => {
    // Test suite for Calculator class

    let calculator;

    // Setup before each test
    beforeEach(() => {
        calculator = new Calculator();
    });

    // Cleanup after each test
    afterEach(() => {
        calculator = null;
    });

    // Individual test case
    test('should add two positive numbers correctly', () => {
        // Arrange
        const a = 5;
        const b = 3;

        // Act
        const result = calculator.add(a, b);

        // Assert
        expect(result).toBe(8);
    });

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

    test('should handle zero', () => {
        expect(calculator.add(0, 5)).toBe(5);
    });
});
                

Test Naming Conventions

Good test names should describe:

  • What is being tested: The unit under test
  • The scenario: Under what conditions
  • Expected behavior: What should happen

Popular Naming Patterns:

  • should_ExpectedBehavior_When_Condition
  • given_Preconditions_when_StateUnderTest_then_ExpectedBehavior
  • test_MethodName_Scenario_ExpectedResult

Testing Frameworks

Popular Unit Testing Frameworks by Language

JavaScript/TypeScript

  • Jest: Most popular, zero config, includes mocking
  • Mocha: Flexible, requires additional assertion library
  • Vitest: Fast, Vite-native, Jest-compatible
  • Jasmine: Behavior-driven, no dependencies

Python

  • pytest: Most popular, powerful fixtures
  • unittest: Built-in, xUnit-style
  • nose2: Extends unittest
  • doctest: Tests in docstrings

Java

  • JUnit 5: Industry standard
  • TestNG: Inspired by JUnit, more powerful
  • Mockito: Mocking framework (companion)
  • AssertJ: Fluent assertions

C#/.NET

  • NUnit: Mature, feature-rich
  • xUnit.net: Modern, used by .NET team
  • MSTest: Microsoft's framework
  • Moq: Mocking library

Ruby

  • RSpec: BDD-style, very popular
  • Minitest: Built-in, fast and simple
  • Test::Unit: Traditional xUnit style

Go

  • testing: Built-in package
  • testify: Assertions and mocks
  • gomega: Matcher library

Framework Examples

Jest (JavaScript)

// sum.js
function sum(a, b) {
    return a + b;
}
module.exports = sum;

// sum.test.js
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).toBe(3);
});

test('handles negative numbers', () => {
    expect(sum(-1, 1)).toBe(0);
});
                

pytest (Python)

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

# test_calculator.py
import pytest
from calculator import Calculator

@pytest.fixture
def calculator():
    return Calculator()

def test_add_positive_numbers(calculator):
    assert calculator.add(5, 3) == 8

def test_add_negative_numbers(calculator):
    assert calculator.add(-5, 3) == -2

def test_add_zero(calculator):
    assert calculator.add(0, 5) == 5
                

JUnit 5 (Java)

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

// CalculatorTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {
    private Calculator calculator;

    @BeforeEach
    void setUp() {
        calculator = new Calculator();
    }

    @Test
    @DisplayName("Should add two positive numbers")
    void testAddPositiveNumbers() {
        assertEquals(8, calculator.add(5, 3));
    }

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

    @Test
    void testAddZero() {
        assertEquals(5, calculator.add(0, 5));
    }
}
                

Test-Driven Development (TDD)

TDD is a software development approach where tests are written before the implementation code.

The Red-Green-Refactor Cycle

1. Red

Write a failing test that defines the desired behavior.

  • Test should fail initially
  • Confirms test is actually running
  • Documents expected behavior

2. Green

Write the minimum code necessary to make the test pass.

  • Focus on making it work
  • Don't worry about perfection
  • Get to green quickly

3. Refactor

Improve the code while keeping tests green.

  • Remove duplication
  • Improve design
  • Optimize performance

TDD Example: String Calculator

Step 1: Write Failing Test (Red)

test('should return 0 for empty string', () => {
    const calculator = new StringCalculator();
    expect(calculator.add('')).toBe(0);
});

// Run test: FAILS (StringCalculator doesn't exist)
                

Step 2: Make Test Pass (Green)

class StringCalculator {
    add(numbers) {
        if (numbers === '') return 0;
    }
}

// Run test: PASSES
                

Step 3: Add More Tests and Refactor

test('should return number for single number', () => {
    const calculator = new StringCalculator();
    expect(calculator.add('5')).toBe(5);
});

test('should add two numbers', () => {
    const calculator = new StringCalculator();
    expect(calculator.add('1,2')).toBe(3);
});

// Refactored implementation
class StringCalculator {
    add(numbers) {
        if (numbers === '') return 0;

        const nums = numbers.split(',').map(Number);
        return nums.reduce((sum, num) => sum + num, 0);
    }
}
                

Benefits of TDD

  • Better Design: Forces you to think about interfaces and dependencies
  • Complete Coverage: All code is covered by tests by definition
  • Living Documentation: Tests document expected behavior
  • Fewer Bugs: Catches issues immediately during development
  • Refactoring Safety: Confidence to improve code without breaking it

Assertions and Matchers

Assertions validate that actual results match expected results. Different frameworks provide various assertion styles.

Common Assertion Types

Equality Assertions

// Jest
expect(value).toBe(5);                    // Strict equality (===)
expect(value).toEqual({ a: 1, b: 2 });   // Deep equality
expect(value).not.toBe(3);                // Negation

// pytest
assert value == 5
assert value != 3
assert obj == {'a': 1, 'b': 2}

// JUnit
assertEquals(5, value);
assertNotEquals(3, value);
                

Boolean Assertions

// Jest
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// pytest
assert value is True
assert value is False
assert value is None

// JUnit
assertTrue(condition);
assertFalse(condition);
assertNull(value);
assertNotNull(value);
                

Numeric Assertions

// Jest
expect(value).toBeGreaterThan(5);
expect(value).toBeGreaterThanOrEqual(5);
expect(value).toBeLessThan(10);
expect(value).toBeCloseTo(0.3, 2);  // Floating point comparison

// pytest
assert value > 5
assert value >= 5
assert value < 10
assert abs(value - 0.3) < 0.01

// JUnit
assertTrue(value > 5);
assertEquals(0.3, value, 0.01);  // Delta for floating point
                

String Assertions

// Jest
expect(string).toMatch(/pattern/);
expect(string).toContain('substring');
expect(string).toHaveLength(5);

// pytest
assert 'substring' in string
assert len(string) == 5
import re
assert re.match(r'pattern', string)

// JUnit (with AssertJ)
assertThat(string).contains("substring");
assertThat(string).hasSize(5);
assertThat(string).matches("pattern");
                

Collection Assertions

// Jest
expect(array).toContain(item);
expect(array).toHaveLength(3);
expect(array).toEqual(expect.arrayContaining([1, 2]));

// pytest
assert item in array
assert len(array) == 3
assert set([1, 2]).issubset(array)

// JUnit (with AssertJ)
assertThat(list).contains(item);
assertThat(list).hasSize(3);
assertThat(list).containsExactly(1, 2, 3);
                

Exception Assertions

// Jest
expect(() => {
    throw new Error('Error message');
}).toThrow();

expect(() => {
    throw new Error('Specific error');
}).toThrow('Specific error');

// pytest
import pytest

def test_exception():
    with pytest.raises(ValueError):
        raise ValueError("error message")

    with pytest.raises(ValueError, match="error"):
        raise ValueError("error message")

// JUnit
assertThrows(IllegalArgumentException.class, () -> {
    methodThatThrows();
});

Exception exception = assertThrows(
    IllegalArgumentException.class,
    () -> methodThatThrows()
);
assertEquals("Expected message", exception.getMessage());
                

Mocking and Test Doubles

Test doubles replace real dependencies to isolate the unit under test. There are several types:

Types of Test Doubles

Dummy

Objects passed around but never used. Typically used to fill parameter lists.

const dummy = null;

Stub

Provides predetermined responses to method calls. Used to control test conditions.

stub.getUser() returns fixedUser

Spy

Records information about how it was used (calls, arguments). Real methods can still be called.

verify spy.save() was called

Mock

Pre-programmed with expectations about calls it should receive. Verifies behavior.

mock expects save(user)

Fake

Working implementation, but simplified. Example: in-memory database.

fakeDB = new InMemoryDB()

Mocking Examples

Jest Mocking

// Service to test
class UserService {
    constructor(userRepository, emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }

    async createUser(userData) {
        const user = await this.userRepository.save(userData);
        await this.emailService.sendWelcomeEmail(user.email);
        return user;
    }
}

// Test with mocks
describe('UserService', () => {
    let userService;
    let mockRepository;
    let mockEmailService;

    beforeEach(() => {
        // Create mock objects
        mockRepository = {
            save: jest.fn()
        };
        mockEmailService = {
            sendWelcomeEmail: jest.fn()
        };

        userService = new UserService(mockRepository, mockEmailService);
    });

    test('should save user and send welcome email', async () => {
        // Arrange
        const userData = { name: 'John', email: 'john@example.com' };
        const savedUser = { id: 1, ...userData };
        mockRepository.save.mockResolvedValue(savedUser);

        // Act
        const result = await userService.createUser(userData);

        // Assert
        expect(mockRepository.save).toHaveBeenCalledWith(userData);
        expect(mockEmailService.sendWelcomeEmail)
            .toHaveBeenCalledWith('john@example.com');
        expect(result).toEqual(savedUser);
    });
});
                

Python unittest.mock

from unittest.mock import Mock, patch
import pytest

class UserService:
    def __init__(self, user_repository, email_service):
        self.user_repository = user_repository
        self.email_service = email_service

    def create_user(self, user_data):
        user = self.user_repository.save(user_data)
        self.email_service.send_welcome_email(user['email'])
        return user

def test_create_user():
    # Arrange
    mock_repository = Mock()
    mock_email_service = Mock()

    user_data = {'name': 'John', 'email': 'john@example.com'}
    saved_user = {'id': 1, **user_data}
    mock_repository.save.return_value = saved_user

    service = UserService(mock_repository, mock_email_service)

    # Act
    result = service.create_user(user_data)

    # Assert
    mock_repository.save.assert_called_once_with(user_data)
    mock_email_service.send_welcome_email.assert_called_once_with(
        'john@example.com'
    )
    assert result == saved_user
                

Mockito (Java)

import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

class UserServiceTest {
    @Test
    void shouldSaveUserAndSendWelcomeEmail() {
        // Arrange
        UserRepository mockRepository = mock(UserRepository.class);
        EmailService mockEmailService = mock(EmailService.class);
        UserService service = new UserService(mockRepository, mockEmailService);

        User userData = new User("John", "john@example.com");
        User savedUser = new User(1, "John", "john@example.com");
        when(mockRepository.save(userData)).thenReturn(savedUser);

        // Act
        User result = service.createUser(userData);

        // Assert
        verify(mockRepository).save(userData);
        verify(mockEmailService).sendWelcomeEmail("john@example.com");
        assertEquals(savedUser, result);
    }
}
                

Test Coverage

Test coverage measures how much of your code is executed during testing. While 100% coverage doesn't guarantee bug-free code, it helps identify untested areas.

Types of Coverage

Line Coverage

Percentage of code lines executed during tests.

Target: 80%+ for critical code

Branch Coverage

Percentage of decision branches (if/else) tested.

More thorough than line coverage

Function Coverage

Percentage of functions called during tests.

Quick indicator of test completeness

Statement Coverage

Percentage of statements executed.

Similar to line coverage

Coverage Tools

JavaScript (Jest with Istanbul)

// package.json
{
    "scripts": {
        "test": "jest",
        "test:coverage": "jest --coverage"
    },
    "jest": {
        "collectCoverageFrom": [
            "src/**/*.js",
            "!src/**/*.test.js"
        ],
        "coverageThresholds": {
            "global": {
                "branches": 80,
                "functions": 80,
                "lines": 80,
                "statements": 80
            }
        }
    }
}

// Run: npm run test:coverage
                

Python (pytest with coverage.py)

# Install
pip install pytest-cov

# Run with coverage
pytest --cov=myapp tests/

# Generate HTML report
pytest --cov=myapp --cov-report=html tests/

# .coveragerc
[run]
omit =
    */tests/*
    */venv/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
                
Coverage Caution: High coverage doesn't mean high quality. Focus on meaningful tests that verify behavior, not just lines of code executed. A single test that exercises 100% of lines but doesn't assert anything is worthless.

Testing Patterns

Test Data Builders

// Create reusable test data builders
class UserBuilder {
    constructor() {
        this.user = {
            id: 1,
            name: 'Test User',
            email: 'test@example.com',
            role: 'user'
        };
    }

    withId(id) {
        this.user.id = id;
        return this;
    }

    withName(name) {
        this.user.name = name;
        return this;
    }

    withEmail(email) {
        this.user.email = email;
        return this;
    }

    asAdmin() {
        this.user.role = 'admin';
        return this;
    }

    build() {
        return { ...this.user };
    }
}

// Usage in tests
test('should process admin user', () => {
    const admin = new UserBuilder()
        .withName('Admin User')
        .asAdmin()
        .build();

    expect(service.canAccessAdminPanel(admin)).toBe(true);
});
                

Parameterized Tests

// Jest
test.each([
    [1, 1, 2],
    [2, 3, 5],
    [0, 5, 5],
    [-1, 1, 0]
])('add(%i, %i) should return %i', (a, b, expected) => {
    expect(calculator.add(a, b)).toBe(expected);
});

// pytest
@pytest.mark.parametrize("a,b,expected", [
    (1, 1, 2),
    (2, 3, 5),
    (0, 5, 5),
    (-1, 1, 0)
])
def test_add(a, b, expected):
    assert calculator.add(a, b) == expected

// JUnit 5
@ParameterizedTest
@CsvSource({
    "1, 1, 2",
    "2, 3, 5",
    "0, 5, 5",
    "-1, 1, 0"
})
void testAdd(int a, int b, int expected) {
    assertEquals(expected, calculator.add(a, b));
}
                

Best Practices

Write Tests First

Practice TDD. Write tests before implementation to ensure testable design.

Test One Thing

Each test should verify one specific behavior. Keep tests focused and simple.

Use Descriptive Names

Test names should clearly describe what is being tested and expected behavior.

Keep Tests Independent

Tests should not depend on each other. Each test should work in isolation.

Avoid Test Logic

Tests should be straightforward. Avoid loops, conditionals, or complex logic.

Don't Test Private Methods

Test public interfaces. Private methods are tested indirectly through public APIs.

Use Setup/Teardown Wisely

Setup common fixtures, but don't hide important context. Balance DRY with clarity.

Test Edge Cases

Test boundary conditions, null values, empty collections, and error scenarios.

Keep Tests Fast

Fast tests encourage frequent running. Mock slow dependencies like databases and APIs.

Common Pitfalls

Testing Anti-Patterns to Avoid

1. Testing Implementation Details

Problem: Tests break when refactoring internal implementation.

// BAD - Testing internal state
expect(service.internalCache.size).toBe(1);

// GOOD - Testing observable behavior
expect(service.getUser(1)).toEqual(expectedUser);

2. Excessive Mocking

Problem: Too many mocks make tests fragile and less valuable.

// BAD - Mocking everything
const mockA = mock(A);
const mockB = mock(B);
const mockC = mock(C);
// Test tells you nothing about real behavior

// GOOD - Mock only external dependencies
const mockDatabase = mock(Database);
// Test real object interactions

3. Tests That Don't Test

Problem: High coverage but no assertions or weak assertions.

// BAD - No assertions
test('should process user', () => {
    service.processUser(user);
});

// GOOD - Verify outcomes
test('should process user', () => {
    service.processUser(user);
    expect(user.processed).toBe(true);
    expect(mockEmailService.send).toHaveBeenCalled();
});

4. Test Interdependence

Problem: Tests depend on execution order or shared state.

// BAD - Shared state
let counter = 0;
test('first test', () => { counter++; });
test('second test', () => { expect(counter).toBe(1); });

// GOOD - Independent tests
test('first test', () => {
    const counter = 0;
    expect(counter + 1).toBe(1);
});

5. Slow Tests

Problem: Tests take too long, discouraging frequent runs.

// BAD - Real database/network calls
test('should save user', async () => {
    await realDatabase.connect();
    await service.save(user);
});

// GOOD - Use mocks or in-memory alternatives
test('should save user', async () => {
    await service.save(user);
    expect(mockRepository.save).toHaveBeenCalled();
});

Practical Examples

Example 1: Testing a Shopping Cart

class ShoppingCart {
    constructor() {
        this.items = [];
    }

    addItem(item, quantity = 1) {
        const existing = this.items.find(i => i.id === item.id);
        if (existing) {
            existing.quantity += quantity;
        } else {
            this.items.push({ ...item, quantity });
        }
    }

    removeItem(itemId) {
        this.items = this.items.filter(i => i.id !== itemId);
    }

    getTotal() {
        return this.items.reduce((sum, item) =>
            sum + (item.price * item.quantity), 0
        );
    }

    isEmpty() {
        return this.items.length === 0;
    }
}

describe('ShoppingCart', () => {
    let cart;

    beforeEach(() => {
        cart = new ShoppingCart();
    });

    describe('addItem', () => {
        test('should add new item to empty cart', () => {
            const item = { id: 1, name: 'Book', price: 10 };
            cart.addItem(item);

            expect(cart.items).toHaveLength(1);
            expect(cart.items[0]).toEqual({ ...item, quantity: 1 });
        });

        test('should increase quantity of existing item', () => {
            const item = { id: 1, name: 'Book', price: 10 };
            cart.addItem(item);
            cart.addItem(item, 2);

            expect(cart.items).toHaveLength(1);
            expect(cart.items[0].quantity).toBe(3);
        });

        test('should handle multiple different items', () => {
            cart.addItem({ id: 1, name: 'Book', price: 10 });
            cart.addItem({ id: 2, name: 'Pen', price: 2 });

            expect(cart.items).toHaveLength(2);
        });
    });

    describe('removeItem', () => {
        test('should remove item from cart', () => {
            const item = { id: 1, name: 'Book', price: 10 };
            cart.addItem(item);
            cart.removeItem(1);

            expect(cart.isEmpty()).toBe(true);
        });

        test('should not affect other items', () => {
            cart.addItem({ id: 1, name: 'Book', price: 10 });
            cart.addItem({ id: 2, name: 'Pen', price: 2 });
            cart.removeItem(1);

            expect(cart.items).toHaveLength(1);
            expect(cart.items[0].id).toBe(2);
        });
    });

    describe('getTotal', () => {
        test('should return 0 for empty cart', () => {
            expect(cart.getTotal()).toBe(0);
        });

        test('should calculate total for single item', () => {
            cart.addItem({ id: 1, name: 'Book', price: 10 }, 2);
            expect(cart.getTotal()).toBe(20);
        });

        test('should calculate total for multiple items', () => {
            cart.addItem({ id: 1, name: 'Book', price: 10 }, 2);
            cart.addItem({ id: 2, name: 'Pen', price: 5 }, 3);
            expect(cart.getTotal()).toBe(35);
        });
    });

    describe('isEmpty', () => {
        test('should return true for new cart', () => {
            expect(cart.isEmpty()).toBe(true);
        });

        test('should return false after adding item', () => {
            cart.addItem({ id: 1, name: 'Book', price: 10 });
            expect(cart.isEmpty()).toBe(false);
        });

        test('should return true after removing all items', () => {
            cart.addItem({ id: 1, name: 'Book', price: 10 });
            cart.removeItem(1);
            expect(cart.isEmpty()).toBe(true);
        });
    });
});
                

Resources and Further Learning

Books

  • "Test Driven Development: By Example" by Kent Beck
  • "The Art of Unit Testing" by Roy Osherove
  • "Working Effectively with Legacy Code" by Michael Feathers
  • "Growing Object-Oriented Software, Guided by Tests" by Steve Freeman

Online Resources

  • Jest Documentation - jestjs.io
  • pytest Documentation - pytest.org
  • JUnit 5 User Guide - junit.org
  • Martin Fowler's Testing Articles - martinfowler.com

Tools and Frameworks

  • Coverage Tools: Istanbul, Coverage.py, JaCoCo
  • Mocking Libraries: Mockito, Sinon, unittest.mock
  • Assertion Libraries: Chai, AssertJ, Hamcrest
  • Test Runners: Jest, pytest, JUnit
Previous: Software Testing Overview Next: Integration Testing