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
Cleanup() at the top of test files
  • Use createModuleMocker() for consistent module mocking
  • Call restoreAll() in afterAll hooks
  • Clear mock registry in afterEach for test isolation
  • Use descriptive test names that explain expected behavior
  • Group related tests with nested describe blocks
  • Test both success and error scenarios
  • Mock at the appropriate abstraction level
  • ⚠️ 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 setupTestCleanup and call it at the top
    • □ Create mockModules instance with createModuleMocker()
    • □ Set up beforeEach for test-specific mocks
    • □ Set up afterEach with clearMockRegistry()
    • □ Set up afterAll with mockModules.restoreAll()
    • □ Use nested describe blocks for organization
    • □ Write descriptive test names
    • □ Include both success and error test cases
    • □ Verify test isolation (tests don't affect each other)