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 |
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:8000/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¶
Tests run automatically on GitHub Actions for every push and pull request:
# .github/workflows/test.yml
- name: Run server tests
run: |
cd server
pytest tests/ -v --cov=app
- name: Run portal tests
run: |
cd portal
npm run test:run
- name: Run E2E tests
run: npm run test:e2e
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