This document describes the testing infrastructure for the fitai.coach application, covering unit tests, integration tests, end-to-end tests, CI/CD workflows, and conventions.
- Overview
- Vitest Setup
- Unit Test Patterns
- Component Tests
- Route and Integration Tests
- Cypress E2E Tests
- CI/CD Test Workflows
- Running Tests
- Test File Organization
The project follows a testing pyramid with three layers:
- Unit tests (Vitest) -- Fast, isolated tests for services, utilities, schema validation, React components, and hooks. These form the bulk of the test suite.
- Integration tests (Vitest, separate config) -- Tests that exercise API routes against a real PostgreSQL database with the full Express app wired up.
- End-to-end tests (Cypress) -- Browser-based tests that verify complete user flows against a running server.
| Layer | Count | Location |
|---|---|---|
| Unit/component/route tests (Vitest) | 184 files | client/src, server, and shared *.test.{ts,tsx} files, excluding *.integration.test.ts and *.smoke.test.ts |
| Integration tests | 2 files | server/routes/tests/*.integration.test.ts |
| Smoke tests | 1 file | server/routes/__tests__/routeRegistration.smoke.test.ts — run as pnpm test:smoke for fast pre-push feedback |
| Cypress E2E specs | 12 files | cypress/e2e/*.cy.ts |
The exact Vitest assertion count changes frequently as review-fix branches land.
Use rg --files -g "*.test.ts" -g "*.test.tsx" for a current total test-file count, and add -g "!*.integration.test.ts" -g "!*.smoke.test.ts" when you need the unit/component/route count.
All four coverage metrics are configured at 80% via @vitest/coverage-v8
for explicit coverage runs. Normal CI currently runs pnpm test without
coverage collection.
- Lines: 80%
- Functions: 80%
- Branches: 80%
- Statements: 80%
// Key settings:
plugins: [react()] // @vitejs/plugin-react for JSX/TSX
environment: 'jsdom' // DOM simulation for component tests
setupFiles: ['./vitest.setup.ts'] // Global setup
globals: true // describe/it/expect available globally
exclude: ['**/*.integration.test.ts', ...] // Integration tests run separatelyPath aliases mirror the Vite dev config:
| Alias | Resolves to |
|---|---|
@ |
client/src |
@shared |
shared |
Setup file (vitest.setup.ts):
- Pins
process.env.TZ = "UTC"so date formatting matches CI on every machine. - Imports
@testing-library/jest-dom/vitestfor DOM matchers (e.g.,toBeInTheDocument()). - Registers the
jest-axetoHaveNoViolations()matcher viaexpect.extend()for automated a11y checks. - Sets dummy environment variables so modules that read
process.envat import time do not crash:DATABASE_URL-- dummy Postgres URLCLERK_PUBLISHABLE_KEY/CLERK_SECRET_KEY-- dummy valuesENCRYPTION_KEY-- 32-character test key
Integration tests use a separate Vitest config to run against a real database:
// Key differences from unit config:
include: ['**/*.integration.test.ts'] // Only integration test files
environment: 'node' // No jsdom -- server-side only
setupFiles: ['./vitest.integration.setup.ts']
fileParallelism: false // Sequential execution to avoid DB conflictsThe integration setup file (vitest.integration.setup.ts) deletes Clerk env vars so the app falls back to dev auth bypass, and falls back to a dummy DATABASE_URL when none is provided by CI.
Service tests are pure logic tests with no mocking of external systems required. They call service functions directly with crafted input data and assert on the return value.
Example structure (server/services/analyticsService.test.ts):
// 1. Factory helper to build test data
function makeSet(overrides: Record<string, unknown> = {}) {
return { exerciseName: "back_squat", category: "strength", /* defaults */ ...overrides };
}
// 2. Group by function under test
describe("calculatePersonalRecords", () => {
it("returns empty object for empty input", () => { ... });
it("tracks maxWeight PR", () => { ... });
});Zod schemas are tested by calling .safeParse() with valid and invalid payloads and asserting on result.success and error messages.
Example (shared/schema.test.ts):
it("rejects a very large csvContent", () => {
const result = importPlanRequestSchema.safeParse(payload);
expect(result.success).toBe(false);
expect(result.error.errors[0].message).toBe("CSV content must be 100,000 characters or less");
});Utility functions in client/src/lib/ and server/utils/ are tested with straightforward input/output assertions. Files include dateUtils.test.ts, exerciseUtils.test.ts, statsUtils.test.ts, sanitize.test.ts, and others.
vi.mock()-- Used at the module level to replace imports (storage, services, auth middleware).vi.fn()-- Creates mock functions for individual spies.vi.mocked()-- Provides typed access to mocked functions for assertions.vi.clearAllMocks()-- Called inbeforeEachto reset mock state between tests.
Component tests use React Testing Library (@testing-library/react) with the jsdom environment provided by Vitest.
Components are rendered with render() and queried using screen queries:
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/button";
it("renders the button label correctly", () => {
render(<Button>Save changes</Button>);
expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled();
});The @testing-library/user-event package is available for simulating user interactions (clicks, typing, etc.).
@testing-library/jest-dom is imported globally via vitest.setup.ts, providing matchers such as:
toBeInTheDocument()toHaveClass()toBeVisible()
Custom hooks are tested in files under client/src/hooks/__tests__/. These test files use .test.tsx extensions when they need JSX for wrapper providers.
Automated accessibility assertions live alongside the component they cover, using the filename convention *.a11y.test.tsx inside __tests__/ directories. Each test renders the component, runs axe() against the markup, and asserts toHaveNoViolations(). The toHaveNoViolations matcher is registered globally in vitest.setup.ts, so individual test files only need to import axe.
import { axe } from "jest-axe";
it("has no detectable a11y violations", async () => {
const { container } = render(<WorkoutHeader title="Log Workout" />);
expect(await axe(container)).toHaveNoViolations();
});Current a11y coverage includes:
client/src/pages/__tests__/not-found.a11y.test.tsxclient/src/components/__tests__/RpeSelector.a11y.test.tsxclient/src/components/__tests__/Breadcrumbs.a11y.test.tsxclient/src/components/ui/__tests__/OfflineIndicator.a11y.test.tsxclient/src/components/workout/__tests__/WorkoutHeader.a11y.test.tsxclient/src/components/timeline/__tests__/TimelineWorkoutCard.a11y.test.tsxclient/src/components/timeline/__tests__/CoachReviewingIndicator.a11y.test.tsxclient/src/components/coach/__tests__/SuggestionCard.a11y.test.tsx
Adding a new a11y test is part of the PR checklist for any user-facing component change. The tests run as part of the normal pnpm test Vitest pool — there is no separate command or workflow.
Route tests use supertest to make HTTP requests against an isolated Express app. External dependencies are fully mocked.
Test app factory (server/routes/__tests__/testUtils.ts):
export function createTestApp(router: express.Router) {
const app = express();
app.use(express.json());
app.use(router);
setupTestErrorHandler(app);
return app;
}Common mocking targets:
../../clerkAuth-- ReplacesisAuthenticatedmiddleware to inject a test user (req.auth = { userId: "test_user_id" })../../types-- MocksgetUserId()to return the test user ID../../storage-- Mocks database storage functions../../services/*-- Mocks service-layer functions../../queue-- Mocks the background job queue
Rate limiting: clearRateLimitBuckets() is called in beforeEach to reset rate limit state.
Integration tests run against a real PostgreSQL database (pgvector/pgvector:pg16 in CI) with the full Express route tree registered.
Helper setup (server/routes/tests/helpers.ts):
setupIntegrationTest()-- Lifecycle hook that creates the full Express app, clears the database before each test, and inserts a test user (dev-user) to satisfy foreign key constraints.clearDatabase()-- Deletes all rows fromexerciseSets,workoutLogs,planDays,trainingPlans, anduserstables.- Auth bypass via
ALLOW_DEV_AUTH_BYPASS=trueenv var and thedev-useruser ID.
Integration test files:
| File | Purpose |
|---|---|
server/routes/tests/api.integration.test.ts |
Tests plans, preferences, timeline, and workout CRUD against a real database |
server/routes/tests/post-migration.integration.test.ts |
Verifies database schema correctness after migrations (used in the post-migration workflow) |
projectId: "dy8p9y" // Cypress Cloud project for recording/parallelism
baseUrl: "http://localhost:5000" // Local dev server
specPattern: "cypress/e2e/**/*.cy.ts"
supportFile: "cypress/support/e2e.ts"
video: true
screenshotOnRunFailure: true
viewportWidth: 1280
viewportHeight: 720
defaultCommandTimeout: 10000 // 10 seconds| Spec file | Coverage area |
|---|---|
cypress/e2e/analytics.cy.ts |
Analytics dashboard |
cypress/e2e/api-validation.cy.ts |
API input validation |
cypress/e2e/coach-chat.cy.ts |
Coach chat flow |
cypress/e2e/emom-rollout-config.cy.ts |
EMOM rollout configuration |
cypress/e2e/landing.cy.ts |
Landing/marketing page |
cypress/e2e/log-workout.cy.ts |
Workout logging form |
cypress/e2e/log-workout-submission.cy.ts |
Workout form submission |
cypress/e2e/navigation.cy.ts |
Sidebar navigation, routing, 404 |
cypress/e2e/onboarding.cy.ts |
Onboarding flow |
cypress/e2e/plan-generation.cy.ts |
AI plan generation flow |
cypress/e2e/settings.cy.ts |
Settings page |
cypress/e2e/timeline.cy.ts |
Timeline view |
cypress/support/e2e.ts -- Global setup:
- Imports custom commands from
./commands. - Suppresses uncaught exceptions from
clerk.example.com(Clerk SDK in test mode). - Intercepts all requests to
https://clerk.example.com/**with a200 {}response inbeforeEach, preventing Clerk from interfering with tests.
cypress/support/commands.ts -- Custom commands:
cy.getBySel(selector)-- Shorthand forcy.get('[data-testid="..."]'). This is the standard way to select elements in E2E tests.
cypress/support/authIntercepts.ts -- Authentication bypass:
The setupAuthIntercepts() function stubs all authenticated API endpoints so tests can run without a real backend or real auth. It intercepts:
GET /api/v1/auth/user-- Returns a mock userGET /api/v1/preferences-- Returns default preferences (configurable viaoverrides)GET /api/v1/timeline*,GET /api/v1/plans,GET /api/v1/workouts*GET /api/v1/personal-records*,GET /api/v1/exercise-analytics*,GET /api/v1/training-overview*GET /api/v1/strava/status,GET /api/v1/exercise-history*,GET /api/v1/custom-exercises*,GET /api/v1/chat/history
All intercepted endpoints return sensible defaults that can be overridden per-test:
setupAuthIntercepts({
workouts: [{ id: "1", date: "2026-01-15", ... }],
preferences: {
weightUnit: "lbs",
distanceUnit: "miles",
weeklyGoal: 3,
emailNotifications: false,
emailWeeklySummary: false,
emailMissedReminder: false,
},
});Every authenticated E2E test follows this structure:
beforeEach(() => {
setupAuthIntercepts();
cy.visit("/log");
cy.wait("@authUser");
});
it("shows the workout form", () => {
cy.getBySel("input-workout-title").should("exist");
});File: server/routes/__tests__/routeRegistration.smoke.test.ts
A fast route-registration smoke test that imports registerRoutes, mounts it on a bare Express app, and asserts the route tree wires up correctly (for example, that the workouts router is mounted exactly once). All sub-routers, auth setup, and CSRF middleware are mocked, so the test runs quickly without a real server, database, or build step.
The smoke test uses a separate Vitest config (vitest.smoke.config.ts):
- Included only via the
**/smoke.test.tsglob; excluded from the standard unit test suite - Uses
nodeenvironment (no jsdom) - Shares the integration setup file (
vitest.integration.setup.ts) - Runs with
fileParallelism: false - Runs as its own step in the Cypress workflow (
cypress.yml)
registerRoutes()mounts each sub-router on the Express app- The workouts router is mounted exactly once (no duplicate registration)
pnpm test:smoke
# equivalent to:
pnpm exec vitest run --config vitest.smoke.config.tsAll workflows are in .github/workflows/ and run on GitHub Actions with Ubuntu runners. Node-based workflows use Node.js 20 via pnpm.
- Name: Unit Tests
- Triggers: Push to
main, pull request (opened/synchronize/reopened) - Steps: Checkout, install pnpm + Node.js 20,
pnpm install,pnpm test - Environment: Dummy values for
CLERK_PUBLISHABLE_KEY,CLERK_SECRET_KEY,DATABASE_URL,ENCRYPTION_KEY
- Name: Cypress Tests
- Triggers: Every push
- Services: PostgreSQL (pgvector/pgvector:pg16) on port 5432
- Parallelism: Matrix strategy with 2 containers for parallel Cypress runs
- Steps:
- Install dependencies and Cypress binary (cached)
- Build the application (
pnpm run build) - Enable pgvector extension
- Run integration tests:
pnpm exec vitest run --config vitest.integration.config.ts - Push database schema with
drizzle-kit push - Run the smoke test:
pnpm exec vitest run --config vitest.smoke.config.ts - Start the built server in test mode (
NODE_ENV=test,ALLOW_DEV_AUTH_BYPASS=true) - Wait for server health check at
/api/v1/health - Run Cypress, recording to Cypress Cloud only when
CYPRESS_RECORD_KEYis set
- Name: Check Migrations
- Triggers: Push to
main, pull request - Steps: Runs
pnpm run db:checkfor internal consistency, thenpnpm run db:generatefollowed bygit diff --exit-code migrations/to verify migrations are up to date with the schema.
- Name: Post-Migration Verification
- Triggers: Manual (
workflow_dispatch) - Steps: Applies migrations to Neon database via
pnpm run db:migrate, then runspost-migration.integration.test.tsagainst the real Neon database to verify schema correctness.
- Name: Build
- Triggers: Push to
main, pull request - Purpose: ESLint, TypeScript, and OpenAPI snapshot drift checks. SonarQube Cloud automatic analysis is configured outside these manual workflow steps.
- DevSkim (
devskim.yml) -- Static security analysis - Bearer (
bearer.yml) -- Static security and privacy analysis - Dependency Review (
dependency-review.yml) -- Reviews dependency changes in PRs
The .claude/commands/review/ directory contains structured code-review prompts ("skill profiles") that frame a walkthrough of the codebase from a specific role's perspective. They complement automated tests by surfacing issues that linters and unit tests miss — architectural drift, UX regressions, privacy gaps, and so on.
| Profile | File | Role |
|---|---|---|
security |
.claude/commands/review/security.md |
Senior security auditor — vulnerabilities, exposed secrets, auth gaps |
privacy |
.claude/commands/review/privacy.md |
Data privacy officer — collection, storage, transmission, retention, compliance |
ux |
.claude/commands/review/ux.md |
UX / accessibility expert — usability, flow, responsiveness, WCAG compliance |
performance |
.claude/commands/review/performance.md |
Performance engineer — bottlenecks, memory leaks, optimization opportunities |
business |
.claude/commands/review/business.md |
Business analyst — requirements, logic, business-value alignment |
qa |
.claude/commands/review/qa.md |
QA engineer — edge cases, race conditions, failure modes |
devops |
.claude/commands/review/devops.md |
DevOps engineer — deployment readiness, error handling, logging, infra |
all |
.claude/commands/review/all.md |
Runs every profile and produces a unified report |
Invoke a profile inside Claude Code with /review:<profile> (for example, /review:security or /review:all). Each profile defines the review scope, the output format, and the severity bands — so repeated runs produce comparable reports.
| Command | Description |
|---|---|
pnpm test |
Run all unit tests once (vitest run) |
pnpm test:watch |
Run unit tests in watch mode (vitest) |
pnpm exec vitest run --config vitest.integration.config.ts |
Run integration tests (requires a running PostgreSQL database) |
pnpm exec cypress open |
Open Cypress interactive runner (requires the app running on port 5000) |
pnpm exec cypress run |
Run Cypress tests headlessly |
To generate a coverage report, run:
pnpm exec vitest run --coverageThis uses @vitest/coverage-v8 and enforces the 80% thresholds defined in vitest.config.ts for that coverage run. If any metric falls below 80%, the coverage command will fail.
Integration tests require a real PostgreSQL database. Set the following environment variables:
export DATABASE_URL="postgresql://user:password@localhost:5432/testdb"
export ALLOW_DEV_AUTH_BYPASS="true"
export ENCRYPTION_KEY="01234567890123456789012345678901"Then push the schema and run:
npx drizzle-kit push
pnpm exec vitest run --config vitest.integration.config.ts# Run a specific test file
pnpm exec vitest run server/routes/__tests__/workouts.test.ts
# Run tests matching a pattern
pnpm exec vitest run -t "should return 404"
# Run in watch mode for a specific file
pnpm exec vitest watch server/services/workoutService.test.ts- Timezone issues: CI runs in UTC. Use
toDateStr()helper instead ofnew Date().toISOString()for date comparisons. - Rate limiting: Tests that hit the same endpoint rapidly may trigger rate limits. Use
clearRateLimitBuckets()fromserver/routeUtils.tsinbeforeEach. - Database state: Integration tests share a real database. Always use
clearDatabase()in setup. Check for leaked state from parallel test runs. - Missing env vars: CI may not have all optional env vars. Tests that depend on
GEMINI_API_KEYshould be conditional or mocked.
Coverage thresholds are configured in vitest.config.ts:
coverage: {
provider: "v8",
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
}
}Run coverage locally: pnpm exec vitest run --coverage
CI does not run coverage by default. Add an explicit coverage workflow or step before treating these thresholds as merge-gating.
- Build and start the app:
pnpm run build NODE_ENV=test ALLOW_DEV_AUTH_BYPASS=true node dist/index.js
- In a separate terminal:
pnpm exec cypress open
project-root/
vitest.config.ts # Unit test configuration
vitest.setup.ts # Unit test global setup
vitest.integration.config.ts # Integration test configuration
vitest.integration.setup.ts # Integration test global setup
vitest.smoke.config.ts # Production smoke test configuration
cypress.config.ts # Cypress E2E configuration
shared/
schema.test.ts # Zod schema validation tests
unitConversion.test.ts # Unit conversion utility tests
server/
clerkAuth.test.ts # Auth middleware tests
crypto.test.ts # Encryption utility tests
emailScheduler.test.ts # Email scheduler tests
emailTemplates.test.ts # Email template tests
gemini.test.ts # Gemini AI client tests
routeUtils.test.ts # Route utility tests
strava.test.ts # Strava integration tests
types.test.ts # Type helper tests
gemini/
chatService.test.ts # AI chat service tests
exerciseParser.test.ts # AI exercise parser tests
exerciseParser.image.test.ts # AI image parser tests
suggestionService.test.ts # AI suggestion tests
routes/
__tests__/ # Route unit tests (mocked dependencies)
testUtils.ts # Shared test app factory
ai.test.ts
analytics.test.ts
auth.test.ts
coaching.test.ts
email.test.ts
plans.test.ts
preferences.test.ts
workouts.test.ts
routeRegistration.smoke.test.ts # Route-registration smoke test
tests/ # Integration tests (real database)
helpers.ts # Integration test setup helper
api.integration.test.ts
post-migration.integration.test.ts
services/
aiEval.test.ts # AI evaluation tests
aiService.test.ts # AI service tests
analyticsService.test.ts # Analytics calculations
coachService.*.test.ts # Coaching service behavior groups
coachService.testSetup.ts # Coaching service test harness
exportService.test.ts # CSV/JSON export tests
planService.test.ts # Training plan tests
ragService.test.ts # RAG service tests
stravaMapper.test.ts # Strava data mapping tests
workoutService.test.ts # Workout CRUD tests
storage/
__tests__/
plans.test.ts # Plan storage tests
workouts.test.ts # Workout storage tests
users.test.ts # User storage tests
utils/
sanitize.test.ts # Input sanitization tests
client/src/
components/
OnboardingWizard.test.tsx # Onboarding wizard tests
__tests__/
ChatMessage.test.tsx # Chat message component
ExerciseInput.test.tsx # Exercise input component
RpeSelector.a11y.test.tsx # RPE selector accessibility
timeline/
__tests__/
TimelineFilters.test.tsx # Timeline filter component
hooks/
__tests__/
useBlockCounts.test.ts # Block counts hook
useChatSession.test.tsx # Chat session hook
useCombineWorkouts.test.tsx # Combine workouts hook
usePlanImport.test.tsx # Plan import hook
useTimelineFilters.test.ts # Timeline filters hook
useUnitPreferences.test.tsx # Unit preferences hook
useVoiceInput.test.ts # Voice input hook
useWorkoutActions.test.tsx # Workout actions hook
useWorkoutEditor.test.ts # Workout editor hook
useWorkoutForm.test.tsx # Workout form hook
lib/
api/
client.test.ts # API client tests
workouts.test.ts # Workouts API tests
authUtils.test.ts # Auth utility tests
dateUtils.test.ts # Date utility tests
exerciseUtils.test.ts # Exercise utility tests
exerciseWarnings.test.ts # Exercise warnings tests
queryClient.test.ts # React Query client tests
statsUtils.test.ts # Statistics utility tests
utils.test.ts # General utility tests
cypress/
e2e/ # E2E spec files
analytics.cy.ts
api-validation.cy.ts
coach-chat.cy.ts
emom-rollout-config.cy.ts
landing.cy.ts
log-workout.cy.ts
log-workout-submission.cy.ts
navigation.cy.ts
onboarding.cy.ts
plan-generation.cy.ts
settings.cy.ts
timeline.cy.ts
support/
commands.ts # Custom Cypress commands (getBySel)
e2e.ts # Global hooks and Clerk intercepts
authIntercepts.ts # API stub helper for authenticated tests
- Co-located tests: Utility and service files place their
.test.tsfile alongside the source file (e.g.,server/crypto.tsandserver/crypto.test.ts). __tests__/directories: Used when a directory contains multiple test files for a module group (e.g.,server/routes/__tests__/,client/src/hooks/__tests__/).- Integration tests: Named with the
.integration.test.tssuffix and excluded from the unit test config. - Cypress specs: Named with the
.cy.tssuffix incypress/e2e/. - Test data factories: Helper functions like
makeSet()andmakeWorkoutLog()are defined at the top of test files to build test data with sensible defaults and per-test overrides. data-testidattributes: Used in React components for Cypress selectors viacy.getBySel().
See also: Server -- Route Registration, Database -- Storage Layer