Back to Blog
TestingUnit TestsJestJavaScript

Unit Testing Best Practices for JavaScript

Write tests that matter. From test structure to mocking strategies to avoiding common pitfalls.

B
Bootspring Team
Engineering
May 28, 2024
5 min read

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.

Share this article

Help spread the word about Bootspring