Files
flyer-crawler.projectium.com/docs/adr/0045-test-data-factories-and-fixtures.md
Torben Sorensen e14c19c112
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m0s
linting docs + some fixes go claude and gemini
2026-01-09 22:38:57 -08:00

9.2 KiB

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:

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

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:

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:

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

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

// 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