Testing
Five suites, one per runtime boundary. Each app owns its own runner and config — there is no top-level test command.
| Suite | Location | Runner | Touches a real DB? |
|---|---|---|---|
| Data engine | data-engine/tests/ | pytest | No — scripted fake DB |
| API unit | apps/api/tests/unit/ | bun test | No |
| API integration | apps/api/tests/integration/ | bun test | Yes — dedicated Neon branch |
| Web unit | apps/web/tests/unit/ | Vitest | No — MSW mocks the API |
| Web e2e | apps/e2e/tests/smoke/ | Playwright | No — fixture API server |
Test files are never co-located with source. They live under the app’s tests/ directory (or
apps/e2e/tests/), mirroring the path of the file under test — e.g. tests/unit/common/mappers.test.ts
covers src/common/mappers.ts.
Running everything
# data-engine
cd data-engine && pytest -v
# api (needs TEST_DATABASE_URL in apps/api/.env for the integration suite)
cd apps/api && bun test # unit + integration
cd apps/api && bun run test:unit # unit only
cd apps/api && bun run test:integration
# web
cd apps/web && bun run test # vitest run
# e2e (spins up its own servers)
cd apps/e2e && bun run test
Data engine (data-engine/tests/)
pytest, config in pyproject.toml (pythonpath = ["."], testpaths = ["tests"]). Dev deps:
requirements-dev.txt (= requirements.txt + pytest).
- Pure functions are tested directly —
math_utils.py(normalize_minmax,softmax,bayesian_win_rate, …), purefeature_helpers.pyhelpers, the modelWEIGHTSinvariants (test_weights.py— sum-to-1 and positivity for both models),rank_by_probability,race_weekend_window. - DB-touching functions are tested through a fake double —
tests/support/fake_db.pyprovidesFakeCursor/FakeConnection, shaped like psycopg2’sRealDictCursor.FakeConnectiontakes a list of result sets and hands one out perwith conn.cursor()block, in call order. This coverscompute_weather_score,compute_luck_score,build_feature_context,build_driver_code_map, andingest_runner.py’s two shared runners (run_ingest_job,run_qualifying_ingest_job) — the latter via monkeypatched per-job callables and FastF1 helpers, no real session data needed. The same double coversdata_quality_audit._audit_race(per-status gate/threshold branching),data_quality_repair.run(issue grouping + resolve/rollback, with the owning jobs mocked),main.py’sauto_detect_*helpers, and — with FastF1’s schedule/session mocked —sync_scheduleandsync_season. conftest.pysets a placeholderDATABASE_URLso importing a job module (which reads it at import time) works with or without a local.env. Tests never open a real connection.upsert.pyandprediction_runner.pywrite viapsycopg2.extras.execute_batch, which renders SQL through a C extension that needs a real connection even just to quote identifiers — so their tests (test_upsert.py,test_prediction_runner.py) monkeypatchexecute_batchitself and assert on the params it receives, the same patterntest_ingest_runner.pyalready uses for its ownexecute_batchcall, rather than extendingFakeCursorto fake SQL rendering.
API unit (apps/api/tests/unit/)
bun test, no DB, no network. Mirrors src/ by domain — mostly the shared src/common/ transform
layer (mappers, collections, standings, prediction-response, prediction-history,
accuracy, cache, featureManifest) plus module-local pure helpers
(modules/predictions/intel-standings.helpers, modules/races/circuit-era.helpers,
modules/races/circuit-stats.helpers, modules/quality). Fixtures are plain objects typed as
typeof <table>.$inferSelect; see
prediction-response.test.ts for the pattern.
Anything pure that a service delegates to — row→DTO mapping, aggregation, normalisation — belongs
here. If a service method is hard to unit-test, that’s usually a sign the pure part should be
extracted into common/ or a *.helpers.ts (this is what the max-lines budget nudges toward).
API integration (apps/api/tests/integration/)
bun test driving the real Hono app in-process via app.request() against a dedicated Neon test
branch.
TEST_DATABASE_URL(inapps/api/.env, see.env.example) — a distinct env var fromDATABASE_URL.tests/support/db/test-db.tsrefuses to truncate if the two are equal (override withTEST_DB_ALLOW_RESET=1). MissingTEST_DATABASE_URLthrows with instructions.tests/support/app/request.ts—apiRequest(path, init?)callsapp.request()with aBindingsenv pointing at the test DB.tests/support/factories/— one insert helper per table, each takingPartial<$inferInsert>overrides and returning the inserted row. FK chain:season → team/circuit → driver → race → results/predictions.- Lifecycle — each test file calls
truncateAll(db)inbeforeAllandafterAll, seeds its own fixtures (typically under a sentinel year like2097/2099), and asserts on the JSON response.truncateAllis a singleTRUNCATE … RESTART IDENTITY CASCADEover every table. - Covered:
races,drivers,teams,seasons,search,predictions,sprint. Not yet:quality.
Web unit (apps/web/tests/unit/)
Vitest, config in vitest.config.ts (include tests/unit/**/*.test.{ts,tsx}, @ alias, setup file).
- Default environment is
node— pure logic runs there:lib/predictionMath.ts(all exports),lib/teamColors/lib/teamLogos,features/compare/compareStats.ts,features/predictions/{buildPredictionPageData,predictionCopy}(API mocked withvi.mock, not MSW). - jsdom is opt-in per file via a
// @vitest-environment jsdomdocblock on line 1 — used for hook/component tests (useCompareController,useGlobalSearch,GlobalSearch) with Testing Library. tests/support/setup.ts— jest-dom matchers, aResizeObserverstub (cmdk needs it), and the MSW server lifecycle (onUnhandledRequest: 'error', reset between tests).tests/support/msw/—handlers.ts+fixtures.tsmock thesrc/lib/api.tsendpoints athttp://localhost:8787. Client-side fetches in the components under test hit these.- Not covered:
.astrocomponent rendering.
Web e2e (apps/e2e/)
Playwright, its own package.json and playwright.config.ts (see
docs/adr/0001-apps-layout-scoped-to-js-bun.md for why it isn’t nested under apps/web/).
webServerstarts two processes: the fixture API server (fixtures/server.ts,Bun.serveon:4310, serves{ data, error: null }+ CORS headers for the routes under test) andastro devforapps/webon:4321withPUBLIC_API_URLpointed at the fixture server.astro previewisn’t used — the@astrojs/cloudflareadapter doesn’t support it — so the SSR frontmatter runs under Vite’s dev server instead. Playwright can’t intercept the server-side fetches Astro makes in frontmatter, which is why a real fixture HTTP server is needed rather than request mocking.- The fixture server sends CORS headers because
GlobalSearch/DriverCompareTool/TeamCompareToolfetch client-side — a genuine cross-origin browser request. Every other route is only ever hit server-side. - Smoke specs cover
/prediction,/races/[id],/races/[id]/sprint,/drivers/[id],/teams/[id],/drivers/compare,/teams/compare,/prediction/sprint/[id], and the global search palette — each asserts key content renders and no “failed to load” appears.
CI (.github/workflows/test.yml)
Four jobs, on every push to master and every PR:
| Job | Steps |
|---|---|
data-engine | pip install -r requirements-dev.txt → pytest -v |
api | bun install → db:migrate (if DATABASE_URL secret set) → bun test → bun run typecheck → bun run lint |
web | bun install → bun run build → bun run test → bun run typecheck → bun run lint |
e2e | bun install (web + e2e) → playwright install chromium → bun run test |
The api job runs the integration suite against secrets.TEST_DATABASE_URL; if that secret is
absent the suite still runs but integration files fail fast on the missing-env guard.
Adding a test
- Pure helper (either app) →
tests/unit/, mirror the source path. Prefer extracting the pure part over testing a DB-bound method. - New API endpoint / query → an integration file under
tests/integration/<domain>/; add a factory if a table has none;truncateAllinbeforeAll/afterAll. - New interactive web island → a
jsdomVitest file; add MSW handlers for any endpoint it calls; consider an e2e smoke spec + a fixture route if it’s a whole page. - New ETL scoring function → pytest; pure if possible, otherwise script
FakeConnectionwith one result set per query the function issues.