Testing Guide¶
Metricis uses multiple testing frameworks across the stack to ensure code quality and reliability.
Testing Stack¶
| Component | Framework | Purpose |
|---|---|---|
| Server | pytest | Unit and integration tests for FastAPI backend |
| Portal | Vitest + React Testing Library | Component and integration tests |
| Patient Portal | Vitest + React Testing Library | Component and integration tests |
| E2E | Playwright | End-to-end browser automation tests |
Verification Authority¶
Local verification is the source of truth for ongoing development. Use the relevant local lint, typecheck, test, build, native, and docs commands to accept or reject a change. Remote GitHub Actions remains useful corroborating signal, but it is not the development authority again until the project intentionally restores remote CI as the deployment gate.
Record the local command results in development handoffs. If a remote CI failure appears but the relevant local verification is green, treat the CI failure as an infrastructure or parity issue to investigate separately rather than as a blocker for ordinary development.
Server Tests (pytest)¶
Running Server Tests¶
cd server
source .venv/bin/activate
# Run all tests
pytest tests/ -v
# Run specific test file
pytest tests/test_auth.py -v
# Run with coverage
pytest tests/ -v --cov=app
# Run with coverage report
pytest tests/ -v --cov=app --cov-report=html
Test Structure¶
server/tests/
├── conftest.py # Pytest fixtures and configuration
├── test_auth.py # Authentication tests
├── test_session.py # Session management tests
├── test_submit.py # Data submission tests
├── test_redcap.py # REDCap integration tests
└── test_participants.py # Participant management tests
Writing Server Tests¶
# tests/test_example.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health_check():
"""Test the health check endpoint."""
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
def test_create_participant(auth_headers):
"""Test creating a participant with authentication."""
response = client.post(
"/api/participants",
json={"email": "test@example.com", "study_id": 1},
headers=auth_headers
)
assert response.status_code == 201
assert "participant_id" in response.json()
Common Fixtures¶
# conftest.py
import pytest
from app.db.session import get_db
@pytest.fixture
def db_session():
"""Provide a database session for testing."""
session = next(get_db())
yield session
session.rollback()
@pytest.fixture
def auth_headers(test_user):
"""Provide authentication headers."""
return {"Authorization": f"Bearer {test_user.token}"}
Portal Tests (Vitest)¶
Running Portal Tests¶
cd portal
# Run tests in watch mode
npm run test
# Run tests once
npm run test:run
# Run with coverage
npm run test:coverage
Test Infrastructure¶
Portal uses: - Vitest - Test runner - React Testing Library - Component testing utilities - MSW (Mock Service Worker) - API mocking
Key files:
- vitest.config.ts - Test configuration
- src/test/setup.ts - Global test setup
- src/test/test-utils.tsx - Custom render with providers
- src/test/mocks/handlers.ts - MSW API handlers
- src/test/mocks/server.ts - MSW server setup
Writing Portal Tests¶
// src/components/ParticipantList.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '../test/test-utils';
import ParticipantList from './ParticipantList';
describe('ParticipantList', () => {
it('renders participant list', async () => {
render(<ParticipantList studyId="1" />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
it('handles empty state', () => {
render(<ParticipantList studyId="999" />);
expect(screen.getByText('No participants yet')).toBeInTheDocument();
});
it('calls API on load', async () => {
const spy = vi.spyOn(global, 'fetch');
render(<ParticipantList studyId="1" />);
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('/api/participants')
);
});
});
});
Custom Test Utilities¶
// src/test/test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions
) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
{ui}
</BrowserRouter>
</QueryClientProvider>,
options
);
}
MSW API Mocking¶
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/participants', () => {
return HttpResponse.json({
participants: [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
],
});
}),
http.post('/api/participants', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 2, ...body },
{ status: 201 }
);
}),
];
Patient Portal Tests (Vitest)¶
Same setup as Portal tests. See Portal section above.
E2E Tests (Playwright)¶
Running E2E Tests¶
E2E tests require dev servers to be running:
# Terminal 1: Start dev servers
npm run dev:all
# Terminal 2: Run E2E tests
npm run test:e2e # Headless
npm run test:e2e:headed # With browser visible
npm run test:e2e:ui # With Playwright UI
npm run test:e2e:debug # Debug mode
# View test report
npm run test:e2e:report
Test Structure¶
e2e/
├── fixtures/ # Test data and fixtures
├── auth.spec.ts # Authentication flows
├── participant.spec.ts # Participant management
├── assessment.spec.ts # Assessment completion
└── playwright.config.ts
Writing E2E Tests¶
// e2e/participant.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Participant Management', () => {
test.beforeEach(async ({ page }) => {
// Login before each test
await page.goto('http://localhost:3000/login');
await page.fill('input[type="email"]', 'admin@metricis.app');
await page.fill('input[type="password"]', 'admin123');
await page.click('button[type="submit"]');
await page.waitForURL('http://localhost:3000/');
});
test('can create new participant', async ({ page }) => {
await page.goto('http://localhost:3000/participants');
await page.click('button:has-text("Add Participant")');
await page.fill('input[name="email"]', 'new@example.com');
await page.fill('input[name="name"]', 'New Participant');
await page.click('button:has-text("Create")');
await expect(page.locator('text=New Participant')).toBeVisible();
});
test('can view participant details', async ({ page }) => {
await page.goto('http://localhost:3000/participants');
await page.click('text=John Doe');
await expect(page).toHaveURL(/\/participants\/\d+/);
await expect(page.locator('h1')).toContainText('John Doe');
});
});
Playwright Configuration¶
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: [
{
command: 'npm run dev:portal',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run dev:server',
url: 'http://localhost:8030/api/health',
reuseExistingServer: !process.env.CI,
},
],
});
Test Coverage¶
Viewing Coverage Reports¶
# Server coverage (HTML report in htmlcov/)
cd server
pytest tests/ --cov=app --cov-report=html
open htmlcov/index.html
# Portal coverage (HTML report in coverage/)
cd portal
npm run test:coverage
open coverage/index.html
# Patient Portal coverage
cd patient-portal
npm run test:coverage
open coverage/index.html
Coverage Goals¶
| Component | Target Coverage |
|---|---|
| Server API endpoints | 80%+ |
| Server business logic | 90%+ |
| Portal components | 70%+ |
| Patient Portal components | 70%+ |
| E2E critical paths | 100% |
Testing Best Practices¶
General Principles¶
- Write tests first - TDD when possible
- Test behavior, not implementation - Focus on what, not how
- Keep tests independent - No shared state between tests
- Use descriptive test names - Should explain what is being tested
- Mock external dependencies - Isolate unit tests from external services
Server Testing¶
- Use fixtures for common test data
- Test error cases and edge cases
- Test authentication and authorization
- Use transaction rollback for database tests
- Mock external API calls (REDCap, Twilio, etc.)
Frontend Testing¶
- Use
screen.getByRoleovergetByTestId - Test user interactions, not implementation details
- Use MSW for API mocking instead of mocking fetch
- Wait for async updates with
waitFor - Test accessibility (ARIA roles, labels)
E2E Testing¶
- Test critical user journeys
- Use page objects for complex flows
- Set up and tear down test data properly
- Run against production-like environment
- Keep tests fast - avoid unnecessary waits
Continuous Integration¶
GitHub Actions still run on pushes and pull requests, but for ongoing development they are advisory. The local verification commands above are authoritative until the project switches remote CI back into deployment-gate mode.
The current workflow lives in .github/workflows/ci.yml and remains useful for environment-parity drift, release-candidate checks, and later deployment readiness. When remote CI is restored as the release gate, update this section and ROADMAP.md together.
Debugging Tests¶
Server Tests¶
# Run with verbose output
pytest tests/ -vv
# Run with print statements
pytest tests/ -s
# Drop into debugger on failure
pytest tests/ --pdb
# Run specific test
pytest tests/test_auth.py::test_login -v
Portal/Patient Portal Tests¶
# Run in watch mode with UI
npm run test
# Debug specific test
npm run test -- src/components/MyComponent.test.tsx
# Update snapshots
npm run test -- -u
E2E Tests¶
# Run in debug mode (pauses on failure)
npm run test:e2e:debug
# Run with headed browser
npm run test:e2e:headed
# Run specific test
npx playwright test e2e/participant.spec.ts
# Open last test report
npm run test:e2e:report
Next Steps¶
- Development Guide - Set up dev environment
- Deployment Guide - Deploy to production
- API Reference - API documentation