Skip to main content

ARC // OS — Test Documentation

Comprehensive guide for running tests, understanding test structure, mocking strategy, and CI process.

Table of Contents​

  1. How to Run Tests
  2. Test Structure
  3. Mocking Strategy
  4. CI Process
  5. Test Database Setup
  6. Writing Tests
  7. 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_URL if set, otherwise DATABASE_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:


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/react for rendering
  • Use @testing-library/user-event for 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 waitFor with 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:

  1. Linting: ESLint for both server and web
  2. Type Checking: TypeScript compilation check
  3. Backend Tests: All server tests with coverage
  4. Frontend Tests: All web tests with coverage
  5. E2E Tests: All Playwright E2E tests
  6. 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:

  1. Linting: ESLint for server and web
  2. 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 beforeEach or afterEach hooks
  • Respects foreign key constraints (deletes in correct order)

Test User Patterns:

  • test-*@example.com - Standard test users
  • test@example.com - Auth service test user
  • other-*@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 users
  • createTestUser(email) - Create test user
  • createTestWeek(userId, monday) - Create test week
  • createTestDay(weekId, date) - Create test day
  • getTestPrismaClient() - Get test Prisma client
  • normalizeToMonday(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 provided
  • should return error when user not authenticated
  • should update task status to done when completed

Bad Names:

  • test1
  • works
  • test 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:

  1. Missing --run flag (watch mode waiting for changes)
  2. Long or missing timeouts
  3. Unresolved promises
  4. Database connections not closing
  5. Event listeners not removed

Solutions:

  • Use pnpm test (includes --run flag)
  • 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:coverage to see coverage report
  • Open coverage/index.html for 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_URL or TEST_DATABASE_URL is 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:

  1. Environment variables not set in CI
  2. Database not set up in CI
  3. Race conditions (tests running in parallel)
  4. 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​

  1. Generate coverage data first:

    cd server
    pnpm test:coverage
  2. 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​


Last Updated: 2026-01-13