Unit Testing
Best practices for writing effective unit tests that verify individual components
Unit tests verify that individual units of code (functions, methods, classes) work correctly in isolation. They form the foundation of the testing pyramid and should comprise the majority of your test suite.
What is a Unit Test?
A unit test verifies a small, isolated piece of code - typically a single function or method. Unit tests should:
- Execute quickly (milliseconds)
- Test one thing at a time
- Not depend on external systems
- Be deterministic (same input = same output)
Unit Test Anatomy
describe('calculateDiscount', () => {
it('should apply 10% discount for orders over $100', () => {
// Arrange
const orderTotal = 150;
const discountThreshold = 100;
const discountRate = 0.10;
// Act
const result = calculateDiscount(orderTotal, discountThreshold, discountRate);
// Assert
expect(result).toBe(15); // 10% of $150
});
it('should return 0 for orders under threshold', () => {
const result = calculateDiscount(50, 100, 0.10);
expect(result).toBe(0);
});
});Unit Testing Principles
Test Behavior, Not Implementation
Bad - Testing implementation details:
// Brittle: breaks if internal structure changes
it('should store items in the _items array', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Widget' });
expect(cart._items.length).toBe(1);
});Good - Testing behavior:
// Robust: tests public API and behavior
it('should include added item in cart contents', () => {
const cart = new ShoppingCart();
const widget = { id: 1, name: 'Widget' };
cart.addItem(widget);
expect(cart.getItems()).toContainEqual(widget);
expect(cart.itemCount).toBe(1);
});Write Descriptive Test Names
Test names should describe the scenario and expected outcome:
// Bad
it('test1', () => { ... });
it('works', () => { ... });
// Good
it('should reject password shorter than 8 characters', () => { ... });
it('should return null when user is not found', () => { ... });
it('should throw AuthenticationError when credentials are invalid', () => { ... });One Assertion Per Concept
Each test should verify one logical concept (though multiple assertions may be needed):
// Testing user creation - multiple assertions for one concept
it('should create user with provided details', () => {
const userData = { name: 'John', email: 'john@example.com' };
const user = userService.create(userData);
expect(user.id).toBeDefined();
expect(user.name).toBe('John');
expect(user.email).toBe('john@example.com');
expect(user.createdAt).toBeInstanceOf(Date);
});Test Organization
Describe Blocks for Grouping
describe('UserService', () => {
describe('create', () => {
it('should create user with valid data', () => { ... });
it('should reject duplicate email', () => { ... });
it('should hash password before storing', () => { ... });
});
describe('authenticate', () => {
it('should return token for valid credentials', () => { ... });
it('should reject invalid password', () => { ... });
it('should lock account after 5 failed attempts', () => { ... });
});
describe('delete', () => {
it('should soft delete by default', () => { ... });
it('should hard delete when specified', () => { ... });
});
});Setup and Teardown
describe('OrderProcessor', () => {
let processor;
let mockPaymentGateway;
beforeAll(() => {
// Run once before all tests in this describe block
initializeTestDatabase();
});
beforeEach(() => {
// Run before each test
mockPaymentGateway = createMockPaymentGateway();
processor = new OrderProcessor(mockPaymentGateway);
});
afterEach(() => {
// Run after each test
jest.clearAllMocks();
});
afterAll(() => {
// Run once after all tests
cleanupTestDatabase();
});
it('should process valid order', () => { ... });
});Mocking and Test Doubles
Types of Test Doubles
| Type | Purpose | Example |
|---|---|---|
| Stub | Returns predefined values | jest.fn().mockReturnValue(42) |
| Mock | Tracks calls and verifies interactions | expect(mock).toHaveBeenCalledWith(...) |
| Spy | Wraps real function, tracks calls | jest.spyOn(object, 'method') |
| Fake | Working implementation for testing | In-memory database |
When to Mock
Mock these:
- External services (APIs, databases)
- Time-dependent operations
- Random number generation
- File system operations
- Network requests
Don't mock these:
- The code you're testing
- Simple value objects
- Pure functions
- Standard library functions
Mocking Examples
// Mocking a module
jest.mock('./emailService', () => ({
sendEmail: jest.fn().mockResolvedValue({ success: true }),
}));
// Mocking a method
const mockSave = jest.spyOn(userRepository, 'save')
.mockResolvedValue({ id: 1, name: 'John' });
// Mocking time
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15'));
// Verify mock was called correctly
expect(mockSave).toHaveBeenCalledTimes(1);
expect(mockSave).toHaveBeenCalledWith(expect.objectContaining({
name: 'John',
email: 'john@example.com',
}));Testing Edge Cases
Input Validation
describe('validateEmail', () => {
// Happy path
it('should accept valid email', () => {
expect(validateEmail('user@example.com')).toBe(true);
});
// Edge cases
it('should reject email without @', () => {
expect(validateEmail('userexample.com')).toBe(false);
});
it('should reject email without domain', () => {
expect(validateEmail('user@')).toBe(false);
});
it('should reject empty string', () => {
expect(validateEmail('')).toBe(false);
});
it('should handle null input', () => {
expect(validateEmail(null)).toBe(false);
});
it('should handle undefined input', () => {
expect(validateEmail(undefined)).toBe(false);
});
// Boundary conditions
it('should accept email with maximum length', () => {
const longEmail = 'a'.repeat(64) + '@' + 'b'.repeat(63) + '.com';
expect(validateEmail(longEmail)).toBe(true);
});
});Error Handling
describe('divideNumbers', () => {
it('should throw when dividing by zero', () => {
expect(() => divideNumbers(10, 0)).toThrow('Division by zero');
});
it('should throw TypeError for non-numeric input', () => {
expect(() => divideNumbers('10', 2)).toThrow(TypeError);
});
});
describe('fetchUser', () => {
it('should throw NotFoundError when user does not exist', async () => {
mockRepository.findById.mockResolvedValue(null);
await expect(userService.fetchUser(999))
.rejects
.toThrow(NotFoundError);
});
});Parameterized Tests
Test multiple cases with the same logic:
describe('isValidPassword', () => {
const validPasswords = [
'Password123!',
'Str0ng_P@ssword',
'Complex#Pass99',
];
const invalidPasswords = [
['short', 'too short'],
['alllowercase1!', 'missing uppercase'],
['ALLUPPERCASE1!', 'missing lowercase'],
['NoNumbers!!', 'missing number'],
['NoSpecial123', 'missing special character'],
];
test.each(validPasswords)('should accept valid password: %s', (password) => {
expect(isValidPassword(password)).toBe(true);
});
test.each(invalidPasswords)(
'should reject invalid password: %s (%s)',
(password, reason) => {
expect(isValidPassword(password)).toBe(false);
}
);
});Async Testing
Testing Promises
// Async/await (preferred)
it('should fetch user data', async () => {
const user = await userService.getById(1);
expect(user.name).toBe('John');
});
// Testing rejection
it('should reject when user not found', async () => {
await expect(userService.getById(999)).rejects.toThrow('User not found');
});
// Using resolves/rejects matchers
it('should resolve with user data', () => {
return expect(userService.getById(1)).resolves.toMatchObject({
name: 'John',
});
});Testing Callbacks
it('should call callback with result', (done) => {
legacyApi.fetchData((err, data) => {
expect(err).toBeNull();
expect(data).toBeDefined();
done();
});
});Testing Timers
jest.useFakeTimers();
it('should debounce function calls', () => {
const callback = jest.fn();
const debounced = debounce(callback, 1000);
debounced();
debounced();
debounced();
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
});Unit Testing for Compliance
Requirement Traceability
Document which tests verify which requirements:
/**
* @requirement REQ-SEC-001
* @description Password must meet complexity requirements
*/
describe('Password Validation - REQ-SEC-001', () => {
it('should require minimum 8 characters', () => { ... });
it('should require at least one uppercase letter', () => { ... });
it('should require at least one number', () => { ... });
it('should require at least one special character', () => { ... });
});Test Evidence
Generate test reports for audit trails:
# Generate JUnit XML report
jest --reporters=default --reporters=jest-junit
# Generate HTML report
jest --coverage --coverageReporters=htmlBest Practices Summary
Do
- Keep tests fast (< 100ms each)
- Use descriptive test names
- Follow AAA pattern (Arrange-Act-Assert)
- Test edge cases and error conditions
- Mock external dependencies
- Run tests in isolation
Don't
- Test implementation details
- Share state between tests
- Make tests depend on execution order
- Use production data in tests
- Skip writing tests for "simple" code
- Ignore failing tests
Related Resources
Compliance
This section fulfills ISO 13485 requirements for design verification (7.3.6) and control of records (4.2.4), and ISO 27001 requirements for secure development lifecycle (A.8.25) and security testing (A.8.29).
How is this guide?
Last updated on