Toast
ContributorPatterns

Testing Patterns

Testing Patterns

Testing conventions and patterns for the Toast codebase.

Quick Reference

pnpm test              # Run unit tests
pnpm test:integration  # Run integration tests (requires Docker)
pnpm test:coverage     # Run with coverage report

Test File Location

Tests are colocated with the code they test, following the conventions of our core dependencies (Hono, Drizzle).

Test TypeLocationExample
Unit testsNext to source filecontent.service.tscontent.service.test.ts
Integration tests*/integration-tests/auth.integration.test.ts
E2E tests(future)-

Why Colocated Tests?

  • Aligns with Hono — Hono uses colocated .test.ts files
  • Aligns with Drizzle — Drizzle uses integration-tests/ folder
  • Easier maintenance — Tests move with their source files
  • Visible coverage — Easy to see if a file has tests

Test Types

TypeLocationDatabasePurpose
UnitColocated .test.ts / .test.tsxMockedFast, isolated logic tests
Integration*/integration-tests/*.test.tsReal (Docker)Auth flows, database ops
E2E(future)RealFull user flows

Unit Tests

Mocking Pattern

Use vi.hoisted() to ensure mocks are available before module loading:

import { beforeEach, describe, expect, it, vi } from 'vitest';

// 1. Create mocks with vi.hoisted (runs before imports)
const { mockFindAll, mockCreate } = vi.hoisted(() => ({
  mockFindAll: vi.fn(),
  mockCreate: vi.fn(),
}));

// 2. Mock the module
vi.mock('../repositories/content.repository.js', () => ({
  findAll: mockFindAll,
  create: mockCreate,
}));

// 3. Import after mocking
import { app } from '../app.js';

describe('GET /api/content', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns list of content', async () => {
    mockFindAll.mockResolvedValue([
      {
        id: '123',
        siteId: '456',
        title: 'Test',
        createdAt: new Date('2024-01-01'),
        updatedAt: new Date('2024-01-01'),
      },
    ]);

    const res = await app.request('/api/content');

    expect(res.status).toBe(200);
    expect(mockFindAll).toHaveBeenCalledOnce();
  });
});

Mock Boundaries

Mock at the appropriate layer boundary:

Testing ThisMock AtWhy
ControllerService layerTest HTTP handling in isolation
ServiceRepository layerTest business logic without database
RepositoryDon't mockUse integration tests with real DB

Snapshot Assertions

Use snapshots for API response structure:

it('returns expected response shape', async () => {
  mockFindAll.mockResolvedValue([mockData]);

  const res = await app.request('/api/content');
  const body = await res.json();

  expect(body).toMatchSnapshot();
});

Snapshots are stored in __snapshots__/ directories. Review changes carefully when updating.

Integration Tests

Integration tests run against a real PostgreSQL database via Docker.

Setup

// packages/db/integration-tests/my-feature.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import {
  setupIntegrationTests,
  teardownIntegrationTests,
  cleanTables,
} from './integration-utils.js';

describe('My Feature Integration', () => {
  beforeAll(async () => {
    await setupIntegrationTests();
  });

  afterAll(async () => {
    await teardownIntegrationTests();
  });

  beforeEach(async () => {
    await cleanTables(['content', 'sites']);
  });

  it('creates and retrieves data', async () => {
    // Test with real database
  });
});

Running Integration Tests

# Start test database
docker compose -f docker-compose.test.yml up -d

# Run integration tests
pnpm test:integration

# Stop test database
docker compose -f docker-compose.test.yml down

Test Organization

Tests are colocated with their source files:

apps/api/
├── src/
│   ├── routes/
│   │   ├── content/
│   │   │   ├── __snapshots__/
│   │   │   │   └── content.test.ts.snap
│   │   │   ├── content.controller.ts
│   │   │   ├── content.test.ts          # Route tests
│   │   │   ├── index.ts
│   │   │   └── schemas.ts
│   │   └── health/
│   │       ├── health.test.ts           # Health route tests
│   │       └── index.ts
│   ├── middleware/
│   │   ├── error-handler.ts
│   │   ├── error-handler.test.ts        # Middleware tests
│   │   ├── request-logger.ts
│   │   ├── request-logger.test.ts
│   │   ├── session.ts
│   │   └── session.test.ts
│   ├── services/
│   │   ├── content.service.ts
│   │   └── content.service.test.ts      # Service tests
│   └── repositories/
│       ├── content.repository.ts
│       └── content.repository.test.ts   # Repository tests
└── integration-tests/                   # At app root, not in src
    ├── setup.ts                         # Test setup
    └── auth.integration.test.ts         # Auth integration tests

packages/db/
├── src/
│   └── ...
└── integration-tests/                   # At package root, not in src
    ├── integration-utils.ts             # Shared setup/teardown
    └── content.integration.test.ts      # DB integration tests

Best Practices

Do

  • Clear mocks in beforeEach to prevent test pollution
  • Use descriptive test names that explain the expected behavior
  • Test error cases, not just happy paths
  • Keep tests focused on one behavior each

Don't

  • Don't test implementation details (internal function calls)
  • Don't share state between tests
  • Don't mock what you're testing
  • Don't skip flaky tests - fix them

Coverage

This codebase enforces 100% test coverage on all code. The pnpm check command runs coverage checks locally, matching CI behavior.

pnpm test:coverage   # Run tests with coverage enforcement

Coverage Thresholds

All packages enforce 100% coverage for lines, statements, branches, and functions by default (configured in packages/config/vitest/base.ts).

If a file cannot reach 100% due to framework constraints (e.g., TanStack Router's getParentRoute callback), add an exclusion in the package's vitest.config.ts with a comment explaining why.

What to Cover

  • All business logic in services
  • Error handling paths
  • Edge cases in data transformation
  • API request/response handling

On this page