How to write meaningful unit tests — the AAA pattern, what to test, mocking strategies, and coverage that actually matters.
Every good unit test follows Arrange → Act → Assert:
import { calculateTax } from './tax';
test('calculateTax returns 10% of the amount', () => {
// Arrange
const amount = 100;
const rate = 0.1;
// Act
const result = calculateTax(amount, rate);
// Assert
expect(result).toBe(10);
});
Test:
Don't test:
// Bad — what does 'works' mean?
test('calculateTax works', () => { ... });
// Good — describes input and expected output
test('calculateTax returns 0 when amount is 0', () => { ... });
test('calculateTax throws when rate is negative', () => { ... });
// Pattern: 'should [do X] when [condition Y]'
describe('UserService', () => {
describe('createUser', () => {
it('should return the created user with an id', async () => { ... });
it('should throw ValidationError when email is missing', async () => { ... });
});
});
import { sendWelcomeEmail } from './emailService';
jest.mock('./emailService');
test('createUser sends a welcome email', async () => {
const mockSend = sendWelcomeEmail.mockResolvedValueOnce(true);
await createUser({ name: 'Trong', email: 'trong@test.com' });
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({
email: 'trong@test.com'
}));
});
// Return promise or use async/await
test('fetchUser returns user data', async () => {
const user = await fetchUser(1);
expect(user.id).toBe(1);
expect(user.name).toBeDefined();
});
// Testing rejections
test('fetchUser throws on 404', async () => {
await expect(fetchUser(9999)).rejects.toThrow('User not found');
});
100% coverage is a vanity metric. A test that calls every line without asserting anything is useless.
Aim for:
# Jest coverage report
jest --coverage
# Set minimum thresholds in jest.config.js
coverageThreshold: {
global: { branches: 80, functions: 85, lines: 85 }
}