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