Testing Patterns
Effective testing patterns and best practices using @rageltd/bun-test-utils. This guide covers proven patterns for organizing tests, managing mocks, and ensuring reliable test suites.
Test File Structure
Organize your test files with a consistent structure for maximum maintainability:
// test/services/userService.test.ts
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'bun:test';
import {
setupTestCleanup,
createModuleMocker,
clearMockRegistry
} from '@rageltd/bun-test-utils';
// 1. Setup cleanup once per file
setupTestCleanup();
// 2. Create shared instances
const mockModules = createModuleMocker();
// 3. Group related tests
describe('UserService', () => {
// 4. Setup/teardown hooks
beforeEach(async () => {
// Setup mocks for each test
await mockModules.mock('@/api/http', () => ({
get: () => Promise.resolve({ data: { id: 1, name: 'Test User' } }),
post: () => Promise.resolve({ success: true }),
delete: () => Promise.resolve({ deleted: true })
}));
});
afterEach(() => {
clearMockRegistry();
});
afterAll(() => {
mockModules.restoreAll();
});
// 5. Test cases
describe('getUser', () => {
it('should return user data when user exists', async () => {
// Test implementation
});
it('should handle user not found', async () => {
// Test implementation
});
});
describe('createUser', () => {
it('should create new user successfully', async () => {
// Test implementation
});
it('should handle validation errors', async () => {
// Test implementation
});
});
});
Module Mocking Patterns
Layered Mocking Strategy
Mock at the appropriate abstraction level for your tests:
describe('Service Layer Testing', () => {
beforeEach(async () => {
// ✅ Mock external dependencies (HTTP, databases, etc.)
await mockModules.mock('@/lib/database', () => ({
db: {
user: {
findUnique: () => Promise.resolve({ id: 1, name: 'Test User' }),
create: () => Promise.resolve({ id: 2, name: 'New User' }),
update: () => Promise.resolve({ id: 1, name: 'Updated User' })
}
}
}));
// ✅ Mock external APIs
await mockModules.mock('@/lib/external-api', () => ({
fetchUserProfile: () => Promise.resolve({
avatar: 'https://example.com/avatar.jpg',
preferences: { theme: 'dark' }
})
}));
// ❌ Don't mock internal business logic you're testing
// Leave @/services/userService unmocked if that's what you're testing
});
it('should integrate external dependencies correctly', async () => {
// Test your service with mocked dependencies
const userService = new UserService();
const result = await userService.createUserWithProfile({
name: 'John Doe',
email: 'john@example.com'
});
expect(result.name).toBe('New User');
expect(result.avatar).toBe('https://example.com/avatar.jpg');
});
});
Conditional Mocking
Apply different mock behaviors based on test scenarios:
describe('Error Handling Scenarios', () => {
describe('when API is available', () => {
beforeEach(async () => {
await mockModules.mock('@/services/api', () => ({
apiClient: {
get: () => Promise.resolve({ data: 'success' }),
post: () => Promise.resolve({ created: true })
}
}));
});
it('should handle successful API calls', async () => {
// Test success scenarios
});
});
describe('when API is unavailable', () => {
beforeEach(async () => {
await mockModules.mock('@/services/api', () => ({
apiClient: {
get: () => Promise.reject(new Error('Network error')),
post: () => Promise.reject(new Error('Service unavailable'))
}
}));
});
it('should handle API failures gracefully', async () => {
// Test error scenarios
});
});
describe('when API returns partial data', () => {
beforeEach(async () => {
await mockModules.mock('@/services/api', () => ({
apiClient: {
get: () => Promise.resolve({ data: null }),
post: () => Promise.resolve({ created: false, error: 'Validation failed' })
}
}));
});
it('should handle partial failures', async () => {
// Test edge cases
});
});
});
Test Isolation Patterns
Cleanup Strategies
Choose the right cleanup strategy for your test scenarios:
// Pattern 1: Per-test cleanup (most common)
describe('User Management', () => {
const mockModules = createModuleMocker();
beforeEach(async () => {
// Fresh mocks for each test
await mockModules.mock('@/config', () => ({
config: { apiUrl: 'http://test.api' }
}));
});
afterEach(() => {
clearMockRegistry();
});
afterAll(() => {
mockModules.restoreAll();
});
});
// Pattern 2: Shared setup with selective cleanup
describe('Performance Tests', () => {
const mockModules = createModuleMocker();
beforeAll(async () => {
// Expensive setup once
await mockModules.mock('@/heavy-module', () => ({
heavyComputation: () => 'mocked-result'
}));
});
afterEach(() => {
// Only clear registry, keep module mocks
clearMockRegistry();
});
afterAll(() => {
mockModules.restoreAll();
});
});
// Pattern 3: Automatic cleanup wrapper
import { withMockCleanup } from '@rageltd/bun-test-utils';
withMockCleanup(() => {
describe('Auto-cleanup Tests', () => {
// Cleanup happens automatically
it('should handle automatic cleanup', () => {
// Test code here
});
});
});
State Management in Tests
Manage test state effectively to avoid test interdependence:
describe('Stateful Operations', () => {
// ✅ Create fresh state for each test
let testDatabase: TestDatabase;
let userService: UserService;
beforeEach(() => {
testDatabase = new TestDatabase();
userService = new UserService(testDatabase);
});
afterEach(() => {
// Clean up test state
testDatabase.clear();
});
it('should create user without affecting other tests', async () => {
await userService.createUser({ name: 'Test User' });
expect(testDatabase.users).toHaveLength(1);
});
it('should start with empty database', async () => {
// This test should not see users from previous test
expect(testDatabase.users).toHaveLength(0);
});
});
Testing Async Operations
Promise-based Testing
Handle asynchronous operations effectively in your tests:
describe('Async Operations', () => {
beforeEach(async () => {
await mockModules.mock('@/services/async-service', () => ({
asyncOperation: () => new Promise(resolve => {
// Simulate async work
setTimeout(() => resolve({ completed: true }), 10);
}),
failingOperation: () => Promise.reject(new Error('Operation failed')),
slowOperation: () => new Promise(resolve => {
setTimeout(() => resolve({ data: 'slow-result' }), 100);
})
}));
});
it('should handle successful async operations', async () => {
const service = new AsyncService();
const result = await service.performOperation();
expect(result.completed).toBe(true);
});
it('should handle failed async operations', async () => {
const service = new AsyncService();
await expect(service.performFailingOperation())
.rejects
.toThrow('Operation failed');
});
it('should handle multiple concurrent operations', async () => {
const service = new AsyncService();
const promises = [
service.performOperation(),
service.performOperation(),
service.performOperation()
];
const results = await Promise.all(promises);
expect(results).toHaveLength(3);
expect(results.every(r => r.completed)).toBe(true);
});
});
Error Handling Patterns
Test error conditions thoroughly:
describe('Error Scenarios', () => {
describe('Network Errors', () => {
beforeEach(async () => {
await mockModules.mock('@/lib/http', () => ({
fetch: () => Promise.reject(new Error('ECONNREFUSED'))
}));
});
it('should handle connection errors', async () => {
const service = new ApiService();
await expect(service.fetchData())
.rejects
.toThrow('ECONNREFUSED');
});
it('should provide fallback when network fails', async () => {
const service = new ApiService({ enableFallback: true });
const result = await service.fetchDataWithFallback();
expect(result.fromCache).toBe(true);
});
});
describe('Validation Errors', () => {
beforeEach(async () => {
await mockModules.mock('@/lib/validator', () => ({
validate: (data) => {
if (!data.email) {
throw new Error('Email is required');
}
return true;
}
}));
});
it('should handle validation failures', async () => {
const service = new UserService();
await expect(service.createUser({ name: 'John' }))
.rejects
.toThrow('Email is required');
});
});
});
Test Organization Patterns
Describe Block Structure
Organize tests with clear, hierarchical structure:
describe('UserService', () => {
describe('User Creation', () => {
describe('with valid data', () => {
it('should create user successfully', () => {});
it('should send welcome email', () => {});
it('should return user with ID', () => {});
});
describe('with invalid data', () => {
it('should reject empty email', () => {});
it('should reject duplicate email', () => {});
it('should reject invalid email format', () => {});
});
describe('with external service failures', () => {
it('should handle email service failure', () => {});
it('should handle database connection failure', () => {});
});
});
describe('User Updates', () => {
describe('with valid changes', () => {
it('should update user profile', () => {});
it('should preserve unchanged fields', () => {});
});
describe('with invalid changes', () => {
it('should reject invalid email updates', () => {});
it('should prevent unauthorized updates', () => {});
});
});
});
Test Naming Conventions
Use clear, descriptive test names that explain behavior:
describe('UserService', () => {
// ✅ Good: Describes what should happen
it('should create user with valid email and password', () => {});
it('should throw ValidationError when email is missing', () => {});
it('should send welcome email after successful registration', () => {});
it('should return user without password in response', () => {});
// ❌ Avoid: Vague or implementation-focused
it('should work', () => {});
it('tests createUser method', () => {});
it('calls database.save', () => {});
it('returns object', () => {});
});
Performance Testing Patterns
Efficient Mocking
Optimize mock setup for better test performance:
describe('Performance Optimized Tests', () => {
// ✅ Reuse expensive mock setups
const mockModules = createModuleMocker();
beforeAll(async () => {
// Expensive setup once
await mockModules.mock('@/heavy-dependency', () => ({
expensiveOperation: () => 'cached-result',
computeHash: () => 'mock-hash-12345'
}));
});
afterAll(() => {
mockModules.restoreAll();
});
// Individual tests run fast with shared setup
it('should process data quickly', () => {
// Fast test using cached mocks
});
it('should handle multiple requests efficiently', () => {
// Another fast test
});
});
Best Practices Summary
✅ Essential Patterns
- Always call
setupTest
createModuleMocker() for consistent module mockingrestoreAll() in afterAll hooksafterEach for test isolationdescribe blocks⚠️ Common Pitfalls
- Forgetting to restore module mocks after tests
- Sharing state between tests without proper cleanup
- Mocking too much (test becomes meaningless)
- Mocking too little (test becomes flaky)
- Not testing error conditions
- Writing tests that depend on execution order
- Using vague test names that don't explain behavior
Pattern Checklist
Use this checklist when setting up new test files:
Test File Setup Checklist
- □ Import
setupTestCleanupand call it at the top - □ Create
mockModulesinstance withcreateModuleMocker() - □ Set up
beforeEachfor test-specific mocks - □ Set up
afterEachwithclearMockRegistry() - □ Set up
afterAllwithmockModules.restoreAll() - □ Use nested
describeblocks for organization - □ Write descriptive test names
- □ Include both success and error test cases
- □ Verify test isolation (tests don't affect each other)