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 reportTest File Location
Tests are colocated with the code they test, following the conventions of our core dependencies (Hono, Drizzle).
| Test Type | Location | Example |
|---|---|---|
| Unit tests | Next to source file | content.service.ts → content.service.test.ts |
| Integration tests | */integration-tests/ | auth.integration.test.ts |
| E2E tests | (future) | - |
Why Colocated Tests?
- Aligns with Hono — Hono uses colocated
.test.tsfiles - 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
| Type | Location | Database | Purpose |
|---|---|---|---|
| Unit | Colocated .test.ts / .test.tsx | Mocked | Fast, isolated logic tests |
| Integration | */integration-tests/*.test.ts | Real (Docker) | Auth flows, database ops |
| E2E | (future) | Real | Full 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 This | Mock At | Why |
|---|---|---|
| Controller | Service layer | Test HTTP handling in isolation |
| Service | Repository layer | Test business logic without database |
| Repository | Don't mock | Use 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 downTest 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 testsBest Practices
Do
- Clear mocks in
beforeEachto 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 enforcementCoverage 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