Unit Testing Strategies
Unit Testing Strategies
Unit testing forms the foundation of the testing pyramid. Fast, isolated tests that verify individual components in isolation.
Unit Testing Principles
1. Fast Execution
Unit tests should run in milliseconds:
- <10ms per test
- Full suite <5 minutes
- Run on every save (watch mode)
2. Isolation
Each test should:
- Run independently
- Not depend on other tests
- Not share state
- Be deterministic
3. Single Responsibility
Test one thing per test:
// BAD - Multiple responsibilities it('should handle user operations', () => { expect(createUser(...)).toBeDefined() expect(updateUser(...)).toBeTruthy() expect(deleteUser(...)).toBe(true) }) // GOOD - Single responsibility describe('User operations', () => { it('should create user', () => { expect(createUser(...)).toBeDefined() }) it('should update user', () => { expect(updateUser(...)).toBeTruthy() }) it('should delete user', () => { expect(deleteUser(...)).toBe(true) }) })
Test Structure (AAA Pattern)
Arrange - Act - Assert
it('should calculate total price with tax', () => { // Arrange - Setup test data const items = [ { price: 10, quantity: 2 }, { price: 5, quantity: 3 } ] const taxRate = 0.1 // Act - Execute the code under test const total = calculateTotalWithTax(items, taxRate) // Assert - Verify the result expect(total).toBe(38.5) // (10*2 + 5*3) * 1.1 = 38.5 })
Given-When-Then (BDD Style)
describe('User authentication', () => { it('should reject invalid credentials', async () => { // Given a user with valid credentials const user = await createUser({ email: 'user@example.com', password: 'correct' }) // When attempting to login with invalid password const result = authService.login('user@example.com', 'wrong') // Then authentication should fail await expect(result).rejects.toThrow('Invalid credentials') }) })
Mocking Strategies
1. Mock External Dependencies
import { vi } from 'vitest' import { EmailService } from '../services/EmailService' import { UserRegistration } from '../UserRegistration' describe('UserRegistration', () => { it('should send welcome email after registration', async () => { // Mock email service const emailService = { send: vi.fn().mockResolvedValue(true) } const registration = new UserRegistration(emailService) await registration.register({ email: 'user@example.com', password: 'password123' }) // Verify email was sent expect(emailService.send).toHaveBeenCalledWith({ to: 'user@example.com', subject: 'Welcome!', template: 'welcome' }) }) })
2. Spy on Methods
it('should call validation before saving', async () => { const user = new User({ email: 'user@example.com' }) const validateSpy = vi.spyOn(user, 'validate') await user.save() expect(validateSpy).toHaveBeenCalled() })
3. Mock Modules
// Mock entire module vi.mock('../services/StripeService', () => ({ StripeService: vi.fn().mockImplementation(() => ({ charge: vi.fn().mockResolvedValue({ success: true }) })) })) it('should process payment', async () => { const order = new Order({ total: 100 }) const result = await order.checkout() expect(result.success).toBe(true) })
Testing Patterns
1. Test Data Builders
// builders/UserBuilder.ts export class UserBuilder { private user = { email: 'default@example.com', password: 'password123', role: 'user', active: true } withEmail(email: string) { this.user.email = email return this } withRole(role: string) { this.user.role = role return this } inactive() { this.user.active = false return this } build() { return this.user } } // Usage in tests it('should not allow inactive users to login', async () => { const user = new UserBuilder() .withEmail('inactive@example.com') .inactive() .build() await expect(authService.login(user.email, user.password)) .rejects.toThrow('Account inactive') })
2. Fixtures
// fixtures/users.ts export const testUsers = { admin: { email: 'admin@example.com', password: 'admin123', role: 'admin' }, regular: { email: 'user@example.com', password: 'user123', role: 'user' }, inactive: { email: 'inactive@example.com', password: 'inactive123', role: 'user', active: false } } // Usage import { testUsers } from '../fixtures/users' it('should allow admin access', async () => { const result = await authService.login( testUsers.admin.email, testUsers.admin.password ) expect(result.user.role).toBe('admin') })
3. Factory Functions
// factories/order.ts let orderId = 1 export function createTestOrder(overrides = {}) { return { id: orderId++, userId: 1, total: 100, status: 'pending', items: [], createdAt: new Date(), ...overrides } } // Usage it('should calculate shipping for large orders', () => { const order = createTestOrder({ total: 500 }) expect(calculateShipping(order)).toBe(0) // Free shipping })
Testing Different Code Types
1. Pure Functions
Easiest to test - no side effects:
// src/utils/math.ts export function add(a: number, b: number): number { return a + b } // tests/utils/math.test.ts describe('add', () => { it('should add two positive numbers', () => { expect(add(2, 3)).toBe(5) }) it('should add negative numbers', () => { expect(add(-2, -3)).toBe(-5) }) it('should add positive and negative', () => { expect(add(5, -3)).toBe(2) }) })
2. Classes
Test public interface, not implementation:
// src/services/CartService.ts export class CartService { private items: CartItem[] = [] addItem(item: CartItem) { this.items.push(item) } getTotal(): number { return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0) } isEmpty(): boolean { return this.items.length === 0 } } // tests/services/CartService.test.ts describe('CartService', () => { let cart: CartService beforeEach(() => { cart = new CartService() }) it('should start empty', () => { expect(cart.isEmpty()).toBe(true) }) it('should calculate total', () => { cart.addItem({ price: 10, quantity: 2 }) cart.addItem({ price: 5, quantity: 3 }) expect(cart.getTotal()).toBe(35) }) })
3. Async Functions
// src/services/UserService.ts export class UserService { async findUser(id: string): Promise<User | null> { const user = await db.users.findOne({ id }) return user } } // tests/services/UserService.test.ts describe('UserService', () => { it('should find user by id', async () => { const service = new UserService() const user = await service.findUser('123') expect(user).toBeDefined() expect(user.id).toBe('123') }) it('should return null when user not found', async () => { const service = new UserService() const user = await service.findUser('nonexistent') expect(user).toBeNull() }) })
4. Error Handling
describe('ValidationService', () => { it('should throw error for invalid email', () => { expect(() => validateEmail('invalid-email')) .toThrow('Invalid email format') }) it('should throw specific error type', () => { expect(() => processPayment({ amount: -10 })) .toThrow(ValidationError) }) it('should handle async errors', async () => { await expect(fetchUser('invalid-id')) .rejects .toThrow('User not found') }) })
Test Organization
File Structure
project/
src/
auth/
AuthService.ts
TokenManager.ts
api/
users.ts
products.ts
tests/
auth/
AuthService.test.ts
TokenManager.test.ts
api/
users.test.ts
products.test.ts
fixtures/
users.ts
builders/
UserBuilder.ts
helpers/
test-utils.ts
Naming Conventions
// GOOD - Descriptive test names describe('AuthService', () => { describe('login', () => { it('should authenticate user with valid credentials', () => {}) it('should reject invalid email format', () => {}) it('should reject incorrect password', () => {}) it('should reject inactive user accounts', () => {}) }) describe('logout', () => { it('should invalidate user token', () => {}) it('should clear user session', () => {}) }) })
Test Utilities
Setup/Teardown
describe('Database operations', () => { beforeAll(async () => { // One-time setup before all tests await db.connect() }) afterAll(async () => { // One-time cleanup after all tests await db.disconnect() }) beforeEach(async () => { // Setup before each test await db.clear() }) afterEach(async () => { // Cleanup after each test await db.resetSequences() }) it('should insert user', async () => { const user = await db.users.insert({ email: 'test@example.com' }) expect(user.id).toBeDefined() }) })
Custom Matchers
// test-utils/matchers.ts export const customMatchers = { toBeValidEmail(received: string) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const pass = emailRegex.test(received) return { pass, message: () => `Expected ${received} ${pass ? 'not ' : ''}to be a valid email` } } } // vitest.setup.ts import { expect } from 'vitest' import { customMatchers } from './test-utils/matchers' expect.extend(customMatchers) // Usage it('should validate email format', () => { expect('user@example.com').toBeValidEmail() })
Performance Testing
Benchmarking
import { bench, describe } from 'vitest' describe('Performance', () => { bench('array iteration - forEach', () => { const arr = Array.from({ length: 1000 }, (_, i) => i) arr.forEach(x => x * 2) }) bench('array iteration - for loop', () => { const arr = Array.from({ length: 1000 }, (_, i) => i) for (let i = 0; i < arr.length; i++) { arr[i] * 2 } }) })
Snapshot Testing
import { expect, it } from 'vitest' import { renderComponent } from './test-utils' it('should render user profile correctly', () => { const html = renderComponent('UserProfile', { user: { name: 'John Doe', email: 'john@example.com', avatar: '/avatars/john.jpg' } }) expect(html).toMatchSnapshot() })
Testing Tips
1. Test Behavior, Not Implementation
// BAD - Testing implementation details it('should set isLoggedIn to true', () => { authService.login('user@example.com', 'password') expect(authService.isLoggedIn).toBe(true) }) // GOOD - Testing behavior it('should allow access to protected resources after login', async () => { await authService.login('user@example.com', 'password') const result = await protectedResource.access() expect(result).toBeDefined() })
2. Use Descriptive Variable Names
// BAD it('test', () => { const x = new Service() const y = x.doSomething(123) expect(y).toBe(true) }) // GOOD it('should activate user account', () => { const userService = new UserService() const activationResult = userService.activateAccount('user-123') expect(activationResult.success).toBe(true) })
3. Keep Tests Simple
// BAD - Complex test logic it('should process orders', () => { const orders = [] for (let i = 0; i < 10; i++) { if (i % 2 === 0) { orders.push({ id: i, status: 'pending' }) } else { orders.push({ id: i, status: 'complete' }) } } // ... more complex logic }) // GOOD - Simple and clear it('should process pending orders', () => { const orders = [ createOrder({ status: 'pending' }), createOrder({ status: 'pending' }) ] const result = processOrders(orders) expect(result.processed).toBe(2) })