End-to-End Regression Testing Strategy¶
Status: Proposal — for team discussion. 2026-08-25. Scope: the NestJS monolith (this repo, Railway) + the micro-frontends (Cloudflare Pages, dev/prod duplicated). Problem: no QA team; regressions between BE and FE are found by hand or by users. We need automatic, trustworthy regression coverage that a small team can sustain.
0. TL;DR¶
We already own the hard part: 400 unit specs + 102 API-level e2e specs running against a real Postgres, per-worker DB clones, migration drift gate, deterministic seed with one user per role. That is the domain regression suite. What is missing is narrow and specific:
- Nothing catches FE↔BE contract drift before it ships. Our history is full of
*-BREAKING.mdguides — every one of them is a regression class a CI gate could catch at compile time. - Nothing exercises the composed micro-frontends against the backend. The only "full stack" test is a human clicking around on dev.
- Nothing watches production. With no QA, prod is our last line and it is silent.
Proposal, in ROI order — each step is independently useful and shippable:
| # | Layer | Tool | Catches | Effort |
|---|---|---|---|---|
| 1 | API contract gate | OpenAPI snapshot + oasdiff in BE CI; generated TS client in MFEs |
Renamed/removed fields, enum changes, route changes — before merge | ~1–2 days |
| 2 | Browser E2E, golden paths only | Playwright, ~10–25 journeys, dedicated e2e env |
"The app doesn't work" for a real user in a real browser | ~1 week for the first 5 journeys, then incremental |
| 3 | Bidirectional triggers | GitHub Actions + Railway/Cloudflare deploy hooks | BE change breaking FE and FE change breaking on real BE | ~1–2 days |
| 4 | Production canary | Same Playwright journeys, 3–5 of them, every 15 min vs a synthetic prod tenant | Outages and prod-only misconfig, minutes after they happen | ~1 day |
Guiding rule: testing trophy, not pyramid. Many API tests (done), a thin browser layer, a contract gate between the two. Teams without QA who build a fat browser suite drown in flake maintenance within six months. The discipline is keeping layer 2 small.
1. Where we are today¶
Backend (this repo)¶
| Asset | State |
|---|---|
| Unit specs | 400 *.spec.ts (services, controllers, DTOs, policies) |
| API e2e | 102 test/*.e2e-spec.ts via Supertest, full Nest app + real Postgres |
| E2E isolation | test/global-setup-e2e.cjs migrates + seeds a template DB, clones one DB per Jest worker (CREATE DATABASE … TEMPLATE) every run — zero cross-run pollution (ch09 §7) |
| Seed | Two-tier, deterministic. NODE_ENV=local = full destructive seed: four tenants, admin/teacher/referent/student users, RBAC catalogue (ch15) |
| CI | .github/workflows/ci.yml on every PR + pushes to stage: audit → prisma generate → migration drift check → lint → build → unit → e2e. dev/main skip CI and rely on Railway's deploy pipeline (ch10 §9) |
| Deploy | Railway, RAILPACK builder (no Dockerfile), preDeployCommand = migrate deploy && db seed, health check /api/v1/health/ready. Branch → env: dev → development, main → production |
| API surface | Swagger generated from controllers + *.swagger.ts; served at /docs, JSON at /docs-json |
| Auth | HttpOnly Secure; SameSite=None cookies (FE and BE on different domains); Bearer also accepted (ch03) |
Micro-frontends¶
- Several MFE repos + a shell, deployed to Cloudflare Pages, one project per env (dev / prod).
- Cloudflare Pages already produces a preview deployment per branch/PR for free — an asset we are not using yet.
- Test coverage on the FE side: to be filled in by the FE team (component tests? none?).
Gaps¶
| Gap | Consequence today |
|---|---|
| No FE↔BE contract check | BE renames a field → FE breaks on dev → someone notices, or doesn't |
| No composed-system test | Shell + remotes + BE + cookies + CORS is only verified by hand |
| No controllable "today" in the BE | Attendance / academic-year journeys can't be made deterministic end-to-end |
| No deterministic hosted environment | dev is shared and mutable; anything automated against it is flaky by construction |
| No prod monitoring beyond Railway health | A broken login on prod is discovered by a school |
2. Principles¶
- Test each thing at the cheapest layer that can catch it. Business rules → unit/API specs (already). Shape drift → contract gate. "Does the button work" → browser. Never re-test through the browser what the API suite already proves.
- Browser tests are golden paths, not coverage. Target 10–25 journeys total, each answering "can a
<role>still do<critical thing>". If a journey isn't something a school would call us about within an hour of it breaking, it doesn't belong here. - Arrange via API, assert via UI. Log in through
POST /auth/login, create fixtures through the BE API, then drive only the journey under test through the browser. Clicking through setup screens to reach the screen you actually test is the #1 source of slow, brittle suites. - Deterministic data, every run. Fresh seed, never shared mutable state. We already do this locally and in CI; the hosted
e2eenvironment must do the same. - Flake is a bug with an owner. A flaky test is quarantined the day it flakes and fixed or deleted within a week. Retries hide problems; they don't fix them.
- Mirror production topology. Cross-domain cookies, CORS, Cloudflare in front, Railway behind. A suite that runs against
localhostproves less than one that runs against real hosts.
3. Layer 1 — API contract gate (start here)¶
Why first: cheapest, fastest, and it targets our single most frequent regression class. Every entry in docs/fe-guides/*-BREAKING.md would have been a red CI check.
3.1 Backend side¶
- Export the OpenAPI document as a build artifact. A small script (
tools/export-openapi.ts) boots the Nest app without listening, runsSwaggerModule.createDocument(app, buildSwaggerConfig())— the same callmain.tsmakes — and writesopenapi/openapi.json. Commit the file; it is a snapshot of the public contract at that commit. - Diff on every PR. Add a CI step: regenerate from the PR branch, run
oasdiff breakingagainst the committed snapshot on the base branch. Exit non-zero on any breaking change (removed path/field, narrowed enum, required-ness change, type change). - Acknowledged breaks are allowed, silent ones are not. A PR labelled
api:breakingpasses the gate only if it also touches the authoritative FE guide underdocs/fe-guides/(we already treat a new*-BREAKING.mdas a failure mode — the guide is updated in place). The gate makes the existing convention enforceable. - Serve the spec from every environment.
/docs-jsonalready exists. The FE tooling below pulls fromhttps://<be-dev>/docs-json.
# .github/workflows/ci.yml — sketch
- name: OpenAPI contract check
run: |
npx ts-node tools/export-openapi.ts --out /tmp/openapi.pr.json
git show origin/${{ github.base_ref }}:openapi/openapi.json > /tmp/openapi.base.json
npx oasdiff breaking /tmp/openapi.base.json /tmp/openapi.pr.json --fail-on ERR
3.2 Frontend side¶
- Generate the API client from the spec (
openapi-typescriptfor types only, ororvalfor typed fetchers). Commit the generated file in each MFE. - FE CI regenerates against the latest
devspec and type-checks. A BE rename that landed ondevnow breaks the FE build, not the user. The diff of the generated file in the PR is the change review. - Hand-written response interfaces in MFEs are deleted as they get replaced. No parallel truth.
3.3 What about Pact / consumer-driven contracts?¶
Pact is the heavyweight industry standard for this. It shines when consumers and providers are owned by different teams with different release cadences. For one BE and a handful of MFEs owned by the same team, the OpenAPI schema diff gets ~80% of the value at ~10% of the ceremony. Revisit if a third party ever consumes our API.
4. Layer 2 — Browser E2E with Playwright¶
4.1 Why Playwright¶
Industry default since ~2022: free parallelism (no paid cloud needed for sharding), trace viewer for post-mortem on CI failures, auto-waiting (kills most sleep-driven flake), multi-browser, first-class storageState for per-role auth, page.clock for FE time control, and an APIRequestContext for the arrange-via-API pattern. Cypress is fine but pays for parallelism and is Chromium-centric; Selenium is not a serious option for a new suite in 2026.
4.2 Where the suite lives¶
A dedicated sis-e2e repository. It spans the BE and every MFE, so it belongs to none of them. Both sides trigger it (see §5). Alternative: inside the shell repo — acceptable if the shell is the only composition point, but ownership gets muddy when a BE change breaks it.
4.3 Conventions¶
- Auth via API, once per role. A
global-setuplogs each seeded role in throughPOST /api/v1/auth/loginwith anAPIRequestContext, saves the resulting cookies asstorageState/<role>.json. Every test starts authenticated in ~0 ms. Seeded users must be single-tenant/single-view so login step 1 is terminal; multi-view users (teacher+staff) get the view-selection step scripted explicitly in the helper. - Arrange via API. A thin
api/module wrapping the BE calls we need for fixtures (create student, create lesson, …), typed from the same generated client as the MFEs. - Locators by role/label/
data-testid, never by CSS structure. MFEs adddata-testidon interactive elements that journeys need; that is part of the FE definition of done for a feature that gets a journey. - No
waitForTimeout. Ever. Auto-wait + explicitexpect(...).toBeVisible(). - One journey = one file, named after the user story it protects, tagged by role (
@admin,@teacher,@referent,@student) and by tier (@smokefor the PR/canary subset, everything for nightly). - Test data prefix. Every entity a test creates carries a
e2e-<run-id>-prefix in its name so a failed run's leftovers are identifiable and sweepable, even though thee2eenv is reset on every deploy. - Time. FE side:
page.clock.setFixedTime(...). BE side: see prerequisite in §7 — until a test clock exists, attendance/AY journeys are written against "today" and accept that they are calendar-sensitive.
4.4 Initial journey list (~12, @smoke marked ★)¶
| # | Journey | Role | Protects |
|---|---|---|---|
| 1 ★ | Login → landing; nav/blocks match /permissions (empty-not-absent hide contract) |
each seeded role | Auth, RBAC → FE visibility |
| 2 ★ | Session view switch (teacher ↔ staff) | teacher+staff user | POST /auth/switch-view + shell re-render |
| 3 ★ | Admin creates Department → Grade → Curriculum row | admin | Config CRUD, cascade UI |
| 4 | Admin creates Academic Year (DRAFT) and patches gracePeriodEnding |
admin | AY lifecycle |
| 5 | Setup wizard happy path on a tenant left in SETUP by the seed | admin | Setup state machine end-to-end |
| 6 | Student import from .xlsx → row appears in table |
admin | File upload, import pipeline, table lists |
| 7 ★ | Teacher marks attendance for today's lesson → counters update | teacher | Attendance register (calendar-sensitive until test clock) |
| 8 ★ | Referent sees child's absence → submits justification → office sees it | referent, admin | Family loop, notifications inbox |
| 9 | Teacher enters a grade → revises it → history visible | teacher | Grades ledger |
| 10 | Admin creates timetable → diagnostics → publish | admin | Timetables + publish gate |
| 11 | Admin sends communication → recipient sees it in inbox | admin, teacher | Communications + Resend memory/log transport on e2e |
| 12 | Disciplinary note → family ack | teacher, referent | Disciplinary notes |
| 13 | Backoffice: platform admin provisions a tenant + first admin | platform admin | Onboarding flow |
Each journey takes a target of < 60 s and the whole @smoke set < 5 min.
4.5 Environment topology¶
We need a hosted environment that is (a) deterministic, (b) shaped like prod (Cloudflare → Railway, cross-domain cookies), (c) never used by humans.
Recommended: a dedicated e2e environment.
- Railway: a third environment
e2e(BE service + Postgres), auto-deployed fromdevlikedevelopmentis. Its seed runs in full-reset mode on every deploy (see §7 prerequisite — today reset mode is tied toNODE_ENV=local; we add an explicitSEED_MODE=full-resetoverride so the app can still run with the hostedNODE_ENV).CORS_ORIGINlists the e2e MFE origins.MAIL_TRANSPORT=logormemory— no real email leaves this env. - Cloudflare: an
e2evariant of the shell (and remotes, if the BE URL is baked at build time) pointed at thee2eBE. This is the same dev/prod duplication mechanism we already run, applied a third time. If the shell can take the BE URL at runtime (window.__ENV__, query param), a single dev build suffices and no MFE duplication is needed — worth checking. - Reset before every run, not only on deploy. The E2E workflow triggers a reseed via Railway CLI (
railway run --environment e2e -- npx prisma db seedwithSEED_MODE=full-reset) so two runs never see each other's data. A GitHub Actionsconcurrencygroup serializes runs against the env.
Alternative considered — ephemeral compose inside GitHub Actions (Postgres service container + BE started from source + MFEs built from source). Pros: parallel PR runs, no shared env. Cons: needs every MFE repo checked out or a published build, does not exercise Cloudflare / cross-domain cookies / CORS, slower per run. Keep as a fallback if the hosted env proves unreliable; the suite itself is identical either way — only baseURL changes.
Alternative considered — Railway PR environments. Railway can fork an ephemeral environment per PR (incl. a Postgres copy). Gives true per-PR BE isolation for BE PRs. Costs per-PR compute; worth enabling once the suite is stable and the BE-PR trigger (§5) is the bottleneck.
4.6 Flake policy¶
retries: 1in CI only,trace: 'on-first-retry',video: 'retain-on-failure'. Locallyretries: 0so flake is felt.- Playwright's HTML report + traces uploaded as workflow artifacts on failure; the failing trace link goes in the PR/Slack message, not "tests failed".
- A test that fails without a code change gets
@quarantinethe same day; quarantined tests run but don't fail the build, and the tag carries a date. Quarantine older than 7 days is deleted in the weekly review, not "fixed later". - You broke it, you fix it. The author of the change that reddened the suite owns the fix, whichever repo the fix lands in.
5. Layer 3 — Triggers (bidirectional)¶
Our branch model: BE PRs run CI; merges to dev deploy to Railway with no CI; MFE branches get Cloudflare previews. The trigger matrix uses what each platform already gives us.
| Event | What runs | Against | How |
|---|---|---|---|
| BE PR | Unit + API e2e (today) + contract gate (new) | CI Postgres | Existing ci.yml |
BE merge → dev |
Playwright @smoke |
e2e env (auto-deployed from dev) |
BE repo workflow on push: dev waits until /health/ready on e2e reports the pushed commit SHA (see §7), then repository_dispatch → sis-e2e |
| MFE PR | Playwright @smoke |
shell e2e + PR preview URL for that remote + BE e2e |
MFE CI passes its Cloudflare preview URL; shell must support a remote-override (see §7) |
| MFE merge → dev | Playwright full | e2e |
Cloudflare deploy hook / MFE workflow → repository_dispatch |
| Nightly 03:00 | Playwright full, Chromium + Firefox + WebKit | e2e |
schedule in sis-e2e |
| Prod deploy + every 15 min | Canary (§6) | prod, synthetic tenant | schedule in sis-e2e (or Checkly) |
PRs run Chromium only; the nightly covers the other engines. Results post to a Slack channel; a red nightly is triaged first thing in the morning by whoever is on rotation.
6. Layer 4 — Production canary¶
With no QA and no on-call, production needs to shout for itself.
- Synthetic tenant on prod, provisioned once through the backoffice like a real school, with a fixed admin/teacher/referent. Never a real school's data.
- 3–5 read-mostly journeys from the
@smokeset: login per role,/permissions-driven nav, open a student, open today's attendance board, open the inbox. Any write must be idempotent and self-cleaning (e.g. create + delete a disciplinary note). - Runs every 15 min and immediately after each prod deploy. Two consecutive failures → Slack alert with the trace link.
- Options: our own
sis-e2eworkflow onschedule(free, 15-min floor on GitHub cron, jittery), or Checkly which runs Playwright natively with proper alerting and multi-region. Start with the workflow; move to Checkly if alert latency matters.
7. Prerequisites — small changes each side needs to make¶
| Where | Change | Why |
|---|---|---|
| BE | tools/export-openapi.ts + committed openapi/openapi.json + oasdiff CI step |
Layer 1 |
| BE | SEED_MODE=full-reset env override (seed today keys reset mode off NODE_ENV=local) |
Deterministic hosted e2e env without pretending to be local |
| BE | Expose the build commit (Railway injects RAILWAY_GIT_COMMIT_SHA) in /health/ready |
Lets the BE-merge trigger wait for the right deploy instead of sleeping |
| BE | Test clock: an injectable Clock (now()) defaulting to the system clock, overridable on non-production envs via env var or a header honoured only when NODE_ENV≠production |
Attendance / AY / grace-period journeys become deterministic. Today there is no such abstraction (new Date() is called inline) |
| BE | A seed tenant left in SETUP state |
Journey #5 |
| BE | CORS_ORIGIN + MAIL_TRANSPORT for the e2e Railway env |
§4.5 |
| Shell | Remote-override mechanism (query param / localStorage key that points one remote at an arbitrary URL, enabled on non-prod builds only) |
MFE PR previews composed into the shell (§5) |
| Shell / MFEs | Runtime-configurable BE URL, if not already | Avoids a third build per MFE for e2e |
| MFEs | data-testid on interactive elements a journey needs; generated API client replaces hand-written types |
§3.2, §4.3 |
| MFEs | Component tests (Vitest + Testing Library) per repo, if absent | The middle of the trophy on the FE side; Playwright must not be the only FE test |
| New repo | sis-e2e: Playwright, storageState per role, api/ arrange helpers, workflows for dispatch / schedule / canary |
Layer 2–4 |
None of these is large. The test clock is the only one that touches domain code and it should go through the normal design-gate as a cross-cutting concern.
8. Rollout¶
| Phase | Deliverable | Exit criterion |
|---|---|---|
| 1 — Contract (week 1) | OpenAPI export + snapshot + oasdiff gate in BE CI; one MFE consumes the generated client |
A deliberate field rename on a branch turns the BE PR red; FE build fails against the renamed spec |
| 2 — Environment (week 1–2) | Railway e2e env with reset-on-deploy; Cloudflare e2e shell; SEED_MODE + health SHA in BE |
A human can open the e2e shell, log in as every seeded role, and the data is identical after a redeploy |
| 3 — First journeys (week 2–3) | sis-e2e repo, storageState per role, journeys 1–3 (@smoke), nightly schedule |
Green nightly for 5 consecutive nights |
| 4 — Triggers (week 3–4) | BE-merge dispatch with SHA wait; MFE-PR dispatch with remote override | A BE merge to dev produces a Slack result within 10 min; an MFE PR shows a Playwright check |
| 5 — Breadth (ongoing) | Journeys 4–13, one per sprint alongside the feature work that touches that area; test clock lands and 7/8 go deterministic | @smoke < 5 min, full < 20 min, flake rate < 2% over 30 days |
| 6 — Canary (after phase 3 is stable) | Synthetic prod tenant + 15-min schedule + Slack alert | A forced prod misconfig (wrong CORS_ORIGIN) is alerted within 30 min |
Rule for phase 5 onward: a feature that changes a golden path updates its journey in the same PR (or in the linked sis-e2e PR), exactly as it updates the FE guide today.
9. What we deliberately do not do¶
- Mirror the API suite in the browser. RBAC matrices, validation errors, edge cases, temporal rules — all stay in
test/*.e2e-spec.ts. The browser only proves the happy path renders and submits. - Run automation against
development. Shared with humans, mutable, non-deterministic.e2eis separate for a reason. - Screenshot/visual regression at the start. Attendance boards and timetables are date-driven; without a test clock every screenshot rots on a calendar boundary. Revisit for a few static pages once the clock exists.
- Record-and-playback / low-code test tools. Unmaintainable without a QA owner; brittle selectors by construction.
- MFE-to-MFE contract tests beyond the shared event/prop surface the shell exposes. The composed browser journey covers integration; per-remote unit/component tests cover behaviour.
- Large retries.
retries: 1in CI is a tripwire for infra blips, not a tolerance for flaky tests.
10. Open questions for the team¶
- MFE stack facts — module federation / single-spa / import maps? Is the BE URL baked at build time or read at runtime? Does the shell already support any remote override? (Decides §4.5 and the shell prerequisite.)
- Current FE test coverage — do the MFEs have component tests today? If not, phase 5 should include a minimum bar before we lean on Playwright as the only FE net.
e2eenv cost — one more Railway environment (service + Postgres) and one more Cloudflare project per duplicated MFE. Acceptable?- Where does
sis-e2elive — new repo (recommended) or inside the shell? - Rotation — who triages the red nightly? A weekly rotation is enough, but it must be named.
- Canary tool — GitHub cron (free, jittery) vs Checkly (paid, proper alerting). Fine to start with cron and decide later.
- Test clock design — env var vs signed header vs both; goes through the design gate as a cross-cutting concern.
11. References¶
- Kent C. Dodds, The Testing Trophy — why integration-heavy, browser-light.
- Playwright docs: authentication /
storageState, API testing, clock, retries & flake. oasdiff— OpenAPI breaking-change detection.openapi-typescript/orval— client generation from OpenAPI.- Railway: environments,
RAILWAY_GIT_COMMIT_SHA, PR environments. Cloudflare Pages: preview deployments per branch. - Internal: ch09 Testing, ch10 Infrastructure, ch15 Seeding, ch03 Auth,
docs/fe-guides/.