ARC // OS — Test Documentation
Comprehensive guide for running tests, understanding test structure, mocking strategy, and CI process.
Table of Contents
- How to Run Tests
- Test Structure
- Mocking Strategy
- CI Process
- Test Database Setup
- Writing Tests
- Troubleshooting
How to Run Tests
Backend Tests
cd server
# Run all tests (one-time execution, fast, fail early)
pnpm test
# Run tests in watch mode (for development)
pnpm test:watch
# Run tests with coverage report
pnpm test:coverage
# Run specific test file
pnpm test WeekService
pnpm test auth.service.test.ts
# Run tests matching a pattern
pnpm test auth
Test Configuration:
- Test timeout: 10 seconds per test (fails fast)
- Default mode: One-time execution (not watch mode)
- Database: Uses
TEST_DATABASE_URLif set, otherwiseDATABASE_URL - Coverage threshold: 90% minimum (enforced)
Frontend Tests
cd web
# Run all tests (one-time execution, fast, fail early)
pnpm test
# Run tests in watch mode (for development)
pnpm test:watch
# Run tests with coverage report
pnpm test:coverage
# Run specific test file
pnpm test Button
pnpm test Button.test.tsx
# Run tests matching a pattern
pnpm test FormRow
Test Configuration:
- Test timeout: 10 seconds per test (fails fast)
- Default mode: One-time execution (not watch mode)
- Environment: jsdom (simulated browser)
- Coverage threshold: 90% minimum (enforced)
E2E Tests
# From project root
# Run all E2E tests
pnpm test:e2e
# Run with UI (interactive)
pnpm test:e2e:ui
# Run in headed mode (see browser)
pnpm test:e2e:headed
# Debug mode
pnpm test:e2e:debug
# Run specific test file
pnpm test:e2e auth.spec.ts
E2E Test Configuration:
- Framework: Playwright
- Browser: Chromium (default)
- Base URL: http://localhost:3000
- API URL: http://localhost:3001
Test Structure
Test Categories
1. Unit Tests (Service Layer)
Location: server/src/services/__tests__/
Purpose: Test individual services and business logic in isolation
Structure:
describe('ServiceName', () => {
beforeEach(async () => {
// Setup: clean database, create test data
await cleanupTestUsers();
});
afterEach(async () => {
// Teardown: clean up
await cleanupTestUsers();
});
describe('methodName', () => {
it('should do X when Y', async () => {
// Arrange: set up test data
const service = new ServiceName();
const input = { /* test data */ };
// Act: call method
const result = await service.methodName(input);
// Assert: verify result
expect(result).toBeDefined();
expect(result.property).toBe(expectedValue);
});
it('should handle error case Z', async () => {
// Test error handling
await expect(service.methodName(invalidInput)).rejects.toThrow();
});
});
});
Key Points:
- Test business logic in isolation
- Use real database (test database)
- Clean up after each test
- Test happy paths, error cases, and edge cases
2. Integration Tests (API Layer)
Location: server/src/routes/__tests__/
Purpose: Test API endpoints with real database
Structure:
describe('Route Name Integration Tests', () => {
let app: FastifyInstance;
let userId: string;
let authToken: string;
beforeEach(async () => {
await cleanupTestUsers();
app = await buildTestServer();
await app.ready();
// Create test user and get auth token
const user = await createTestUser('test@example.com');
userId = user.id;
const loginResponse = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { email: 'test@example.com', password: 'test123' },
});
const cookies = loginResponse.cookies;
const tokenCookie = cookies.find((c) => c.name === 'token');
authToken = tokenCookie?.value || '';
});
afterEach(async () => {
await app.close();
});
describe('POST /api/endpoint', () => {
it('should create resource for authenticated user', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/endpoint',
payload: { /* test data */ },
headers: { cookie: `token=${authToken}` },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.ok).toBe(true);
expect(body.data).toBeDefined();
});
});
});
Key Points:
- Use
buildTestServer()to create test server instance - Use
app.inject()to make HTTP requests - Test authentication and authorization
- Test request/response formats
- Test error responses
3. Component Tests (Frontend)
Location: web/src/components/**/__tests__/ or web/src/components/**/*.test.tsx
Purpose: Test React components in isolation
Structure:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Button } from '../Button';
describe('Button', () => {
it('should render with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeDefined();
});
it('should call onClick when clicked', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByText('Click me');
await userEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('should be disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
const button = screen.getByText('Click me');
expect(button).toBeDisabled();
});
});
Key Points:
- Use
@testing-library/reactfor rendering - Use
@testing-library/user-eventfor interactions - Test rendering, props, interactions, error states, loading states
- Mock API calls using
vi.mock()
4. Page Tests (Frontend)
Location: web/src/pages/**/*.test.tsx
Purpose: Test full page components and flows
Structure:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { api } from '../../utils/api';
import { PageName } from './PageName';
// Mock API
vi.mock('../../utils/api');
describe('PageName', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should load data on mount', async () => {
const mockData = { /* test data */ };
vi.mocked(api.get).mockResolvedValue({
ok: true,
data: mockData,
});
render(<PageName />);
await waitFor(() => {
expect(screen.getByText('Expected Content')).toBeDefined();
}, { timeout: 5000 });
});
it('should handle errors', async () => {
vi.mocked(api.get).mockResolvedValue({
ok: false,
error: { message: 'Error message' },
});
render(<PageName />);
await waitFor(() => {
expect(screen.getByText('Error message')).toBeDefined();
}, { timeout: 5000 });
});
});
Key Points:
- Mock API calls
- Test data loading, navigation, form submissions, error handling
- Use
waitForwith explicit timeouts (max 5 seconds)
5. E2E Tests
Location: e2e/**/*.spec.ts
Purpose: Test complete user journeys end-to-end
Structure:
import { test, expect } from '@playwright/test';
test.describe('Feature Name', () => {
test('should complete user journey', async ({ page }) => {
// Navigate to page
await page.goto('/login');
// Fill form
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'test123');
await page.click('button[type="submit"]');
// Wait for navigation
await page.waitForURL('/');
// Verify page content
await expect(page.locator('h1')).toContainText('Welcome');
});
});
Key Points:
- Use real browser (Playwright)
- Test full user journey
- Test error recovery
- Test data persistence
Mocking Strategy
What to Mock
✅ Mock External Dependencies
External APIs:
// Mock external API calls
vi.mock('../utils/api', () => ({
api: {
get: vi.fn(),
post: vi.fn(),
},
}));
Email Service:
// Email service is automatically mocked in tests
// Use getMockEmailService() to access sent emails
import { getMockEmailService } from '../services/email.service';
const mockService = getMockEmailService();
const emails = mockService?.getSentEmails();
JWT Library (for unit tests):
// Mock JWT for unit tests
vi.mock('jsonwebtoken', () => ({
default: {
sign: vi.fn(),
verify: vi.fn(),
},
}));
❌ Don't Mock What You're Testing
Database (Integration Tests):
- ✅ Use real database for integration tests
- ✅ Use test database (separate from development)
- ✅ Clean up after each test
Services (Unit Tests):
- ✅ Test services with real database
- ✅ Don't mock the service you're testing
- ✅ Mock only external dependencies
Example - Correct:
// ✅ CORRECT: Test service with real database
describe('TaskService', () => {
it('should create task', async () => {
const service = new TaskService();
const result = await service.createTask(userId, input);
// Test with real database
});
});
Example - Incorrect:
// ❌ INCORRECT: Don't mock the service you're testing
vi.mock('../services/task.service');
const service = new TaskService(); // This is mocked - can't test it!
Mocking Patterns
Pattern 1: Mock External API (Frontend)
// Mock API utility
vi.mock('../../utils/api', () => ({
api: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
// In test
vi.mocked(api.get).mockResolvedValue({
ok: true,
data: mockData,
});
Pattern 2: Mock Email Service (Backend)
// Email service is automatically mocked
// Access mock service to verify emails sent
import { getMockEmailService } from '../services/email.service';
const mockService = getMockEmailService();
if (mockService) {
const emails = mockService.getSentEmails();
expect(emails.length).toBeGreaterThan(0);
}
Pattern 3: Mock JWT (Unit Tests Only)
// Only for unit tests - integration tests use real JWT
vi.mock('jsonwebtoken', () => ({
default: {
sign: vi.fn().mockReturnValue('mock-token'),
verify: vi.fn().mockReturnValue({ id: 'user-123', email: 'test@example.com' }),
},
}));
Real Implementations
✅ Use Real Database
All integration tests use real database:
- Test database (separate from development)
- Automatic cleanup between tests
- Real Prisma queries
- Real foreign key constraints
Test Database Setup:
// Uses TEST_DATABASE_URL if set, otherwise DATABASE_URL
// Configured in vitest.config.ts
const prisma = getTestPrismaClient();
✅ Use Real Services
Service tests use real service implementations:
- Real business logic
- Real database queries
- Real validation
- Only external dependencies are mocked
CI Process
GitHub Actions Workflow
Location: .github/workflows/ci.yml
What Runs:
- Linting: ESLint for both server and web
- Type Checking: TypeScript compilation check
- Backend Tests: All server tests with coverage
- Frontend Tests: All web tests with coverage
- E2E Tests: All Playwright E2E tests
- Coverage Upload: Upload coverage to Codecov
When It Runs:
- On every push to any branch
- On every pull request
- On schedule (daily)
What Blocks Merge:
- ❌ Any test failures
- ❌ Coverage below 90%
- ❌ Linting errors
- ❌ Type errors
Pre-Commit Hooks
Location: .husky/pre-commit
What Runs:
- Linting: ESLint for server and web
- Type Checking: TypeScript compilation check
What Blocks Commit:
- ❌ Linting errors
- ❌ Type errors
Note: Full test suite runs in CI, not in pre-commit (for speed)
Coverage Enforcement
Backend Coverage:
- Threshold: 90% minimum
- Enforced in:
server/vitest.config.ts - Blocks: Merge if coverage drops below 90%
Frontend Coverage:
- Threshold: 90% minimum
- Enforced in:
web/vitest.config.ts - Blocks: Merge if coverage drops below 90%
Coverage Reports:
- Generated on every test run
- Uploaded to Codecov in CI
- Available locally:
coverage/index.html
Test Database Setup
Configuration
Default Behavior:
- Tests use the same database as development (
DATABASE_URL) - Test users are identified by email pattern:
test-*@example.com - Automatic cleanup between tests via
cleanupTestUsers()
Separate Test Database (Recommended):
# Set TEST_DATABASE_URL environment variable
export TEST_DATABASE_URL="postgresql://localhost:5432/arcos_test"
# Or in .env file
TEST_DATABASE_URL="postgresql://localhost:5432/arcos_test"
Creating Test Database:
createdb arcos_test
Test Data Cleanup
Automatic Cleanup:
cleanupTestUsers()deletes all test users and related data- Called in
beforeEachorafterEachhooks - Respects foreign key constraints (deletes in correct order)
Test User Patterns:
test-*@example.com- Standard test userstest@example.com- Auth service test userother-*@example.com- Test users for user isolation tests
Example:
beforeEach(async () => {
await cleanupTestUsers();
});
Test Helpers
Location: server/src/utils/test-helpers.ts
Available Functions:
cleanupTestUsers()- Clean up all test userscreateTestUser(email)- Create test usercreateTestWeek(userId, monday)- Create test weekcreateTestDay(weekId, date)- Create test daygetTestPrismaClient()- Get test Prisma clientnormalizeToMonday(date)- Normalize date to Monday
Example:
import { createTestUser, createTestWeek, createTestDay } from '../utils/test-helpers';
const user = await createTestUser('test@example.com');
const week = await createTestWeek(user.id, new Date());
const day = await createTestDay(week.id, new Date());
Writing Tests
Test Structure (Arrange-Act-Assert)
it('should do X when Y', async () => {
// Arrange: set up test data
const service = new ServiceName();
const input = { /* test data */ };
// Act: call method
const result = await service.methodName(input);
// Assert: verify result
expect(result).toBeDefined();
expect(result.property).toBe(expectedValue);
});
Test Naming
Good Names:
should create task when valid input providedshould return error when user not authenticatedshould update task status to done when completed
Bad Names:
test1workstest create
Test Independence
Every test must:
- ✅ Be independent (no shared state)
- ✅ Clean up after itself
- ✅ Have clear name
- ✅ Test one thing
- ✅ Be deterministic (same input = same output)
- ✅ Run fast (< 10 seconds)
Error Handling Tests
Always test:
- ✅ Happy path
- ✅ Error cases
- ✅ Edge cases
- ✅ Validation errors
- ✅ Authentication errors
- ✅ Authorization errors
Timeouts
Always use explicit timeouts:
// ✅ CORRECT
await waitFor(() => {
expect(screen.getByText('Expected Text')).toBeDefined();
}, { timeout: 5000 }); // 5 seconds max
// ❌ INCORRECT
await waitFor(() => {
expect(screen.getByText('Expected Text')).toBeDefined();
}); // No timeout - can hang forever
Troubleshooting
Tests Hang
Common Causes:
- Missing
--runflag (watch mode waiting for changes) - Long or missing timeouts
- Unresolved promises
- Database connections not closing
- Event listeners not removed
Solutions:
- Use
pnpm test(includes--runflag) - Always use explicit timeouts in
waitFor - Clean up timers and event listeners in
afterEach - Close database connections properly
Coverage Below 90%
Check:
- Run
pnpm test:coverageto see coverage report - Open
coverage/index.htmlfor detailed view - Identify uncovered lines
- Add tests for uncovered code
Common Issues:
- Error handling paths not tested
- Edge cases not tested
- Optional code paths not tested
Database Connection Errors
Check:
DATABASE_URLorTEST_DATABASE_URLis set- Database is running
- Database exists
- User has permissions
Solutions:
# Check database connection
psql $DATABASE_URL -c "SELECT 1"
# Create test database
createdb arcos_test
Test Failures in CI but Pass Locally
Common Causes:
- Environment variables not set in CI
- Database not set up in CI
- Race conditions (tests running in parallel)
- Time-dependent tests (dates, timestamps)
Solutions:
- Check CI environment variables
- Ensure database setup in CI workflow
- Run tests sequentially (configured in vitest.config.ts)
- Use fixed dates in tests
Per-Route Coverage Breakdown Report
The project includes a tool to generate detailed per-route coverage breakdown reports. This helps identify which route files have uncovered branches and need additional test coverage.
Generating the Report
-
Generate coverage data first:
cd server
pnpm test:coverage -
Generate the per-route breakdown report:
# Print to console
pnpm coverage:report
# Save to file
pnpm coverage:report --output docs/testing/route-coverage-report.md
# Show only routes with gaps
pnpm coverage:report --routes-only
Report Contents
The report includes:
- Summary statistics: Average branch/line coverage, total branches across all routes
- Routes with uncovered branches: Sorted by number of uncovered branches (most first)
- Detailed breakdown per route: Branch coverage %, line coverage %, uncovered branch details with line numbers
- Complete table of all routes: Full coverage statistics for all route files (if not using
--routes-only)
Using the Report
The report helps identify:
- Which route files need additional test coverage
- Specific branches that are not covered (with line numbers)
- Overall coverage trends across the codebase
This supports the goal of 100% coverage on critical paths (validation error paths, auth failure paths, ownership checks, not-found branches).
For more details, see server/scripts/README.md.
Additional Resources
- TESTING_REQUIREMENTS.md - Policy and coverage rules
- TEST_QUALITY_STANDARDS.md - How to write independent tests
- TEST_REVIEW_PROCESS.md - Reviewing tests in PRs
- E2E_TESTING.md - Playwright
- testing-infrastructure.md - CI and harness
Last Updated: 2026-01-13