Skip to main content

Automatic Testing Practices

Athlora uses a three-tier testing strategy: unit/component tests for fast feedback, integration tests against a real database for correctness, and end-to-end tests that exercise the full stack through a real browser. Frontend, backend, coverage, and documentation checks run on every push and pull request. End-to-end tests run in CI only when the required Auth0 test-account secrets are configured; otherwise the job reports an explicit skip.


1.Policy overview​

TierRunnerScopeSpeedDatabaseAuth
Unit / componentVitestSingle function or React componentMillisecondsNoneMocked
IntegrationVitest + SupertestExpress routes and service functionsSecondsReal PostgreSQLMocked
End-to-endPlaywrightFull browser workflow through real serversMinutesReal PostgreSQLReal Auth0

The tiers are designed to catch different classes of bugs:

  • Unit tests verify pure logic (result derivation, validation rules, formatting) and component rendering (state transitions, user interactions, accessibility roles) in isolation. They are the fastest and cheapest to run.
  • Integration tests verify that Express routes, SQL queries, and service functions work together against a real PostgreSQL instance. They catch query bugs, constraint violations, and transaction issues that mocked tests miss.
  • E2E tests verify complete user journeys (sign in, create event, log results, view statistics) through a real browser against real backend and database services. They catch authentication flow issues, SPA routing bugs, and cross-service integration problems.

2. Frontend unit and component tests​

Stack​

  • Vitest — test runner and assertion library
  • React Testing Library — component rendering and interaction queries
  • @testing-library/user-event — realistic user interaction simulation (clicks, typing, keyboard navigation)
  • jsdom — browser environment simulation in Node.js
  • @testing-library/jest-dom — custom DOM matchers (toBeInTheDocument, toHaveAttribute, etc.)

Configuration​

The Vitest config lives inside frontend/vite.config.ts:

test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
css: false,
testTimeout: 30_000,
exclude: ['e2e/**', 'node_modules/**'],
coverage: {
provider: 'v8',
reporter: ['json-summary'],
exclude: ['dist/**', 'src/test/**', '**/*.test.*', '**/*.config.*'],
},
}

The setup file (src/test/setup.ts) imports @testing-library/jest-dom/vitest for extended matchers and stubs window.matchMedia for components that use media queries.

What is tested​

The frontend test inventory is maintained next to its source and covers:

| Category | Examples | |---|---|---| | API client wrappers | src/api/*.test.ts — request/response shapes, error handling, auth headers | | Feature components | src/features/**/*.test.tsx — dashboard, athletes, events, fixtures, fitness, live logging, comparison, public stats, offline sync | | Shared components | Button.test.tsx, Modal.test.tsx, Select.test.tsx, AsyncBoundary.test.tsx | | Hooks and utilities | useLocalStorage, useEventOfflineSync, formatting, auth0, syncEngine | | Pure logic | anatomySurfaceMap, resultPresentation, trackMath, introTimeline | | App shell | App.test.tsx |

Patterns​

Mocking modules: Tests use vi.mock() and vi.hoisted() to replace dependencies at the top level. API modules, Auth0 providers, and WebGL components are commonly mocked.

vi.mock('../../api/athletes', () => ({
fetchAthletes: vi.fn(),
}));

User interaction simulation: @testing-library/user-event provides realistic input simulation rather than raw DOM events.

const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /save/i }));
await user.type(screen.getByLabelText(/name/i), 'Usain Bolt');

Accessible role assertions: Tests query elements by their accessible role rather than CSS selectors, which also serves as a basic accessibility check.

screen.getByRole('heading', { name: /roster/i });
screen.getByRole('button', { name: /archive/i });
screen.getByRole('status'); // for live regions
screen.getByRole('alert'); // for error messages
screen.getByRole('dialog'); // for modals

State coverage: Components are tested across loading, error, empty, populated, and filtered states. Async operations are verified with waitFor and findBy queries.

Lifecycle testing: Tests verify cleanup on unmount (clearing intervals, aborting in-flight requests, removing event listeners).


3. Backend unit tests​

Stack​

  • Vitest — test runner (Node environment, no globals)
  • Supertest — HTTP assertion library that wraps the Express app

Configuration​

The Vitest config lives at backend/vitest.config.ts:

coverage: {
provider: 'v8',
reporter: ['json-summary'],
exclude: ['dist/**', 'src/test/**', '**/*.test.*', '**/*.config.*'],
}

No custom test environment — tests run in the default Node.js environment. Unlike the frontend, backend tests explicitly import vi, describe, it, and expect (no globals).

What is tested​

The backend unit-test inventory is maintained next to the source and covers:

| Area | What they cover | |---|---|---| | Routes | routes/*.test.ts — request validation, response shapes, status codes, auth headers | | Services | services/*.test.ts — business logic, SQL query construction, result derivation | | Middleware | middleware/*.test.ts — auth verification, ownership checks, error handling | | Validation | validation/payloads.test.ts — exhaustive schema validation with exact error shape assertions | | DB utilities | db/*.test.ts — row mapping, migration checksum verification | | Realtime | realtime/*.test.ts — Socket.IO subscription authorization and broadcast |

Patterns​

Mocking the database: The pool's query and connect methods are mocked with mockImplementation that routes by SQL content:

const mockQuery = vi.fn().mockImplementation((sql: string) => {
if (sql.includes('SELECT')) return { rows: [/* fixtures */] };
return { rows: [] };
});

Auth mocking: A configureAuth() helper sets environment variables and mocks jose.jwtVerify to simulate verified tokens without hitting Auth0.

Supertest route testing: Tests exercise the full Express middleware stack through Supertest:

const res = await request(app)
.get('/api/v1/athletes')
.set('Authorization', 'Bearer valid-token')
.set('X-Workspace-Id', workspaceId);

expect(res.status).toBe(200);
expect(res.body.data).toBeInstanceOf(Array);

Ownership non-disclosure: Tests verify that cross-coach resource access returns the same generic 404 NOT_FOUND response whether the resource is missing, malformed, belongs to the wrong parent, or belongs to another coach. This prevents information leakage through error responses.

Lifecycle state machine: Event status transitions are tested exhaustively — valid forward transitions succeed, backward transitions return 409 INVALID_EVENT_TRANSITION, and logging against non-in_progress events returns 409 EVENT_NOT_IN_PROGRESS.

Validation exhaustiveness: payloads.test.ts tests every accepted and rejected field combination for each DTO, verifying exact error codes and ordered issue lists.


4. Backend integration tests​

Gating pattern​

Integration tests are opt-in — they require a real PostgreSQL database and are skipped by default:

const connectionString = process.env.TEST_DATABASE_URL;
const describeDB = connectionString ? describe : describe.skip;

Set TEST_DATABASE_URL to enable them. Use a disposable database because these suites create and remove application data.

What is tested​

The backend integration inventory is TEST_DATABASE_URL-gated and kept next to the source. The following table names the covered areas rather than a volatile file count:

FileCoverage
db/migrate.integration.test.tsMigration runner, checksum tracking, advisory locking
services/athletes.integration.test.tsCRUD persistence, archival preserves timeline/results, lifecycle transitions
services/events.integration.test.tsEvent lifecycle, cancellation history, cross-coach isolation
services/participants.integration.test.tsAssignment persistence, idempotent updates, ownership isolation
services/timeline.integration.test.tsEntry persistence, parent/coach isolation, competition/training timing, ranking
services/aggregates.integration.test.tsStatistics boundaries, archival/cancellation rules, dashboard modes
services/authorization.integration.test.tsTwo-coach isolation for athletes, events, participants, timeline, statistics
services/injuries.integration.test.tsInjury CRUD persistence
services/accounts.integration.test.tsAccount lifecycle and workspace persistence
services/meets.integration.test.tsMigration upgrades/schema install, catalogue sessions, entrant kinds and registrations, session results/corrections, own-club official selection, relay official selection, relay leg PB/SB flags, relay-leg PBs surfacing on the roster/progression/comparison reads, relay team rows and team-based club statistics on the public report/leaderboard/club reads, supported-catalogue preference hiding, and the retired-code preference prune
services/sync.integration.test.tsOffline batch sync: idempotent receipts, rejected-action retries, EVENT_NOT_IN_PROGRESS guard
services/publicLoggers.integration.test.tsPublic logger link authorization: host and invited-coach management, unrelated-coach blocking, stale/retracted fixture acceptance

Setup​

Each integration test:

  1. Connects to the test database
  2. Runs pending migrations
  3. Seeds test data
  4. Exercises service functions directly (not through HTTP)
  5. Cleans up by truncating tables with CASCADE in afterEach

Running locally​

Start a disposable PostgreSQL instance:

docker run --rm -e POSTGRES_PASSWORD=postgres -p 55432:5432 postgres:16

Then run with the test database URL:

cd backend
TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres npm run test

5. End-to-end tests​

Stack​

  • Playwright — browser automation
  • @axe-core/playwright — automated accessibility auditing
  • Real Auth0 Universal Login — no mocked authentication
  • Real PostgreSQL — migrated and truncated before every run

Configuration​

The Playwright config lives at e2e/playwright.config.ts:

SettingValue
Test directory./tests
Parallelfalse (fully sequential)
Workers1
Retries1 in CI, 0 locally
Timeout90 seconds per test
Expect timeout15 seconds
Base URLhttp://localhost:5174
TraceCaptured on first retry

Projects​

ProjectPurposeAuth
auth-setupAuthenticates via Auth0 Universal Login, saves browser stateReal Auth0
smokeAnonymous landing page smoke test + axe auditNone
desktop-chromiumAll authenticated tests at desktop viewportSaved state from auth-setup
mobile-chromiumAll authenticated tests at Pixel 5 viewportSaved state from auth-setup

What is tested​

The E2E suite has 23 spec files covering:

SpecCoverage
vertical-slice.spec.tsFull 100m workflow: roster → event → assignment → live logging → corrections → overrides → completion → statistics → dashboard
workspace.spec.tsMulti-workspace switching and membership management
roles.spec.tsCoach vs assistant role enforcement
athlete-lifecycle.spec.tsActive/inactive/archived transitions
injuries.spec.tsInjury creation and resolution
event-helpers.spec.tsHelper invitations and offline designation
realtime.spec.tsSocket.IO live updates
reminders.spec.tsEvent reminders
public-logger.spec.tsPublic logger links and sessions
fixtures.spec.tsCross-club fixture flow (multi-user, multi-context)
fixture-notifications.spec.tsNotification delivery and unread counts
authorization.spec.tsCross-workspace authorization boundaries
migration.spec.tsSchema migration verification
accessibility.spec.tsDeep axe-core audit of 7 pages + keyboard nav + narrow viewport
routing.spec.tsSPA route navigation
analytics.spec.tsAnalytics features
comparison.spec.tsTwo-athlete comparison
offline-logging.spec.tsOffline sync E2E
public-schedule.spec.tsPublished schedule visibility without sign-in, empty/unavailable states, axe
public-statistics-report.spec.tsPublic report filters in the shareable URL and axe audit
relay-session.spec.tsRelay setup, team logging, official selection, and standings
vertical-events.spec.tsHigh jump setup, progression, audited attempts, and finalized placing
smoke.spec.tsAnonymous landing page + axe

Global setup​

Before any tests run, global-setup.ts:

  1. Runs npm run db:migrate against the E2E database
  2. Truncates its application-table list with CASCADE for a clean slate (catalogue data seeded by migrations is left in place)

Auth setup​

auth.setup.ts authenticates two separate users through Auth0 Universal Login:

  1. Coach — primary test account, saves to .auth/coach.json
  2. Guest — secondary account for cross-workspace fixture flows, saves to .auth/guest.json

Multi-user tests use browser.newContext({ storageState }) to run with separate browser sessions.

Required environment variables​

DATABASE_URL PostgreSQL (disposable — truncated every run)
VITE_AUTH0_DOMAIN Auth0 tenant domain
VITE_AUTH0_CLIENT_ID Auth0 SPA application client ID
VITE_AUTH0_AUDIENCE Auth0 API audience
E2E_AUTH0_EMAIL Primary coach test account email
E2E_AUTH0_PASSWORD Primary coach test account password
E2E_GUEST_AUTH0_EMAIL Guest coach test account email
E2E_GUEST_AUTH0_PASSWORD Guest coach test account password

Running locally​

docker run --rm -e POSTGRES_PASSWORD=postgres -p 55432:5432 postgres:16

cp e2e/.env.example e2e/.env # fill in Auth0 + database credentials
cd e2e
npm install
npm run test:install # install Chromium
npm test

Playwright starts both the backend (port 4100) and the Vite frontend (port 5174) automatically via the webServer config.


6. Accessibility testing​

Accessibility is tested at two levels:

E2E level (axe-core)​

Every major page is audited with @axe-core/playwright against WCAG 2.0/2.1 Level A and AA. The accessibility.spec.ts file tests:

  • Dashboard
  • Roster
  • Events
  • Live logger
  • Comparison
  • Fixtures
  • Account
  • Athlete detail

Tests fail on any critical or serious violation. The shared expectNoSeriousViolations() helper wraps axe-core and is reused across spec files:

import { expectNoSeriousViolations } from './helpers/accessibility';

await expectNoSeriousViolations(page);

Component level​

Frontend component tests use accessible role queries (getByRole, getByLabelText, getByTestId) which implicitly verify that ARIA roles and labels are present. This provides a baseline accessibility check during unit testing without a full axe audit.


7. Linting and typechecking​

ESLint​

Both frontend and backend use ESLint v9 flat config with @eslint/js recommended + typescript-eslint recommended.

PackageExtra pluginsNotable rules
Frontendeslint-plugin-react-hooks, eslint-plugin-react-refreshHooks rules enforced, only-export-components warn
Backend—_ prefix ignores unused vars, namespaces allowed in .d.ts

TypeScript​

Both packages use strict mode:

PackageTargetModule resolutionExtra strictness
FrontendES2022BundlerProject references (tsconfig.app.json + tsconfig.node.json)
BackendES2022NodeNextnoUnusedLocals, noUnusedParameters, noFallthroughCasesInSwitch

8. Coverage​

Configuration​

Both frontend and backend use Vitest V8 coverage with json-summary reporter:

coverage: {
provider: 'v8',
reporter: ['json-summary'],
}

Running coverage locally:

npm run test:coverage --prefix frontend # → frontend/coverage/coverage-summary.json
npm run test:coverage:ci --prefix backend # → backend/coverage/coverage-summary.json

Combined report​

The scripts/generate-coverage-report.mjs script reads both JSON summaries and generates a Markdown table combining frontend and backend line, branch, and function coverage. In CI it writes to GITEA_STEP_SUMMARY (or GITHUB_STEP_SUMMARY), printing a short Markdown table visible in the job summary.

Coverage is informational — it makes gaps visible but does not enforce a threshold. The CI backend coverage command excludes database-gated integration suites; it is a consistent source-coverage signal, not a substitute for running integration tests against a disposable database. A coverage report is reviewed as part of the author's documented pre-merge self-review for source-code changes.


9. CI pipeline​

The Gitea Actions workflow (.gitea/workflows/ci.yml) runs on every push and pull request using Node.js 22:

JobSteps
frontendInstall → Lint → Typecheck → Test → Build
backendInstall → Lint → Typecheck → Test → Build
coverageInstall both → Generate frontend coverage → Generate backend coverage → Generate quality report
docsInstall → Build
e2eDetect Auth0/E2E secrets → when configured: Provision PostgreSQL on port 55432 (root/sudo-aware) → Install all deps → Install Chromium → Run Playwright; otherwise emit an explicit skip message

E2E job details​

The e2e job first writes a configured step output from the seven required Auth0/E2E repository secrets (the secrets context is not reliable inside step if: expressions on act runners). When configured, it provisions an isolated PostgreSQL cluster inside the job container without assuming sudo exists — act runner images often run as root with no sudo binary:

# root → plain apt-get; non-root → sudo when available
apt-get install -y postgresql libpq-dev
runuser/su postgres → initdb → pg_ctl start -p 55432 → createdb athlora_e2e → set password

When the seven required Auth0/E2E repository secrets are not configured, the job prints a clear skip message and stays green:

Skipping the e2e job: the Auth0 / E2E credentials are not configured.
Set VITE_AUTH0_DOMAIN, VITE_AUTH0_CLIENT_ID, VITE_AUTH0_AUDIENCE,
E2E_AUTH0_EMAIL, E2E_AUTH0_PASSWORD, E2E_GUEST_AUTH0_EMAIL, and
E2E_GUEST_AUTH0_PASSWORD as repository secrets to run the
full 100m vertical-slice suite (Playwright desktop + mobile + a11y).

On failure, the Playwright HTML report is uploaded as an artifact with 7-day retention.


10. Running tests locally — quick reference​

# Frontend unit tests
cd frontend && npm run test

# Backend unit tests (skips integration tests)
cd backend && npm run test

# Backend integration tests (requires PostgreSQL)
docker run --rm -e POSTGRES_PASSWORD=postgres -p 55432:5432 postgres:16
cd backend
TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres npm run test

# E2E tests (requires PostgreSQL + Auth0 test accounts)
docker run --rm -e POSTGRES_PASSWORD=postgres -p 55432:5432 postgres:16
cp e2e/.env.example e2e/.env # fill credentials
cd e2e && npm install && npm run test:install && npm test

# Lint and typecheck
npm run lint --prefix frontend
npm run typecheck --prefix frontend
npm run lint --prefix backend
npm run typecheck --prefix backend

# Coverage
npm run test:coverage --prefix frontend
npm run test:coverage:ci --prefix backend
node scripts/generate-coverage-report.mjs

11. Test file organisation​

Naming​

  • Unit and component tests: *.test.ts or *.test.tsx next to the source file
  • Integration tests: *.integration.test.ts in the same directory as unit tests
  • E2E specs: *.spec.ts in e2e/tests/

Directory mapping​

frontend/src/
api/athletes.test.ts → tests src/api/athletes.ts
features/athletes/AthletesPage.test.tsx → tests src/features/athletes/AthletesPage.tsx
components/Modal.test.tsx → tests src/components/Modal.tsx

backend/src/
routes/athletes.test.ts → tests src/routes/athletes.ts
services/athletes.test.ts → tests src/services/athletes.ts
services/athletes.integration.test.ts → tests src/services/athletes.ts against real DB

e2e/tests/
vertical-slice.spec.ts → tests the full 100m workflow
helpers/accessibility.ts → shared axe-core wrapper
helpers/navigation.ts → shared page navigation helpers
helpers/names.ts → unique test data generators

12. Writing new tests​

When adding a new feature, tests should be written in the same session as the implementation. The "done" checklist requires:

  • Backend: Supertest coverage for happy path + at least one validation/error path per new endpoint
  • Result-derivation and merge logic: Vitest unit tests with edge cases (foul-only attempts, DQ, tied results)
  • Frontend: RTL test per new component covering render + primary interaction
  • Cross-cutting flows (login, live logging, offline sync): Playwright E2E added incrementally
  • Accessibility: axe-core audit covers any new page or major view
  • No task is "done" without its applicable automated checks passing; authenticated E2E checks additionally require configured repository secrets

Guidelines​

  • Test behaviour, not implementation. Assert on what the component renders and how it responds to interaction, not on internal state or call counts.
  • Use accessible queries. Prefer getByRole, getByLabelText, and getByText over getByTestId.
  • Mock at the boundary. Mock API calls and external services, not internal utility functions.
  • Keep tests independent. Each test should set up its own data and not depend on other tests running in a specific order.
  • Use unique test data. E2E tests generate unique tokens, names, and IDs to prevent collisions between desktop and mobile runs.
  • Test error states. Loading, empty, error, and retry states are first-class test scenarios.

13. Stakeholder feedback​

Automated tests are complemented by manual stakeholder and user feedback. During Sprints 2 and 3, the client/stakeholder and users tested the deployed application and completed separate structured forms covering the product experience and improvement opportunities. The response evidence is retained with the Sprint records; feedback-to-issue/PR linkage is recorded only where that link is available.

The submitted response evidence and form links are retained in Stakeholder Feedback and User Feedback. Manual feedback is not a substitute for unit, integration, end-to-end, or accessibility testing; it provides independent product perspectives alongside those automated quality gates.


AI declaration​

This document was created or updated with the assistance of OpenCode[openai/gpt-5.6-terra].