Master the art of writing effective unit tests for robust, maintainable software
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.
Unit testing provides numerous benefits:
| 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 |
Effective unit tests follow the FIRST principles:
Tests should run quickly (milliseconds). Developers won't run slow tests frequently, reducing their effectiveness.
Target: < 100ms per test
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
Tests should produce the same results every time, in any environment.
Avoid: Time-dependent tests, random data, external dependencies
Tests should automatically detect pass/fail without manual verification.
Use: Assertions to validate expected outcomes
Tests should be written at the right time—ideally before or alongside production code (TDD).
Practice: Don't leave tests until the end
Every unit test should follow this structure:
// 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);
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);
});
});
Good test names should describe:
should_ExpectedBehavior_When_Conditiongiven_Preconditions_when_StateUnderTest_then_ExpectedBehaviortest_MethodName_Scenario_ExpectedResult
// 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);
});
# 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
// 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));
}
}
TDD is a software development approach where tests are written before the implementation code.
Write a failing test that defines the desired behavior.
Write the minimum code necessary to make the test pass.
Improve the code while keeping tests green.
test('should return 0 for empty string', () => {
const calculator = new StringCalculator();
expect(calculator.add('')).toBe(0);
});
// Run test: FAILS (StringCalculator doesn't exist)
class StringCalculator {
add(numbers) {
if (numbers === '') return 0;
}
}
// Run test: PASSES
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);
}
}
Assertions validate that actual results match expected results. Different frameworks provide various assertion styles.
// 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);
// 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);
// 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
// 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");
// 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);
// 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());
Test doubles replace real dependencies to isolate the unit under test. There are several types:
Objects passed around but never used. Typically used to fill parameter lists.
const dummy = null;
Provides predetermined responses to method calls. Used to control test conditions.
stub.getUser() returns fixedUser
Records information about how it was used (calls, arguments). Real methods can still be called.
verify spy.save() was called
Pre-programmed with expectations about calls it should receive. Verifies behavior.
mock expects save(user)
Working implementation, but simplified. Example: in-memory database.
fakeDB = new InMemoryDB()
// 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);
});
});
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
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 measures how much of your code is executed during testing. While 100% coverage doesn't guarantee bug-free code, it helps identify untested areas.
Percentage of code lines executed during tests.
Target: 80%+ for critical code
Percentage of decision branches (if/else) tested.
More thorough than line coverage
Percentage of functions called during tests.
Quick indicator of test completeness
Percentage of statements executed.
Similar to line coverage
// 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
# 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
// 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);
});
// 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));
}
Practice TDD. Write tests before implementation to ensure testable design.
Each test should verify one specific behavior. Keep tests focused and simple.
Test names should clearly describe what is being tested and expected behavior.
Tests should not depend on each other. Each test should work in isolation.
Tests should be straightforward. Avoid loops, conditionals, or complex logic.
Test public interfaces. Private methods are tested indirectly through public APIs.
Setup common fixtures, but don't hide important context. Balance DRY with clarity.
Test boundary conditions, null values, empty collections, and error scenarios.
Fast tests encourage frequent running. Mock slow dependencies like databases and APIs.
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);
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
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();
});
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);
});
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();
});
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);
});
});
});