Spec 37.5: E2E Tests & CI/CD
Status: READY FOR IMPLEMENTATION Phase: 5 of 5 Depends On: Spec 37.4 (App Integration) Goal: End-to-end tests with Playwright and GitHub Actions CI pipeline
Deliverables
By the end of this phase, you will have:
- Playwright E2E tests for critical user flows
- Page Object Models for maintainable tests
- GitHub Actions workflow for CI
- Coverage reporting integration
Step 1: Install Playwright
cd frontend
npm install -D @playwright/test@1.49.1
npx playwright install
Step 2: Playwright Configuration
Create 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,
reporter: [
['html', { open: 'never' }],
['list'],
...(process.env.CI ? [['github' as const]] : []),
],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// Uncomment for cross-browser testing:
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
})
Step 3: Update package.json Scripts
Add to package.json scripts:
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug"
}
}
Step 4: Page Object Model
Create e2e/pages/HomePage.ts:
import { type Page, type Locator, expect } from '@playwright/test'
export class HomePage {
readonly page: Page
readonly heading: Locator
readonly caseSelector: Locator
readonly runButton: Locator
readonly processingText: Locator
readonly metricsPanel: Locator
readonly diceScore: Locator
readonly viewer: Locator
readonly placeholderText: Locator
readonly errorAlert: Locator
constructor(page: Page) {
this.page = page
this.heading = page.getByRole('heading', {
name: /stroke lesion segmentation/i,
})
this.caseSelector = page.getByRole('combobox')
this.runButton = page.getByRole('button', { name: /run segmentation/i })
this.processingText = page.getByText(/processing/i)
this.metricsPanel = page.getByRole('heading', { name: /results/i })
this.diceScore = page.getByText(/0\.\d{3}/)
this.viewer = page.locator('canvas')
this.placeholderText = page.getByText(/select a case and run segmentation/i)
this.errorAlert = page.getByRole('alert')
}
async goto() {
await this.page.goto('/')
await expect(this.heading).toBeVisible()
}
async waitForCasesToLoad() {
await expect(this.caseSelector).toBeEnabled({ timeout: 10000 })
}
async selectCase(caseId: string) {
await this.caseSelector.selectOption(caseId)
}
async runSegmentation() {
await this.runButton.click()
}
async waitForResults() {
await expect(this.metricsPanel).toBeVisible({ timeout: 30000 })
}
async expectViewerVisible() {
await expect(this.viewer).toBeVisible()
}
async expectPlaceholderVisible() {
await expect(this.placeholderText).toBeVisible()
}
async expectErrorVisible() {
await expect(this.errorAlert).toBeVisible()
}
}
Step 5: Global API Mocking Fixture
CRITICAL: E2E tests run against npm run dev which has no backend.
Without API mocking, tests will hang or fail on API calls.
Create e2e/fixtures.ts - Global mock for all API calls:
import { test as base, expect } from '@playwright/test'
// API response mocks matching MSW handlers
const MOCK_CASES = ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003']
const MOCK_SEGMENT_RESPONSE = {
caseId: 'sub-stroke0001',
diceScore: 0.847,
volumeMl: 15.32,
elapsedSeconds: 12.5,
// Use a real public NIfTI for visual testing (NiiVue demo image)
dwiUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
predictionUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
}
// Extend base test to include API mocking
export const test = base.extend({
// Auto-mock API routes for every test
page: async ({ page }, use) => {
// Mock GET /api/cases
await page.route('**/api/cases', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ cases: MOCK_CASES }),
})
})
// Mock POST /api/segment - return different caseId based on request
await page.route('**/api/segment', async (route) => {
const request = route.request()
const body = JSON.parse(request.postData() || '{}')
// Simulate network delay
await new Promise((r) => setTimeout(r, 200))
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
...MOCK_SEGMENT_RESPONSE,
caseId: body.case_id || 'sub-stroke0001',
}),
})
})
await use(page)
},
})
export { expect }
Step 6: E2E Tests
Create e2e/home.spec.ts:
import { test, expect } from './fixtures'
import { HomePage } from './pages/HomePage'
test.describe('Home Page', () => {
test('displays main heading', async ({ page }) => {
const homePage = new HomePage(page)
await homePage.goto()
await expect(homePage.heading).toBeVisible()
})
test('loads case selector with options', async ({ page }) => {
const homePage = new HomePage(page)
await homePage.goto()
await homePage.waitForCasesToLoad()
// Verify selector has options
const options = await homePage.caseSelector.locator('option').count()
expect(options).toBeGreaterThan(1) // placeholder + cases
})
test('shows placeholder viewer initially', async ({ page }) => {
const homePage = new HomePage(page)
await homePage.goto()
await homePage.expectPlaceholderVisible()
})
test('run button disabled without case selected', async ({ page }) => {
const homePage = new HomePage(page)
await homePage.goto()
await homePage.waitForCasesToLoad()
await expect(homePage.runButton).toBeDisabled()
})
})
Create e2e/segmentation-flow.spec.ts:
import { test, expect } from './fixtures'
import { HomePage } from './pages/HomePage'
test.describe('Segmentation Flow', () => {
test('complete segmentation workflow', async ({ page }) => {
const homePage = new HomePage(page)
await homePage.goto()
await homePage.waitForCasesToLoad()
// Select a case
await homePage.selectCase('sub-stroke0001')
await expect(homePage.runButton).toBeEnabled()
// Run segmentation
await homePage.runSegmentation()
// Verify processing state
await expect(homePage.processingText).toBeVisible()
// Wait for results
await homePage.waitForResults()
// Verify results displayed
await expect(homePage.diceScore).toBeVisible()
await homePage.expectViewerVisible()
// Placeholder should be gone
await expect(homePage.placeholderText).not.toBeVisible()
})
test('can run multiple segmentations', async ({ page }) => {
const homePage = new HomePage(page)
await homePage.goto()
await homePage.waitForCasesToLoad()
// First run
await homePage.selectCase('sub-stroke0001')
await homePage.runSegmentation()
await homePage.waitForResults()
// Second run with different case
await homePage.selectCase('sub-stroke0002')
await homePage.runSegmentation()
await homePage.waitForResults()
// Results should still be visible
await expect(homePage.metricsPanel).toBeVisible()
})
})
Create e2e/error-handling.spec.ts:
import { test as base, expect } from '@playwright/test'
import { HomePage } from './pages/HomePage'
// Error tests need to override the default mocks, so use base test
const test = base
test.describe('Error Handling', () => {
test('shows error when API fails', async ({ page }) => {
// Mock cases API (needed for page to load)
await page.route('**/api/cases', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ cases: ['sub-stroke0001'] }),
})
})
// Mock segment API to return error
await page.route('**/api/segment', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ detail: 'Segmentation failed' }),
})
})
const homePage = new HomePage(page)
await homePage.goto()
await homePage.waitForCasesToLoad()
await homePage.selectCase('sub-stroke0001')
await homePage.runSegmentation()
await homePage.expectErrorVisible()
await expect(homePage.errorAlert).toContainText(/failed/i)
})
test('shows error when cases fail to load', async ({ page }) => {
// Mock cases API to return error
await page.route('**/api/cases', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ detail: 'Server error' }),
})
})
const homePage = new HomePage(page)
await homePage.goto()
// Case selector should show error state
await expect(page.getByText(/failed to load/i)).toBeVisible()
})
})
Step 7: GitHub Actions CI Workflow
Create .github/workflows/frontend-ci.yml:
name: Frontend CI
on:
push:
branches: [main]
paths:
- 'frontend/**'
- '.github/workflows/frontend-ci.yml'
pull_request:
paths:
- 'frontend/**'
defaults:
run:
working-directory: frontend
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npx tsc --noEmit
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run test:coverage
- uses: codecov/codecov-action@v4
with:
files: frontend/coverage/coverage-final.json
flags: frontend
fail_ci_if_error: false
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 7
build:
runs-on: ubuntu-latest
needs: [typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: frontend/dist/
retention-days: 7
Step 8: Add Coverage Thresholds
Update vite.config.ts coverage section:
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.test.{ts,tsx}',
'src/test/**',
'src/mocks/**',
'src/main.tsx',
'src/vite-env.d.ts',
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
Step 9: Run All Tests
# Unit & Integration tests
npm test
# Expected: ~45+ tests passing
# E2E tests
npm run test:e2e
# Expected: 7 tests passing
# Coverage report
npm run test:coverage
# Expected: >80% coverage
Verification Checklist
-
npm test- All unit/integration tests pass -
npm run test:coverage- Coverage meets thresholds -
npm run test:e2e- All E2E tests pass -
npm run build- Production build succeeds - CI workflow runs successfully (push to branch)
File Structure After This Phase
frontend/
βββ e2e/
β βββ pages/
β β βββ HomePage.ts
β βββ fixtures.ts # <-- NEW: Global API mocking
β βββ home.spec.ts
β βββ segmentation-flow.spec.ts
β βββ error-handling.spec.ts
βββ src/
β βββ components/
β βββ api/
β βββ hooks/
β βββ types/
β βββ test/
β βββ mocks/
β βββ App.tsx
β βββ App.test.tsx
β βββ ...
βββ .github/
β βββ workflows/
β βββ frontend-ci.yml
βββ playwright.config.ts
βββ vite.config.ts
βββ package.json
βββ ...
Summary: Complete Testing Stack
| Layer | Tool | Test Count | Purpose |
|---|---|---|---|
| Unit | Vitest + RTL | ~35 | Component isolation |
| Integration | Vitest + MSW | ~15 | API + hooks |
| E2E | Playwright | ~7 | Full user flows |
| Total | ~57 |
Next Steps After All Phases Complete
- Deploy Frontend: Push to HuggingFace Static Space
- Connect to Backend: Update
VITE_API_URLto real backend - Test Against Real API: Run E2E tests with real backend
- Monitor: Set up error tracking (optional)
Congratulations!
You now have a fully tested React frontend with:
- Type-safe TypeScript code
- Comprehensive unit tests
- API mocking with MSW
- End-to-end browser tests
- Automated CI/CD pipeline
- 80%+ code coverage