Unit tests verify individual pieces of code work correctly. Well-written tests catch bugs early and serve as documentation. Here's how to write tests that provide real value.
Test Structure (AAA Pattern)#
1describe('UserService', () => {
2 describe('createUser', () => {
3 it('should create a user with valid data', async () => {
4 // Arrange
5 const userData = {
6 email: 'test@example.com',
7 name: 'Test User',
8 };
9 const userRepository = new MockUserRepository();
10
11 // Act
12 const user = await createUser(userData, userRepository);
13
14 // Assert
15 expect(user.email).toBe('test@example.com');
16 expect(user.name).toBe('Test User');
17 expect(user.id).toBeDefined();
18 });
19 });
20});Naming Conventions#
1// Pattern: should [expected behavior] when [condition]
2
3describe('calculateTotal', () => {
4 it('should return 0 when cart is empty', () => {});
5 it('should sum all item prices', () => {});
6 it('should apply discount when code is valid', () => {});
7 it('should throw error when items have negative prices', () => {});
8});
9
10// Or: [unit] [behavior] [condition]
11
12describe('Cart', () => {
13 it('returns zero total for empty cart', () => {});
14 it('calculates correct total with multiple items', () => {});
15 it('applies percentage discount correctly', () => {});
16});Testing Pure Functions#
1// Pure function - easy to test
2function calculateDiscount(price: number, percentage: number): number {
3 if (percentage < 0 || percentage > 100) {
4 throw new Error('Invalid percentage');
5 }
6 return price * (percentage / 100);
7}
8
9describe('calculateDiscount', () => {
10 it('should calculate 10% of 100 as 10', () => {
11 expect(calculateDiscount(100, 10)).toBe(10);
12 });
13
14 it('should return 0 for 0%', () => {
15 expect(calculateDiscount(100, 0)).toBe(0);
16 });
17
18 it('should return full price for 100%', () => {
19 expect(calculateDiscount(100, 100)).toBe(100);
20 });
21
22 it('should handle decimal prices', () => {
23 expect(calculateDiscount(99.99, 10)).toBeCloseTo(9.999);
24 });
25
26 it('should throw for negative percentage', () => {
27 expect(() => calculateDiscount(100, -10)).toThrow('Invalid percentage');
28 });
29
30 it('should throw for percentage over 100', () => {
31 expect(() => calculateDiscount(100, 150)).toThrow('Invalid percentage');
32 });
33});Mocking Dependencies#
1// Service with dependencies
2class OrderService {
3 constructor(
4 private userRepo: UserRepository,
5 private paymentGateway: PaymentGateway,
6 private emailService: EmailService
7 ) {}
8
9 async placeOrder(userId: string, items: Item[]): Promise<Order> {
10 const user = await this.userRepo.findById(userId);
11 if (!user) throw new Error('User not found');
12
13 const total = items.reduce((sum, item) => sum + item.price, 0);
14 await this.paymentGateway.charge(user.paymentMethod, total);
15
16 const order = { id: generateId(), userId, items, total };
17 await this.emailService.sendOrderConfirmation(user.email, order);
18
19 return order;
20 }
21}
22
23// Test with mocks
24describe('OrderService', () => {
25 let orderService: OrderService;
26 let mockUserRepo: jest.Mocked<UserRepository>;
27 let mockPaymentGateway: jest.Mocked<PaymentGateway>;
28 let mockEmailService: jest.Mocked<EmailService>;
29
30 beforeEach(() => {
31 mockUserRepo = {
32 findById: jest.fn(),
33 };
34 mockPaymentGateway = {
35 charge: jest.fn(),
36 };
37 mockEmailService = {
38 sendOrderConfirmation: jest.fn(),
39 };
40
41 orderService = new OrderService(
42 mockUserRepo,
43 mockPaymentGateway,
44 mockEmailService
45 );
46 });
47
48 it('should place order successfully', async () => {
49 // Arrange
50 const user = { id: '1', email: 'test@example.com', paymentMethod: 'card_123' };
51 const items = [{ id: 'item1', price: 100 }];
52
53 mockUserRepo.findById.mockResolvedValue(user);
54 mockPaymentGateway.charge.mockResolvedValue({ success: true });
55 mockEmailService.sendOrderConfirmation.mockResolvedValue(undefined);
56
57 // Act
58 const order = await orderService.placeOrder('1', items);
59
60 // Assert
61 expect(order.total).toBe(100);
62 expect(mockPaymentGateway.charge).toHaveBeenCalledWith('card_123', 100);
63 expect(mockEmailService.sendOrderConfirmation).toHaveBeenCalledWith(
64 'test@example.com',
65 expect.objectContaining({ total: 100 })
66 );
67 });
68
69 it('should throw when user not found', async () => {
70 mockUserRepo.findById.mockResolvedValue(null);
71
72 await expect(orderService.placeOrder('1', [])).rejects.toThrow('User not found');
73 expect(mockPaymentGateway.charge).not.toHaveBeenCalled();
74 });
75});Testing Async Code#
1// Promises
2it('should fetch user data', async () => {
3 const user = await fetchUser('123');
4 expect(user.name).toBe('John');
5});
6
7// Error handling
8it('should handle fetch errors', async () => {
9 await expect(fetchUser('invalid')).rejects.toThrow('User not found');
10});
11
12// Timers
13jest.useFakeTimers();
14
15it('should debounce calls', () => {
16 const callback = jest.fn();
17 const debounced = debounce(callback, 1000);
18
19 debounced();
20 debounced();
21 debounced();
22
23 expect(callback).not.toHaveBeenCalled();
24
25 jest.advanceTimersByTime(1000);
26
27 expect(callback).toHaveBeenCalledTimes(1);
28});Test Data Factories#
1// Factory functions for test data
2function createUser(overrides: Partial<User> = {}): User {
3 return {
4 id: 'user-123',
5 email: 'test@example.com',
6 name: 'Test User',
7 role: 'user',
8 createdAt: new Date('2024-01-01'),
9 ...overrides,
10 };
11}
12
13function createOrder(overrides: Partial<Order> = {}): Order {
14 return {
15 id: 'order-123',
16 userId: 'user-123',
17 items: [],
18 total: 0,
19 status: 'pending',
20 ...overrides,
21 };
22}
23
24// Usage in tests
25it('should apply admin discount', () => {
26 const admin = createUser({ role: 'admin' });
27 const order = createOrder({ total: 100 });
28
29 const discounted = applyRoleDiscount(order, admin);
30
31 expect(discounted.total).toBe(90); // 10% admin discount
32});What Not to Test#
1// ❌ Don't test implementation details
2it('should call setState with user data', () => {
3 // Testing React internals - brittle
4});
5
6// ✅ Test behavior
7it('should display user name after loading', () => {
8 // Testing what user sees
9});
10
11// ❌ Don't test library code
12it('should sort array correctly', () => {
13 expect([3, 1, 2].sort()).toEqual([1, 2, 3]); // Testing Array.sort
14});
15
16// ✅ Test your logic
17it('should sort users by creation date', () => {
18 const users = sortUsersByDate([user2, user1, user3]);
19 expect(users[0].id).toBe(user1.id);
20});
21
22// ❌ Don't test trivial code
23it('should return name', () => {
24 const user = new User('John');
25 expect(user.getName()).toBe('John'); // Just a getter
26});Code Coverage Guidelines#
1// jest.config.js
2module.exports = {
3 coverageThreshold: {
4 global: {
5 branches: 80,
6 functions: 80,
7 lines: 80,
8 statements: 80,
9 },
10 },
11 collectCoverageFrom: [
12 'src/**/*.{js,ts}',
13 '!src/**/*.d.ts',
14 '!src/**/*.test.{js,ts}',
15 '!src/**/index.{js,ts}', // barrel files
16 ],
17};Coverage targets:
- 80%+ is a good goal
- 100% is often counterproductive
- Focus on critical paths
- Avoid testing just for coverage
Conclusion#
Good unit tests are fast, isolated, and focused on behavior. They catch bugs, enable refactoring, and document intent.
Write tests that you'd want to maintain—clear, purposeful, and valuable.