E2E Testing Guide
End-to-end tests drive a real Chromium browser via Playwright (@playwright/test) against a
running app. They live with the React apps in the pnpm workspace, not in the .NET solution.
Not the same thing as the component tests. Vitest browser mode also drives a real Chromium, but it mounts a single component in an iframe with no dev server and no backend — that is a component test, covered in the Testing Guide. Playwright
e2e/suites exercise the full app through a running dev or production server, and (for backend specs) the live API.
Prerequisites
- Node 24 and pnpm (see the repo root
.nvmrcandpackageManager) - Workspace dependencies installed:
pnpm install - Playwright browsers:
pnpm --filter ./apps/wallow-auth exec playwright install --with-deps chromium
Suite Layout
apps/wallow-auth/e2e/ — the reference pattern
Ten specs plus three helper modules:
| Spec | Backend dependency |
|---|---|
routes.spec.ts |
None — the route-reachability gate |
first-run-setup.spec.ts |
API without an administrator — drives the /setup page and creates admin@wallow.dev in the e2e stack |
login.spec.ts |
API + seeded admin |
signup.spec.ts |
API |
logout.spec.ts |
API (validates the post-logout redirect URI against the client allow-list) |
forgot-password.spec.ts |
API |
reset-password.spec.ts |
API + Mailpit |
magic-link.spec.ts |
API + Mailpit |
otp-login.spec.ts |
API + Mailpit |
mfa.spec.ts |
API + Mailpit |
The config splits the run into two Playwright projects: first-run holds only
first-run-setup.spec.ts, and main (everything else) declares dependencies: ["first-run"].
That ordering is load-bearing — against the admin-less stack ./scripts/e2e.sh boots, the
first-run journey creates the admin@wallow.dev every other spec signs in as (against an
already-provisioned backend it skips itself). Keep new specs out of first-run.
Helpers: mailpit.ts (reads emails back over Mailpit's HTTP API), totp.ts (generates TOTP
codes for the MFA lifecycle), and global-setup.ts (wired in as Playwright's globalSetup).
Global setup warms each of the six routes in its WARMUP_PATHS list (/, /login,
/register, /setup, /forgot-password, /reset-password) to hydration so no spec's
readiness wait races Vite's cold pre-bundle, and — when WALLOW_API_INTERNAL_URL is set —
asserts the app proxies to the expected API by comparing the OIDC discovery issuer served
through the app against the one the API URL serves directly, which catches an orphaned dev
server left over from an earlier run being adopted by reuseExistingServer.
routes.spec.ts is the render-only deletion gate: it visits every route the app claims to
serve, asserts the response status is below 400, and waits for hydration. It proves each screen
is reachable, not that its flow is correct, so it needs only the app itself.
Everything else is backend-dependent and says so in a header comment. Those specs assert
app-level signals (login-signed-in, verify-email-heading, register-error) rather than
incidental side effects like a URL change — a bare /login visit carries no OIDC returnUrl,
so a successful sign-in renders the authenticated state in place instead of navigating.
apps/wallow-web/e2e/
The same pattern on port 3000. routes.spec.ts is its backend-free reachability gate; only
/bff-demo qualifies today, because it is the one public route with no beforeLoad gate. The
other dashboard routes redirect to OIDC or need the API.
apps/wallow-web/e2e-cross-app/ — the cross-app journey suite
Two specs live here, both under a dedicated config, playwright.cross-app.config.ts, which —
unlike the per-app configs — boots no server of its own.
login-journey.spec.tsexercises the complete wallow-web → wallow-auth → wallow-web login round trip. It needs three cooperating origins that only a full stack cross-wires: wallow-web (where the journey starts and ends), the API OIDC issuer, and wallow-auth (the login UI the API'sAuthUrlredirects to).external-origin-login.spec.tsruns the same round trip from thebff-exampleorigin, whose host port defaults to:3003, which authenticates as the seeded third-partybff-example-clientclient instead ofwallow-web-client. Because that client is not seeded first-party, the API routes it through wallow-auth's interactive consent screen — the leglogin-journey.spec.tsnever reaches.bff-exampleexists only indocker/docker-compose.test.yml, so this spec needs the containerised stack specifically; Aspire has no equivalent service.
Supply that stack one of two ways:
# Against the containerised test stack (wallow-web on :5053, the classic default) — runs both
# specs. ./scripts/e2e.sh instead allocates a free per-run port for each and threads it through
# E2E_BASE_URL / E2E_BFF_EXAMPLE_URL (Wallow-joo0).
E2E_BASE_URL=http://localhost:5053 pnpm --filter ./apps/wallow-web test:e2e:cross-app
# Against the Aspire AppHost (wallow-web on :3000, the config's default) — login-journey only
pnpm backend
pnpm --filter ./apps/wallow-web test:e2e:cross-app
Both also need the seeded admin from api/seed.json.
Running
pnpm --filter ./apps/wallow-auth test:e2e # full suite (boots its own dev server)
pnpm --filter ./apps/wallow-web test:e2e # wallow-web suite (port 3000)
# A single spec, or filter by title
pnpm --filter ./apps/wallow-auth exec playwright test routes.spec.ts
pnpm --filter ./apps/wallow-auth exec playwright test -g "password login"
Playwright's webServer starts the app with pnpm dev and reuses an already-running dev
server, so you do not need to start the app yourself for the reachability gate. Backend-
dependent specs additionally need the API — either pnpm backend or the runner below.
One-command backend-dependent runner
./scripts/e2e.sh is the supported way to run the backend-dependent suites. It brings up
docker/docker-compose.test.yml (infra, migrations, seeder, Wallow.Api, and wallow-web),
waits for OIDC discovery to answer at that run's API URL (classic default
http://localhost:5050/.well-known/openid-configuration), runs all three Playwright suites, and
tears the stack down:
pnpm --filter ./apps/wallow-auth test:e2epnpm --filter ./apps/wallow-web test:e2e— the reachability gatepnpm --filter ./apps/wallow-web test:e2e:cross-app— both cross-app specs: the first-party login journey (full login + authenticated mutation + logout loop) and the external-origin journey through the consent screen
./scripts/e2e.sh # local run: (re)build images, up, test, down
E2E_SKIP_IMAGE_BUILD=1 ./scripts/e2e.sh # reuse already-built :test images
It always starts by running docker compose down -v so the volumes are fresh. That matters:
the seeder skips admin bootstrap when its Admin options are unconfigured (a blank
Admin__Email), when the setup gate is already closed (an active membership holding an
AdminAccess role exists), or when the seed-admin user itself already exists — so a reused
database whose admin came from an earlier run would be re-seeded inconsistently rather than
freshly bootstrapped. A database containing only non-admin users does get the admin
bootstrapped.
The stack comes up admin-less on purpose: e2e.sh exports E2E_SEED_ADMIN_EMAIL as empty
for the initial seed, so the first-run-setup journey exercises the real /setup page, then runs
the seeder a second time with admin@wallow.dev restored to attach the journey-created admin to
the seeded organization's memberships (and, as a backstop, to bootstrap the admin if the journey
was skipped).
| Env knob | Effect |
|---|---|
E2E_STACK_ID=<id> |
Per-run stack identity (default: this shell's PID). The compose project is wallow-test-<id>; concurrent runs isolate on this plus their per-run host ports (Wallow-joo0). |
E2E_*_PORT=<n> |
Pin any host port (API/AUTH/WEB/BFF/POSTGRES/VALKEY/MAILPIT_SMTP/MAILPIT_HTTP/GARAGE_S3/GARAGE_ADMIN — full list in docker/.env.example). Unset ports each get a free port from the kernel for that run. |
E2E_IMAGE_TAG=<tag> |
Pin the image tag. Default: test when E2E_SKIP_IMAGE_BUILD=1 (reuse), else test-<stack id> (built per-run, untagged at teardown). |
E2E_SKIP_IMAGE_BUILD=1 |
Reuse whatever :test images already exist rather than building any of them — it suppresses both the dotnet publish of the API, migration, and seeder images and compose's --build of the services that have a build block (wallow-web, wallow-auth, bff-example, garage). CI sets it because a prior job preloads all but bff-example from cache. Leaving it unset is what guarantees the run tests your current tree. |
E2E_UP_SERVICE=<svc> |
Extra compose service to up --wait (default wallow-api). CI sets wallow-auth so that app is served from a container, which also points the wallow-auth suite at that container's per-run port unless E2E_BASE_URL is set. wallow-web is always brought up as well. |
E2E_BASE_URL=<url> |
Drive an already-running wallow-auth at that URL; Playwright then boots no local dev server. Does not affect the wallow-web suites. |
E2E_KEEP_STACK=1 |
Leave the stack up after the run, for debugging. |
E2E_SEED_ADMIN_EMAIL=<email> |
The admin email the seeder bootstraps — docker/docker-compose.test.yml interpolates it as Admin__Email: ${E2E_SEED_ADMIN_EMAIL-admin@wallow.dev} (explicitly empty stays empty; unset falls back to the default). e2e.sh exports it empty for the initial seed so the stack boots admin-less and the first-run journey drives /setup, then re-runs the seeder with admin@wallow.dev restored. |
The two serving modes follow from E2E_BASE_URL, and they apply to the wallow-auth suite
only. Left unset (the local default), Playwright's own pnpm dev webServer serves that app on
this run's allocated port (classic default :3002, passed through PORT) and its passthrough
server routes target the containerised API via WALLOW_API_INTERNAL_URL. Set (as in CI), the
prebuilt wallow-auth-react:test container serves it on this run's auth port (classic default
:5051) and Playwright drives it directly.
The two wallow-web suites always run in container mode against the wallow-web-react:test
container on this run's web port (classic default :5053). The cross-app journey needs three
cooperating origins that only the compose stack cross-wires — wallow-web, the API's OIDC issuer,
and the wallow-auth login UI — and playwright.cross-app.config.ts boots no server of its own.
Driving the backend manually
pnpm backend:infra # docker compose up -d (infra only)
dotnet run --project api/src/Wallow.Api # port 5001
dotnet run --project api/src/Wallow.SeederService # needs ConnectionStrings__DefaultConnection standalone
Configuration
apps/wallow-auth/playwright.config.ts sets the shared defaults; apps/wallow-web/playwright.config.ts
is identical apart from the port (and has no projects split).
testDir: "./e2e",fullyParallel: true,reporter: "list"- wallow-auth only: two
projects—first-run(justfirst-run-setup.spec.ts) andmain(everything else,dependencies: ["first-run"]) — so the setup journey always runs before the specs that sign in as the admin it creates testIdAttribute: "data-testid"— every selector resolves againstdata-testidbaseURLdefaults tohttp://localhost:3002for wallow-auth andhttp://localhost:3000for wallow-web; override withPORT, or withE2E_BASE_URLto target an external appwebServerrunspnpm devwithreuseExistingServer: true— and is omitted entirely whenE2E_BASE_URLis setWALLOW_API_INTERNAL_URLdefaults tohttp://localhost:5001, so the app's proxy resolves to a locally-run API outside Aspire
Selectors
- Always use
data-testid:page.getByTestId("login-email"). - Never use raw
#id, a CSS class (.btn-primary), or a text-based selector (button:has-text('Sign in')). - Naming:
{page}-{element}in kebab-case —login-email,login-submit,mfa-challenge-code.
React Readiness
Both apps stamp data-app-ready="true" on the document once React hydration completes, emitted
by src/shared/components/ready-indicator.tsx. Wait for that marker before interacting with a page:
await expect(page.locator("[data-app-ready='true']")).toBeAttached();
Writing a New E2E Test
- Add a spec under the app's
e2e/directory. Playwright specs must stay out ofsrc/**, which is where Vitest looks. - Add
data-testidattributes to the components you need to target, using{page}-{element}kebab-case naming. - Navigate, wait for
[data-app-ready='true'], then drive the flow withgetByTestId. - If the spec needs the backend, say so in a header comment and assert an app-level signal, not a URL change.
- Run it:
pnpm --filter ./apps/<app> test:e2e.
Playwright artifacts (test-results/, playwright-report/) are gitignored per app.
Debugging Failed Tests
# Headed mode
pnpm --filter ./apps/wallow-auth exec playwright test --headed
# Step through with the inspector
pnpm --filter ./apps/wallow-auth exec playwright test --debug
# Open the last HTML report (traces, screenshots, video when enabled)
pnpm --filter ./apps/wallow-auth exec playwright show-report
Common Failure Patterns
| Symptom | Likely cause | Fix |
|---|---|---|
Timeout waiting for data-app-ready |
The app failed to hydrate | Check the dev server output and the browser console |
Timeout waiting for a data-testid |
Element not rendered, or the testid is wrong | Verify the attribute in the component |
login.spec.ts cannot sign in |
Backend not running, or admin@wallow.dev was never created |
Run ./scripts/e2e.sh — in the e2e stack the first seed is deliberately admin-less and first-run-setup.spec.ts creates the admin (the second seeder pass is the backstop) |
Mail-dependent specs get ECONNREFUSED on :8035 |
Mailpit is not up | Use ./scripts/e2e.sh; the compose file gates wallow-api on Mailpit starting |
| Proxy or API errors | WALLOW_API_INTERNAL_URL points nowhere |
Point it at your running API (default http://localhost:5001) |