The test pyramid, as built
Unit tests: validation & status logic
The pure functions:getMatchStatus (derives scheduled/live/finished from timestamps, including boundary cases like “now exactly equals start”) and the Zod schemas (valid + invalid inputs, coercion, the endTime > startTime rule). No DB, no network: fast and deterministic.
Integration tests: routes against real Postgres
These drive the real Express app with Supertest, against a real database (not a mock). Mocking the DB would mean testing the mock, not the SQL.createApp()is built withoutserver.listen()so Supertest can drive it in-memory (see Backend).resetDb()runsTRUNCATE ... RESTART IDENTITY CASCADEin abeforeEach, so every test gets a clean, deterministic database with IDs starting at 1.- The WS broadcast functions are replaced with
vi.fn()spies to assert the route calls them correctly, without needing a live socket.
protect() makes a real network call; a setup file (tests/setup/mock-arcjet.ts) mocks it to always allow (and sets test env vars before any import, because arcjet.ts throws at import time if ARCJET_KEY is missing).
WebSocket tests: real socket pairs
These start a realhttp.Server on port 0 (OS-assigned free port), attach the real WS server, and connect real ws clients. They verify: welcome on connect, subscribe/unsubscribe confirmations, malformed-JSON handling, room-scoped broadcast (subscribers of match 1 get it, subscribers of match 2 and unsubscribed clients don’t), and the Arcjet deny path (overriding the global mock for one call).
Two real bugs were found by writing these tests: a message race and a missing close() causing hung timers. Both are post-mortemed in Issues.
End-to-end tests: Playwright against the real UI
These drive the actualsportz-ui in a real Chromium browser: load the page, click “Watch Live,” assert the commentary panel fills, toggle dark mode, page through matches, scan for accessibility violations. They verify the thing the backend tests can’t: that a user sees and does the right thing.
- Mocked at the network boundary. REST is stubbed with
page.route, the WebSocket withpage.routeWebSocket. List/scale data is generated from a template (Array.from({ length: 8 }, ...)). So the suite is deterministic and offline: it never depends on the cold free-tier backend, whose latency would otherwise silently change what renders. - Runs against a production build, on its own port.
webServerrunsnpm run build && npm run start -- -p 3100withreuseExistingServer: false.next devcompiles routes on first request, so underfullyParallelseveral cold compiles serialize and blow past the 30s test timeout, producing flaky failures that shift between runs and vanish when you run files individually. A prod build serves pre-compiled routes instantly (reliable in parallel) and is the exact build CI and Vercel ship. The dedicated port (separate fromnext dev’s 3000) lets the dev server and test server coexist with no collision. - Accessibility is part of the suite.
@axe-core/playwrightscans the settled page (after the entry animation reachesopacity: 1, to avoid mid-animation false positives) forwcag2a/wcag2aaviolations. Writing this caught two real contrast bugs: a theme-token that wasn’t theme-aware and a status pill that broke the established-700-on--50pattern (see Issues). - First-run UI is kept out of the way. The onboarding tour auto-opens on a first visit, and Playwright starts each spec with fresh
localStorage, so it would pop up in every test, cover the page with an overlay, and break the a11y scan + Watch-Live clicks. Two guards: the app skips the auto-tour under automation (navigator.webdriver), and the mock setup pre-sets the “seen” flag viapage.addInitScript(deterministic belt-and-suspenders). The lesson generalizes: any first-run popup/overlay must be suppressible in automated runs.
Deployed smoke: the live stack (post-deploy)
One spec (e2e/smoke.deployed.spec.ts, tagged @deployed) runs against the real deployed stack: the Vercel frontend talking to the Render backend, no mocks. It answers the one question mocking can’t: is the deployed system actually wired together? Four read-only checks: the app loads → a real match renders (REST + CORS + baked API URL) → the WebSocket connects (the wss:// upgrade through Render’s proxy) → Watch Live opens the commentary panel.
- It’s the top rung of the realness ladder (mocked → … → deployed). Each rung answers its own question; we don’t make one tier carry two jobs.
- Read-only and presence-based. It never mutates production and never asserts exact data (real data changes); it only checks that things are present and connected. Generous timeouts absorb Render’s free-tier cold start.
- Excluded from CI and the normal suite.
npm run test:e2euses--grep-invert @deployed; the deployed spec runs only vianpm run test:e2e:deployed(which setsPLAYWRIGHT_NO_SERVER=1+PLAYWRIGHT_BASE_URL). It’s a post-deploy check, not a per-push gate: it hits live prod and depends on a cold server. Automating it after a Vercel deploy is a documented next step (DevOps).
Running the tests
postgres:16-alpine service container: fast, free, deterministic, and identical to the local throwaway DB. See DevOps.
Not yet built
- Contract testing between frontend and backend types.
- Load testing (k6/Artillery) of the WS server under thousands of concurrent subscribers, relevant before any real scale.
- Visual regression, relevant once the UI stabilizes.