Skip to content

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.

cd patient-portal

# Run tests
npm run test

# Run with coverage
npm run test:coverage

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

  1. Write tests first - TDD when possible
  2. Test behavior, not implementation - Focus on what, not how
  3. Keep tests independent - No shared state between tests
  4. Use descriptive test names - Should explain what is being tested
  5. 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.getByRole over getByTestId
  • 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