All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m0s
351 lines
9.2 KiB
Markdown
351 lines
9.2 KiB
Markdown
# ADR-045: Test Data Factories and Fixtures
|
|
|
|
**Date**: 2026-01-09
|
|
|
|
**Status**: Accepted
|
|
|
|
**Implemented**: 2026-01-09
|
|
|
|
## Context
|
|
|
|
The application has a complex domain model with many entity types:
|
|
|
|
- Users, Profiles, Addresses
|
|
- Flyers, FlyerItems, Stores
|
|
- ShoppingLists, ShoppingListItems
|
|
- Recipes, RecipeIngredients
|
|
- Gamification (points, badges, leaderboards)
|
|
- And more...
|
|
|
|
Testing requires realistic mock data that:
|
|
|
|
1. Satisfies TypeScript types.
|
|
2. Has valid relationships between entities.
|
|
3. Is customizable for specific test scenarios.
|
|
4. Is consistent across test suites.
|
|
5. Avoids boilerplate in test files.
|
|
|
|
## Decision
|
|
|
|
We will implement a **factory function pattern** for test data generation:
|
|
|
|
1. **Centralized Mock Factories**: All factories in a single, organized file.
|
|
2. **Sensible Defaults**: Each factory produces valid data with minimal input.
|
|
3. **Override Support**: Factories accept partial overrides for customization.
|
|
4. **Relationship Helpers**: Factories can generate related entities.
|
|
5. **Type Safety**: Factories return properly typed objects.
|
|
|
|
### Design Principles
|
|
|
|
- **Convention over Configuration**: Factories work with zero arguments.
|
|
- **Composability**: Factories can call other factories.
|
|
- **Immutability**: Each call returns a new object (no shared references).
|
|
- **Predictability**: Deterministic output when seeded.
|
|
|
|
## Implementation Details
|
|
|
|
### Factory File Structure
|
|
|
|
Located in `src/test/mockFactories.ts`:
|
|
|
|
```typescript
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import type {
|
|
User,
|
|
UserProfile,
|
|
Flyer,
|
|
FlyerItem,
|
|
ShoppingList,
|
|
// ... other types
|
|
} from '../types';
|
|
|
|
// ============================================
|
|
// PRIMITIVE HELPERS
|
|
// ============================================
|
|
let idCounter = 1;
|
|
export const nextId = () => idCounter++;
|
|
export const resetIdCounter = () => {
|
|
idCounter = 1;
|
|
};
|
|
|
|
export const randomEmail = () => `user-${uuidv4().slice(0, 8)}@test.com`;
|
|
export const randomDate = (daysAgo = 0) => {
|
|
const date = new Date();
|
|
date.setDate(date.getDate() - daysAgo);
|
|
return date.toISOString();
|
|
};
|
|
|
|
// ============================================
|
|
// USER FACTORIES
|
|
// ============================================
|
|
export const createMockUser = (overrides: Partial<User> = {}): User => ({
|
|
user_id: nextId(),
|
|
email: randomEmail(),
|
|
name: 'Test User',
|
|
role: 'user',
|
|
created_at: randomDate(30),
|
|
updated_at: randomDate(),
|
|
...overrides,
|
|
});
|
|
|
|
export const createMockUserProfile = (overrides: Partial<UserProfile> = {}): UserProfile => {
|
|
const user = createMockUser(overrides.user);
|
|
return {
|
|
user,
|
|
profile: createMockProfile({ user_id: user.user_id, ...overrides.profile }),
|
|
address: overrides.address ?? null,
|
|
preferences: overrides.preferences ?? null,
|
|
};
|
|
};
|
|
|
|
// ============================================
|
|
// FLYER FACTORIES
|
|
// ============================================
|
|
export const createMockFlyer = (overrides: Partial<Flyer> = {}): Flyer => ({
|
|
flyer_id: nextId(),
|
|
file_name: 'test-flyer.jpg',
|
|
image_url: 'https://example.com/flyer.jpg',
|
|
icon_url: 'https://example.com/flyer-icon.jpg',
|
|
checksum: uuidv4(),
|
|
store_name: 'Test Store',
|
|
store_address: '123 Test St',
|
|
valid_from: randomDate(7),
|
|
valid_to: randomDate(-7), // 7 days in future
|
|
item_count: 10,
|
|
status: 'approved',
|
|
uploaded_by: null,
|
|
created_at: randomDate(7),
|
|
updated_at: randomDate(),
|
|
...overrides,
|
|
});
|
|
|
|
export const createMockFlyerItem = (overrides: Partial<FlyerItem> = {}): FlyerItem => ({
|
|
flyer_item_id: nextId(),
|
|
flyer_id: overrides.flyer_id ?? nextId(),
|
|
item: 'Test Product',
|
|
price_display: '$2.99',
|
|
price_in_cents: 299,
|
|
quantity: 'each',
|
|
category_name: 'Groceries',
|
|
master_item_id: null,
|
|
view_count: 0,
|
|
click_count: 0,
|
|
created_at: randomDate(7),
|
|
updated_at: randomDate(),
|
|
...overrides,
|
|
});
|
|
|
|
// ============================================
|
|
// FLYER WITH ITEMS (COMPOSITE)
|
|
// ============================================
|
|
export const createMockFlyerWithItems = (
|
|
flyerOverrides: Partial<Flyer> = {},
|
|
itemCount = 5,
|
|
): { flyer: Flyer; items: FlyerItem[] } => {
|
|
const flyer = createMockFlyer(flyerOverrides);
|
|
const items = Array.from({ length: itemCount }, (_, i) =>
|
|
createMockFlyerItem({
|
|
flyer_id: flyer.flyer_id,
|
|
item: `Product ${i + 1}`,
|
|
price_in_cents: 100 + i * 50,
|
|
}),
|
|
);
|
|
flyer.item_count = items.length;
|
|
return { flyer, items };
|
|
};
|
|
|
|
// ============================================
|
|
// SHOPPING LIST FACTORIES
|
|
// ============================================
|
|
export const createMockShoppingList = (overrides: Partial<ShoppingList> = {}): ShoppingList => ({
|
|
shopping_list_id: nextId(),
|
|
user_id: overrides.user_id ?? nextId(),
|
|
name: 'Weekly Groceries',
|
|
is_active: true,
|
|
created_at: randomDate(14),
|
|
updated_at: randomDate(),
|
|
...overrides,
|
|
});
|
|
|
|
export const createMockShoppingListItem = (
|
|
overrides: Partial<ShoppingListItem> = {},
|
|
): ShoppingListItem => ({
|
|
shopping_list_item_id: nextId(),
|
|
shopping_list_id: overrides.shopping_list_id ?? nextId(),
|
|
item_name: 'Milk',
|
|
quantity: 1,
|
|
is_purchased: false,
|
|
created_at: randomDate(7),
|
|
updated_at: randomDate(),
|
|
...overrides,
|
|
});
|
|
```
|
|
|
|
### Usage in Tests
|
|
|
|
```typescript
|
|
import {
|
|
createMockUser,
|
|
createMockFlyer,
|
|
createMockFlyerWithItems,
|
|
resetIdCounter,
|
|
} from '../test/mockFactories';
|
|
|
|
describe('FlyerService', () => {
|
|
beforeEach(() => {
|
|
resetIdCounter(); // Consistent IDs across tests
|
|
});
|
|
|
|
it('should get flyer by ID', async () => {
|
|
const mockFlyer = createMockFlyer({ store_name: 'Walmart' });
|
|
|
|
mockDb.query.mockResolvedValue({ rows: [mockFlyer] });
|
|
|
|
const result = await flyerService.getFlyerById(mockFlyer.flyer_id);
|
|
|
|
expect(result.store_name).toBe('Walmart');
|
|
});
|
|
|
|
it('should return flyer with items', async () => {
|
|
const { flyer, items } = createMockFlyerWithItems(
|
|
{ store_name: 'Costco' },
|
|
10, // 10 items
|
|
);
|
|
|
|
mockDb.query.mockResolvedValueOnce({ rows: [flyer] }).mockResolvedValueOnce({ rows: items });
|
|
|
|
const result = await flyerService.getFlyerWithItems(flyer.flyer_id);
|
|
|
|
expect(result.flyer.store_name).toBe('Costco');
|
|
expect(result.items).toHaveLength(10);
|
|
});
|
|
});
|
|
```
|
|
|
|
### Bulk Data Generation
|
|
|
|
For integration tests or seeding:
|
|
|
|
```typescript
|
|
export const createMockDataset = () => {
|
|
const users = Array.from({ length: 10 }, () => createMockUser());
|
|
const flyers = Array.from({ length: 5 }, () => createMockFlyer());
|
|
const flyersWithItems = flyers.map((flyer) => ({
|
|
flyer,
|
|
items: Array.from({ length: Math.floor(Math.random() * 20) + 5 }, () =>
|
|
createMockFlyerItem({ flyer_id: flyer.flyer_id }),
|
|
),
|
|
}));
|
|
|
|
return { users, flyers, flyersWithItems };
|
|
};
|
|
```
|
|
|
|
### API Response Factories
|
|
|
|
For testing API handlers:
|
|
|
|
```typescript
|
|
export const createMockApiResponse = <T>(
|
|
data: T,
|
|
overrides: Partial<ApiResponse<T>> = {},
|
|
): ApiResponse<T> => ({
|
|
success: true,
|
|
data,
|
|
meta: {
|
|
timestamp: new Date().toISOString(),
|
|
requestId: uuidv4(),
|
|
...overrides.meta,
|
|
},
|
|
...overrides,
|
|
});
|
|
|
|
export const createMockPaginatedResponse = <T>(
|
|
items: T[],
|
|
page = 1,
|
|
pageSize = 20,
|
|
): PaginatedApiResponse<T> => ({
|
|
success: true,
|
|
data: items,
|
|
meta: {
|
|
timestamp: new Date().toISOString(),
|
|
requestId: uuidv4(),
|
|
},
|
|
pagination: {
|
|
page,
|
|
pageSize,
|
|
totalItems: items.length,
|
|
totalPages: Math.ceil(items.length / pageSize),
|
|
hasMore: false,
|
|
},
|
|
});
|
|
```
|
|
|
|
### Database Query Mock Helpers
|
|
|
|
```typescript
|
|
export const mockQueryResult = <T>(rows: T[]) => ({
|
|
rows,
|
|
rowCount: rows.length,
|
|
});
|
|
|
|
export const mockEmptyResult = () => ({
|
|
rows: [],
|
|
rowCount: 0,
|
|
});
|
|
|
|
export const mockInsertResult = <T>(inserted: T) => ({
|
|
rows: [inserted],
|
|
rowCount: 1,
|
|
});
|
|
```
|
|
|
|
## Test Cleanup Utilities
|
|
|
|
```typescript
|
|
// For integration tests with real database
|
|
export const cleanupTestData = async (pool: Pool) => {
|
|
await pool.query('DELETE FROM flyer_items WHERE flyer_id > 1000000');
|
|
await pool.query('DELETE FROM flyers WHERE flyer_id > 1000000');
|
|
await pool.query('DELETE FROM users WHERE user_id > 1000000');
|
|
};
|
|
|
|
// Mark test data with high IDs
|
|
export const createTestFlyer = (overrides: Partial<Flyer> = {}) =>
|
|
createMockFlyer({ flyer_id: 1000000 + nextId(), ...overrides });
|
|
```
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
|
|
- **Consistency**: All tests use the same factory patterns.
|
|
- **Type Safety**: Factories return correctly typed objects.
|
|
- **Reduced Boilerplate**: Tests focus on behavior, not data setup.
|
|
- **Maintainability**: Update factory once, all tests benefit.
|
|
- **Flexibility**: Easy to create edge case data.
|
|
|
|
### Negative
|
|
|
|
- **Single Large File**: Factory file can become large.
|
|
- **Learning Curve**: New developers must learn factory patterns.
|
|
- **Maintenance**: Factories must be updated when types change.
|
|
|
|
### Mitigation
|
|
|
|
- Split factories into multiple files if needed (by domain).
|
|
- Add JSDoc comments explaining each factory.
|
|
- Use TypeScript to catch type mismatches automatically.
|
|
|
|
## Key Files
|
|
|
|
- `src/test/mockFactories.ts` - All mock factory functions
|
|
- `src/test/testUtils.ts` - Test helper utilities
|
|
- `src/test/setup.ts` - Global test setup with factory reset
|
|
|
|
## Related ADRs
|
|
|
|
- [ADR-010](./0010-testing-strategy-and-standards.md) - Testing Strategy
|
|
- [ADR-040](./0040-testing-economics-and-priorities.md) - Testing Economics
|
|
- [ADR-027](./0027-standardized-naming-convention-for-ai-and-database-types.md) - Type Naming
|