Mock System Architecture
Date: November 7, 2025 Status: Design Phase Approach: Utility-Based Mock Extraction (Not Wholesale Migration)
Philosophy
The centralized mock system provides reusable utilities and presets to reduce duplication while maintaining the flexibility and clarity of inline mocking. Tests can continue using inline mocks while leveraging shared factories for common patterns.
Core Principles
- Utilities, Not Requirements - Centralized mocks are helpers, not mandatory
- Maintain Inline Flexibility - Tests can override/customize any behavior
- Reduce Duplication - Extract repeated patterns into factories
- Improve Discoverability - Easy to find and use common mocks
- Type Safety First - Fully typed mock interfaces
- Clear Documentation - Every pattern documented with examples
Directory Structure
tests/
├── mocks/
│ ├── index.ts # Central exports barrel file
│ ├── README.md # Quick start guide
│ │
│ ├── db/ # Database mocks
│ │ ├── index.ts # DB exports
│ │ ├── pool-client.mock.ts # PoolClient factory
│ │ ├── query-result.fixtures.ts # Query result test data
│ │ ├── transaction.mock.ts # Transaction utilities
│ │ └── presets.ts # Common DB scenarios
│ │
│ ├── auth/ # Auth system mocks
│ │ ├── index.ts # Auth exports
│ │ ├── secret-store.mock.ts # SecretStore factory
│ │ ├── auth-service.mock.ts # AuthService spy helpers
│ │ ├── jwt-claim.fixtures.ts # JWT test data
│ │ ├── key-material.fixtures.ts # Encryption key fixtures
│ │ └── presets.ts # Common auth scenarios
│ │
│ ├── services/ # Service layer mocks
│ │ ├── index.ts
│ │ └── [future service mocks]
│ │
│ └── models/ # Model fixtures
│ ├── index.ts
│ └── [future model fixtures]
│
└── integration/
└── setup/ # Existing integration mocks (unchanged)
├── factories/
├── mocks.ts
└── utilities/
Naming Conventions
Files
- Factories:
*.mock.ts- Mock factory functions - Fixtures:
*.fixtures.ts- Test data constants - Presets:
presets.ts- Pre-configured scenarios - Exports:
index.ts- Barrel files for clean imports
Functions
-
Factories:
create[Type]Mock()- Returns mock instancecreatePoolClientMock() createSecretStoreMock() createAuthServiceMock() -
Presets:
[Type]Presets.[scenario]- Pre-configured mocksPoolClientPresets.withSuccessfulQuery() AuthServicePresets.withValidJWT() SecretStorePresets.withMasterStore() -
Fixtures:
[Type]Fixtures.[variant]- Test dataJWTClaimFixtures.forRegularUser QueryResultFixtures.emptyResult KeyMaterialFixtures.validRSAKey
Design Patterns
Pattern 1: Factory Functions
Purpose: Create mock instances with sensible defaults and override capability
Structure:
/**
* Creates a mock [Interface] with default behavior
* @param overrides - Partial overrides for specific methods/properties
* @returns Mock instance implementing [Interface]
*/
export function create[Type]Mock(
overrides?: Partial<Interface>
): Interface {
return {
// Default implementations
method1: vi.fn().mockResolvedValue(defaultValue),
method2: vi.fn().mockResolvedValue(defaultValue),
...overrides // Allow customization
};
}
Key Features:
- ✅ Sensible defaults for all methods
- ✅ Partial override support via spread operator
- ✅ Full TypeScript type safety
- ✅ Explicit mock behavior (vi.fn() visible)
Example:
// tests/mocks/db/pool-client.mock.ts
import { vi } from 'vitest';
import type { PoolClient } from 'pg';
export function createPoolClientMock(
overrides?: Partial<PoolClient>
): PoolClient {
return {
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
release: vi.fn().mockResolvedValue(undefined),
...overrides
} as PoolClient;
}
Pattern 2: Preset Configurations
Purpose: Provide pre-configured mocks for common test scenarios
Structure:
export const [Type]Presets = {
/**
* Description of scenario
* @param params - Optional parameters for customization
* @returns Pre-configured mock
*/
scenarioName: (params?: ScenarioParams) => create[Type]Mock({
// Specific overrides for this scenario
}),
anotherScenario: () => create[Type]Mock({
// Different overrides
})
};
Key Features:
- ✅ Named scenarios for common use cases
- ✅ Built on top of factory functions
- ✅ Optional parameters for variation
- ✅ Self-documenting test intent
Example:
// tests/mocks/db/presets.ts
import { createPoolClientMock } from './pool-client.mock';
import { QueryResultFixtures } from './query-result.fixtures';
export const PoolClientPresets = {
/** Mock client that returns successful query results */
withSuccessfulQuery: (rows: any[] = []) =>
createPoolClientMock({
query: vi.fn().mockResolvedValue({
rows,
rowCount: rows.length
})
}),
/** Mock client that simulates query failure */
withQueryFailure: (error = new Error('Query failed')) =>
createPoolClientMock({
query: vi.fn().mockRejectedValue(error)
}),
/** Mock client that simulates connection error */
withConnectionError: () =>
createPoolClientMock({
query: vi.fn().mockRejectedValue(new Error('Connection lost')),
release: vi.fn().mockRejectedValue(new Error('Connection lost'))
})
};
Pattern 3: Fixture Constants
Purpose: Centralize test data for reusability and consistency
Structure:
/**
* Test fixtures for [DataType]
*/
export const [DataType]Fixtures = {
/** Description of variant */
variantName: {
// Test data
} as DataType,
/** Another variant */
anotherVariant: {
// Different data
} as DataType,
/** Factory function for dynamic fixtures */
create: (overrides?: Partial<DataType>): DataType => ({
// Defaults
...overrides
})
};
Key Features:
- ✅ Typed fixture data
- ✅ Multiple variants for different scenarios
- ✅ Factory functions for dynamic generation
- ✅ Composable fixtures
Example:
// tests/mocks/auth/jwt-claim.fixtures.ts
import type { JWTClaims } from '@/lib/auth/types';
export const JWTClaimFixtures = {
/** Standard claims for a regular user */
forRegularUser: {
sub: 'user-123',
email: 'user@example.com',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
role: 'user'
} as JWTClaims,
/** Claims for an admin user */
forAdmin: {
sub: 'admin-456',
email: 'admin@example.com',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
role: 'admin',
permissions: ['read', 'write', 'admin']
} as JWTClaims,
/** Expired token claims */
expired: {
sub: 'user-789',
email: 'expired@example.com',
iat: Math.floor(Date.now() / 1000) - 7200,
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
role: 'user'
} as JWTClaims,
/** Factory for creating custom claims */
create: (overrides?: Partial<JWTClaims>): JWTClaims => ({
sub: 'custom-user',
email: 'custom@example.com',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
role: 'user',
...overrides
})
};
Pattern 4: Spy Helpers
Purpose: Simplify common spy setups for complex objects
Structure:
/**
* Helper utilities for spying on [Service]
*/
export const [Service]SpyHelpers = {
/**
* Sets up spies for scenario
*/
setupScenarioSpies: (service: Service, scenario: Scenario) => {
// Multiple vi.spyOn() calls
// Return cleanup function or spy references
},
/** Common spy configurations */
mockMethod: (service: Service, behavior: Behavior) => {
return vi.spyOn(service, 'method').mockImplementation(behavior);
}
};
Example:
// tests/mocks/auth/auth-service.mock.ts
import { vi } from 'vitest';
import type { AuthService } from '@/lib/auth/service';
import { JWTClaimFixtures } from './jwt-claim.fixtures';
export const AuthServiceSpyHelpers = {
/** Mock successful JWT validation */
mockValidJWT: (service: AuthService, claims = JWTClaimFixtures.forRegularUser) => {
vi.spyOn(service.validation(), 'resolveKeyId').mockResolvedValue({
key_id: 'jwt-key-v1',
error_message: null,
header: { alg: 'RS256' },
payload: claims
});
vi.spyOn(service.validation(), 'verifyJWT').mockResolvedValue({
valid: true,
error_message: null,
header: { alg: 'RS256' },
claims
});
return { claims };
},
/** Mock expired JWT */
mockExpiredJWT: (service: AuthService) => {
vi.spyOn(service.validation(), 'verifyJWT').mockResolvedValue({
valid: false,
error_message: 'Token expired',
header: { alg: 'RS256' },
claims: null
});
},
/** Mock invalid JWT signature */
mockInvalidSignature: (service: AuthService) => {
vi.spyOn(service.validation(), 'verifyJWT').mockResolvedValue({
valid: false,
error_message: 'Invalid signature',
header: { alg: 'RS256' },
claims: null
});
}
};
Export Strategy
Barrel Files (index.ts)
Each category has an index.ts that re-exports its contents:
// tests/mocks/db/index.ts
export * from './pool-client.mock';
export * from './query-result.fixtures';
export * from './transaction.mock';
export * from './presets';
Root Barrel File
The main tests/mocks/index.ts provides centralized access:
// tests/mocks/index.ts
// Database mocks
export * from './db';
// Auth mocks
export * from './auth';
// Service mocks (future)
export * from './services';
// Model fixtures (future)
export * from './models';
Import Patterns
Option 1: Import from root (recommended)
import {
createPoolClientMock,
PoolClientPresets,
JWTClaimFixtures,
AuthServiceSpyHelpers
} from '@tests/mocks';
Option 2: Import from category
import { createPoolClientMock, PoolClientPresets } from '@tests/mocks/db';
import { JWTClaimFixtures } from '@tests/mocks/auth';
Option 3: Import specific file (rare)
import { createPoolClientMock } from '@tests/mocks/db/pool-client.mock';
TypeScript Configuration
Path Aliases
Ensure tsconfig.json includes the mock path:
{
"compilerOptions": {
"paths": {
"@tests/mocks": ["./tests/mocks"],
"@tests/mocks/*": ["./tests/mocks/*"]
}
}
}
Type Safety
All mock factories return properly typed interfaces:
// ✅ Correct: Fully typed
export function createPoolClientMock(
overrides?: Partial<PoolClient>
): PoolClient {
// ...
}
// ❌ Incorrect: Loose typing
export function createPoolClientMock(overrides?: any): any {
// ...
}
Usage Examples
Example 1: Basic Factory Usage
import { describe, it, expect, beforeEach } from 'vitest';
import { createPoolClientMock } from '@tests/mocks';
import { AuthAPI } from '@/lib/auth/services/api/AuthAPI';
describe('AuthAPI', () => {
let client: PoolClient;
let api: AuthAPI;
beforeEach(() => {
// Simple: Use factory with defaults
client = createPoolClientMock();
api = new AuthAPI(client);
});
it('should initialize successfully', async () => {
await expect(api.initialize()).resolves.not.toThrow();
});
});
Example 2: Factory with Overrides
import { createPoolClientMock } from '@tests/mocks';
describe('Error handling', () => {
it('should handle query failures', async () => {
// Override specific method behavior
const client = createPoolClientMock({
query: vi.fn().mockRejectedValue(new Error('Connection lost'))
});
await expect(someOperation(client)).rejects.toThrow('Connection lost');
});
});
Example 3: Using Presets
import { PoolClientPresets } from '@tests/mocks';
describe('Query scenarios', () => {
it('should handle successful queries', async () => {
// Use preset for common scenario
const client = PoolClientPresets.withSuccessfulQuery([
{ id: 1, name: 'Test' }
]);
const result = await someQuery(client);
expect(result).toHaveLength(1);
});
it('should handle query failures', async () => {
const client = PoolClientPresets.withQueryFailure();
await expect(someQuery(client)).rejects.toThrow('Query failed');
});
});
Example 4: Using Fixtures
import { JWTClaimFixtures, AuthServiceSpyHelpers } from '@tests/mocks';
describe('JWT validation', () => {
it('should validate regular user token', async () => {
const service = createAuthService();
// Use fixture for test data
AuthServiceSpyHelpers.mockValidJWT(
service,
JWTClaimFixtures.forRegularUser
);
const result = await api.verifyAndValidateJWT({ jwt: 'token' });
expect(result.valid).toBe(true);
expect(result.claims).toEqual(JWTClaimFixtures.forRegularUser);
});
it('should reject expired tokens', async () => {
const service = createAuthService();
AuthServiceSpyHelpers.mockExpiredJWT(service);
const result = await api.verifyAndValidateJWT({ jwt: 'expired-token' });
expect(result.valid).toBe(false);
});
});
Example 5: Complex Scenario Composition
import {
createPoolClientMock,
createSecretStoreMock,
JWTClaimFixtures,
KeyMaterialFixtures
} from '@tests/mocks';
describe('Complex auth flow', () => {
it('should complete full JWT lifecycle', async () => {
// Compose multiple mocks for complex scenarios
const client = createPoolClientMock({
query: vi.fn()
.mockResolvedValueOnce({ rows: [KeyMaterialFixtures.validRSAKey] })
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
});
const store = createSecretStoreMock({
getSecret: vi.fn().mockResolvedValue({
id: 'jwt-key-v1',
value: KeyMaterialFixtures.validRSAKey.value,
metadata: { algorithm: 'RS256' }
})
});
// Test complex scenario...
});
});
Migration Strategy
Gradual Adoption
- New Tests: Use centralized mocks from day one
- Existing Tests: Migrate opportunistically (when touching test files)
- Critical Paths: Prioritize high-traffic test files
- No Force Migration: Allow both patterns to coexist
Migration Process
Before:
const createMockClient = (): PoolClient => {
return {
query: vi.fn(),
release: vi.fn(),
} as unknown as PoolClient;
};
let client: PoolClient;
beforeEach(() => {
client = createMockClient();
});
After:
import { createPoolClientMock } from '@tests/mocks';
let client: PoolClient;
beforeEach(() => {
client = createPoolClientMock();
});
Steps:
- Import centralized mock
- Replace inline factory call
- Remove inline factory definition
- Verify tests still pass
- (Optional) Refactor to use presets if applicable
Guidelines for Creating New Mocks
When to Create a Factory
Create a factory when:
- ✅ Pattern appears in 2+ test files
- ✅ Mock is complex (5+ methods/properties)
- ✅ Interface changes frequently
- ✅ Multiple test scenarios exist
Don't create a factory when:
- ❌ Used in only one test file
- ❌ Mock is trivial (1-2 properties)
- ❌ Highly test-specific behavior
When to Create a Preset
Create a preset when:
- ✅ Scenario appears in 3+ tests
- ✅ Configuration is non-trivial
- ✅ Scenario has clear business meaning
- ✅ Reduces test setup complexity
When to Create a Fixture
Create a fixture when:
- ✅ Data structure appears in 2+ tests
- ✅ Data represents domain concept
- ✅ Multiple variants needed
- ✅ Data is complex or verbose
Quality Standards
Code Quality
- ✅ Full TypeScript type safety
- ✅ JSDoc comments on all exports
- ✅ Consistent naming conventions
- ✅ No
anytypes - ✅ Proper error handling
Documentation
- ✅ Clear purpose statement
- ✅ Usage examples in comments
- ✅ Parameter descriptions
- ✅ Return value descriptions
Testing
- ✅ Mocks themselves don't need tests
- ✅ Example usage in documentation
- ✅ Verify in consuming tests
Maintenance
Updating Mocks
When interfaces change:
- Update the factory function
- Update affected presets
- Update fixtures if needed
- Run full test suite
- Update documentation
Deprecating Mocks
When mocks become obsolete:
- Mark as
@deprecatedin JSDoc - Provide migration path
- Keep for 1-2 release cycles
- Remove after migration period
Adding New Mocks
- Follow established patterns
- Add to appropriate category
- Export from index files
- Document in README
- Provide usage examples
Success Metrics
Quantitative
- Reduce mock code duplication by 60%+
- Decrease test setup time by 30%+
- Increase test file consistency to 90%+
Qualitative
- Easier to write new tests
- Clearer test intent
- Simpler maintenance
- Better discoverability
Next Phase
After architecture approval, proceed to Phase 3: Create Infrastructure
This involves:
- Create directory structure
- Implement base mock utilities
- Set up TypeScript configuration
- Create template files
- Document patterns