Skip to main content

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) })