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