A self-paced, hands-on workshop teaching modern E2E testing with Cucumber + Playwright, from beginner to advanced, against a simple but fully-functional reference app. Target audience: total beginners to E2E testing.
This file is the complete build plan for a new project. Open Claude Code in an empty directory and hand it this file:
Follow PLAN.md. Build Phase A first. Ask before moving between phases.
All decisions below are locked unless stated otherwise. If you find an ambiguity, ask before inventing.
Two things in one repo:
- A reference web app — a small, fully-functional master-detail project management tool (Users, Projects, Tasks). The app itself is not the lesson; it exists to give the E2E tests something real to test.
- A 12-module workshop — progressive, self-paced content that teaches E2E testing with Cucumber + Playwright, delivered as tagged commits on a linear
mainbranch.
Final workshop state = reference app + complete E2E suite covering every concept in the curriculum.
| Topic | Decision |
|---|---|
| Package manager | npm (not pnpm/yarn) |
| Node | 20 LTS |
| Monorepo | Nx |
| Frontend | React + Vite |
| Backend | NestJS |
| Database | SQLite via Prisma |
| Auth | Simple JWT (Authorization: Bearer), no refresh tokens |
| API style | REST |
| Styling | Tailwind |
| Language | TypeScript everywhere |
| BDD lib | playwright-bdd (vitalets) — compiles .feature files into Playwright tests |
| Cucumber runner | use playwright-bdd's compiler, NOT @cucumber/cucumber runner directly |
| License | MIT |
| Repo name | modern-e2e-quickstart |
| Workshop delivery | tagged commits on linear main, not branches per module |
| Exclusions | No CI integration, no visual regression, no accessibility, no Docker, no multi-browser matrix (Chromium only), no native Windows (WSL2 only) — all out of scope for v1 |
| Platform | macOS + Linux first-class; Windows via WSL2, documented in troubleshooting |
| Node enforcement | .nvmrc pins 20; engines.node: ">=20.0.0 <21.0.0" in package.json |
| Line endings | .gitattributes with * text=auto eol=lf |
| Lockfile | package-lock.json committed; npm ci is the documented clean-install |
| Scripts | No bash. All repo scripts are .mjs (Node ESM) |
// apps/api/prisma/schema.prisma
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
role String @default("member") // "admin" | "member"
ownedProjects Project[] @relation("ProjectOwner")
assignedTasks Task[] @relation("TaskAssignee")
createdAt DateTime @default(now())
}
model Project {
id String @id @default(cuid())
name String
description String?
ownerId String
owner User @relation("ProjectOwner", fields: [ownerId], references: [id])
tasks Task[]
createdAt DateTime @default(now())
}
model Task {
id String @id @default(cuid())
title String
description String?
status String @default("todo") // "todo" | "doing" | "done"
priority String @default("medium") // "low" | "medium" | "high"
dueDate DateTime?
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
assigneeId String?
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
createdAt DateTime @default(now())
}Two separate seeds — keep them distinct.
Dev seed (apps/api/prisma/seed.ts): idempotent (wipe + reseed). Runs automatically on every nx serve api start via a predev hook. For the developer experience of using the app.
Seed contents:
- 3 users with known passwords (documented in
docs/cheatsheet.md):admin@example.com/Admin123!(role: admin)alice@example.com/Password1!(role: member)bob@example.com/Password1!(role: member)
- 4 projects with varied ownership
- ~15 tasks spread across projects, statuses, priorities, assignees, due dates (include past-due, no-due-date, unassigned)
E2E seed (via /test/seed/* endpoints): used exclusively by the E2E suite. All data it creates is prefixed E2E_ (e.g., E2E_admin@example.com, project name E2E_<scenario-id>_<purpose>). Tests never touch dev-seed data; dev-seed data is never used by tests. Cleanup is guaranteed in two ways:
- Primary:
Afterhooks delete IDs tracked in the scenario's World. - Belt & suspenders:
globalSetupandglobalTeardowncall/test/reset, which doesDELETE FROM * WHERE name/email LIKE 'E2E_%'. Catches anything abandoned by crashed hooks.
JWT secret is pinned via JWT_SECRET in .env / .env.example, stable across reboots and reseeds. Tests' cached storageState stays valid across reruns.
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /auth/login |
public | body {email, password} → {token, user} |
| POST | /auth/logout |
JWT | no-op stub (for symmetry) |
| GET | /auth/me |
JWT | current user |
| GET | /users |
JWT | list |
| GET | /users/:id |
JWT | |
| GET | /projects |
JWT | ?search=&ownerId= |
| POST | /projects |
JWT | |
| GET | /projects/:id |
JWT | |
| PATCH | /projects/:id |
JWT (owner or admin) | |
| DELETE | /projects/:id |
JWT (owner or admin) | |
| GET | /projects/:id/tasks |
JWT | ?status=&assigneeId= |
| POST | /projects/:id/tasks |
JWT | |
| GET | /tasks/:id |
JWT | |
| PATCH | /tasks/:id |
JWT | |
| DELETE | /tasks/:id |
JWT | |
| POST | /test/seed/user |
env-guarded | body {role, emailPrefix?} → {id, email, password, token}. Created user's email is E2E_<prefix-or-random>@example.com |
| POST | /test/seed/project |
env-guarded | body {ownerId, name} → {id, name, ownerId}. Name auto-prefixed E2E_ if missing |
| POST | /test/seed/task |
env-guarded | body {projectId, title, ...} → {id}. Title auto-prefixed E2E_ if missing |
| POST | /test/reset |
env-guarded | Sweeps all E2E_% rows. No body, no return body |
All /test/* endpoints are disabled when NODE_ENV === 'production' — the route module is not loaded. The test-seam is deliberate and documented (Module 06 teaches why).
Validation via class-validator. Errors return { statusCode, message, error } consistently.
| Route | Purpose |
|---|---|
/login |
Login form |
/projects |
Master list: search box, "New Project" button, each item links to detail |
/projects/:id |
Detail: project info, task list filterable by status, "Add Task" button |
/projects/:id/tasks/:taskId |
Task edit (drawer or page — pick one, be consistent) |
/users |
User list |
/users/:id |
User profile, shows assigned tasks |
/profile |
Current user's profile + logout |
Layout: top nav with links, current user, logout. Redirect unauthenticated users to /login (preserve returnTo).
Layered priority, in order:
getByRole(role, { name })— for anything with a natural ARIA role and accessible name: buttons, links, headings, checkboxes, radios.getByLabel(text)— for form fields with a visible label.getByText(text)— for static content assertions where role doesn't apply.getByTestId(id)— for structural scaffolding without a natural role: row containers, nav regions, toast containers, empty states, inline errors.- Scope + filter — for selecting specific items in a list:
getByTestId('projects-list').getByRole('listitem').filter({ hasText: seeded.name }).
Test-ID format (when used): {page-or-feature}-{component}-{role-or-action}, kebab-case. No dynamic IDs baked into test-IDs — list rows get stable test-IDs (projects-list-item) and are disambiguated by content filter, not -{id} suffix.
What gets a test-ID:
- List containers:
projects-list,tasks-list,users-list - Row scaffolding:
projects-list-item,task-row(with inner childrentask-row-status,task-row-priority,task-row-assignee) - Regions without natural roles:
app-nav-user-menu,projects-list-empty-state,login-error-message,toast-container
What does NOT get a test-ID:
- Form fields with labels (use
getByLabel('Email')) - Buttons with visible text (use
getByRole('button', { name: 'Save' })) - Links (use
getByRole('link', { name: 'Projects' })) - Headings (use
getByRole('heading', { name: 'Projects' }))
Document the strategy in docs/cheatsheet.md. Module 02 teaches the layered priority explicitly — it's the workshop's canonical locator lesson.
modern-e2e-quickstart/
├── apps/
│ ├── api/ # NestJS + Prisma + SQLite + JWT
│ │ ├── prisma/
│ │ │ ├── schema.prisma
│ │ │ ├── seed.ts
│ │ │ └── migrations/
│ │ └── src/
│ │ ├── auth/
│ │ ├── users/
│ │ ├── projects/
│ │ ├── tasks/
│ │ └── main.ts
│ ├── web/ # React + Vite + Tailwind
│ │ └── src/
│ │ ├── pages/
│ │ ├── components/
│ │ ├── api/ # fetch wrappers
│ │ ├── auth/ # context, guards
│ │ └── main.tsx
│ └── web-e2e/ # playwright-bdd
│ ├── features/
│ │ ├── auth/
│ │ ├── projects/
│ │ └── tasks/
│ ├── steps/
│ ├── pages/ # page objects
│ ├── fixtures/
│ ├── support/
│ │ ├── seed/ # typed seed helper library (composes /test/seed/* endpoints)
│ │ ├── world.ts
│ │ ├── hooks.ts
│ │ ├── env.ts
│ │ └── api-client.ts
│ ├── .auth/ # gitignored storageState files
│ ├── data/
│ ├── playwright.config.ts
│ └── cucumber.config.ts # playwright-bdd defineConfig
├── modules/ # workshop content
│ ├── 00-setup/README.md
│ ├── 01-first-feature/README.md
│ ├── 02-page-objects-test-ids/README.md
│ ├── 03-fixtures-hooks-world/README.md
│ ├── 04-tagging-outlines-data-tables/README.md
│ ├── 05-auth-storage-state/README.md
│ ├── 06-api-ui-hybrid/README.md
│ ├── 07-network-mocking-interception/README.md
│ ├── 08-custom-commands/README.md
│ ├── 09-parallel-retries-flake/README.md
│ ├── 10-debugging/README.md
│ ├── 11-reporting/README.md
│ └── 12-env-config-capstone/README.md
├── docs/
│ ├── intro.md
│ ├── participant-workflow.md # how to use tags, git diff, etc.
│ ├── cheatsheet.md
│ └── troubleshooting.md
├── scripts/
│ ├── modules.manifest.json
│ ├── rebuild-tags.mjs # regenerates tags from manifest
│ └── module.mjs # participant workflow: begin, compare, reset, status
├── .env.example
├── .nvmrc # 20
├── .gitattributes # * text=auto eol=lf
├── README.md # project-level overview, links to docs/intro.md
├── LICENSE # MIT
├── package.json # engines.node pinned to >=20.0.0 <21.0.0
├── package-lock.json # committed
├── nx.json
├── tsconfig.base.json
└── .gitignore # includes apps/web-e2e/.auth/
- Install:
npm add -D playwright-bdd @playwright/test cucumber.config.tsuses playwright-bdd'sdefineConfigto map features → stepsplaywright.config.tsuses the generated test dir from playwright-bdd- Single
npx nx e2e web-e2eruns everything
- Page objects extend a
BasePagewithgoto(), common locators (nav, toast). Row-level POMs where relevant (projectsListPage.row(name)returns a scoped locator with its own children). - Locators follow the layered strategy in §3.5 (role → label → text → testid → scope+filter). Never CSS classes, never XPath.
- Fixtures compose:
authenticatedPagebuilds onpage+apiClient+ pinnedstorageStateper role. - Seed helpers in
support/seed/are typed functions that compose granular/test/seed/*calls. Example:seedProjectWithTasks({ owner: 'admin', tasks: 3 })returns{ project, tasks }with known IDs, names prefixedE2E_<scenario-id>_. - World (Cucumber's scenario context) holds shared state like
currentUser,createdProjectIds,scenarioId— NOT DOM state. - Hooks in
support/hooks.ts:Beforetag-filtered for role setup,Afterfor cleanup (deletes tracked IDs). - globalSetup: seed shared read-only E2E users, log in as each, write
storageStatefiles to.auth/(pinned JWT secret keeps these valid across runs). - globalTeardown: call
/test/resetsweep — belt & suspenders. - Tags:
@smoke,@wip,@auth,@projects,@tasks,@module-NN,@flaky(excluded from default run).
Every modules/NN-name/README.md follows this template:
# Module NN — <Title>
## What you'll learn
- bullet
- bullet
## Why it matters
(2–3 sentences of real motivation — tie to a pain point)
## Prerequisites
- Completed Module NN-1
- On tag `NN-start`: `git checkout NN-start`
## Walkthrough
(step-by-step, with code blocks, small and incremental)
## Exercise
(the thing the participant does themselves)
## Run it
npx nx e2e web-e2e -- --grep @module-NN
## Compare
git diff NN-complete -- apps/web-e2e
## Cheat sheet
(key API surface introduced this module)
## Next
→ [Module NN+1](../NN+1-name/README.md)| # | Module | NN-complete deliverable |
|---|---|---|
| 00 | Setup & app tour | App runs, DB seeds, empty e2e project, one passing smoke test |
| 01 | First feature + step definitions | login.feature (2 scenarios), matching step defs, green |
| 02 | Page objects + locator strategy | LoginPage, BasePage, steps refactored to use POM; teaches layered locator priority (§3.5) |
| 03 | Fixtures, hooks, world | authenticatedPage fixture, scenario world, Before/After hooks |
| 04 | Tagging + scenario outlines + data tables | @smoke @auth, outline for login failures, data table for project create |
| 05 | Auth & storage state | storageState per role, login-once-per-worker, @anonymous escape hatch |
| 06 | API + UI hybrid | apiClient fixture, seed via API in Before, assert via UI, cleanup in After |
| 07 | Network mocking & interception | page.route stubs for slow/failed responses, offline scenario, HAR recording demo |
| 08 | Custom commands / step composition | Composite steps like Given I am logged in as {role}, POM vs step-helper guidance |
| 09 | Parallel, sharding, retries, flake | fullyParallel, worker isolation, retries. Flake demo: a features/flaky.feature (tagged @flaky, excluded from default run) that violates §3.2 isolation — un-prefixed shared name, no cleanup. Reproduce with --repeat-each 5 --grep @flaky. Fix by applying the E2E_<scenario-id>_ prefix pattern |
| 10 | Debugging | UI mode, trace viewer, test.step(), page.pause(), codegen |
| 11 | Reporting | Playwright HTML, Cucumber JSON output, optional Allure |
| 12 | Env config + capstone | .env.local / .env.ci, base URL selection; capstone: participant writes a feature solo |
Linear main, tagged commits:
00-setup → 01-start → 01-complete → 02-start → 02-complete → ... → 12-complete
Rules:
NN-start= previous module'sNN-1-complete+ a commit that adds the module's README AND removes the code the participant will writeNN-complete=NN-start+ commits that implement the module's exercise/walkthrough- Participants:
git checkout NN-start→ follow README →git diff NN-completeto check
scripts/modules.manifest.json maps tags to commit SHAs. scripts/rebuild-tags.mjs regenerates all tags from the manifest (no bash — Node for cross-platform consistency, WSL included).
scripts/module.mjs wraps the git mechanics participants hit every module. package.json exposes them as npm scripts:
npm run module:begin 03 # checkout 03-start, create my/03 branch
npm run module:compare 03 # git diff my/03..03-complete -- apps/web-e2e
npm run module:reset 03 # hard-reset my/03 to 03-start (recovery)
npm run module:status # show current module + branch + dirty state
Scripts are thin, readable wrappers — docs/participant-workflow.md teaches both the npm commands and the underlying git commands they invoke, so participants understand the mechanics.
Do these in order.
Gate policy: Phase A → B.1 and B.1 → B.2 transitions are open — proceed autonomously. Rationale: Phase B.1's vertical slice is the automated equivalent of the manual smoke script, so passing B.1 green proves Phase A. B.2 is additive and each feature is independently verifiable.
The Phase B.2 → Phase C gate is closed — stop and ask before beginning Phase C. Rationale: Phase C rewrites commit history and designs pedagogical commit boundaries, which is a teaching judgment call requiring human review.
The Phase C → Phase D gate is closed — stop and ask. Phase D requires a human to dry-run the workshop as a participant.
Hard blockers: If Step 0 of Phase A (the ESM/CJS spike) fails or reveals incompatibilities between locked versions, stop and flag rather than improvise. Any deviation from §2 locked decisions is a hard blocker.
- Create Nx workspace, install all deps. Step 0: before scaffolding apps, verify the ESM/CJS split (CJS for api, ESM for web/web-e2e) works with the exact Nx + NestJS + Prisma versions you pinned. Spike a "hello world" endpoint before committing to full api scope.
- Prisma schema + migrations + dev seed + predev hook
- NestJS API: auth module, JWT guard (secret pinned via
JWT_SECRET), users/projects/tasks modules, validation, consistent error shape - NestJS
/test/*module (env-guarded):/test/seed/user,/test/seed/project,/test/seed/task,/test/reset(§3.3) - React app: Tailwind setup, router, auth context, all pages and forms
- Wire locators per §3.5 (accessible labels first; test-IDs for structural elements)
- Walk the manual smoke script below end-to-end
- Commit as tag
reference-complete(internal milestone — not a module tag)
Manual smoke script (Phase A exit gate):
npx nx serve apistarts cleanly; dev seed creates 3 users + 4 projects + ~15 tasks; no errors.npx nx serve webstarts; no console errors.- Hit
/projectsanonymous → redirects to/login?returnTo=/projects. - Log in as
admin@example.com / Admin123!→ lands on/projects, 4 projects visible. - Search filters the list.
- "New Project" → create "Smoke Project" → appears in list.
- Click into project → detail page, no tasks.
- "Add Task" → create task with title, priority, due date, assignee → appears.
- Change task status → list reflects. Filter by status → works.
- Edit task title → saves. Delete task → gone.
- Delete project → returns to list, project gone.
- Log out → back to
/login. - Log in as
alice→ cannot delete admin-owned project (button hidden or 403). /users,/users/:id,/profilerender correctly for both roles.- Browser console clean across the whole flow.
Exit criteria for Phase A: manual smoke script passes; dev seed is idempotent; POST /test/reset succeeds; browser console clean.
Split into two sub-phases:
Phase B.1 — Vertical slice (~¼ of final scope). Prove the architecture end-to-end before filling out the suite:
- Scaffold
apps/web-e2ewith playwright-bdd - Write
BasePage+LoginPage+ one or two other POMs - Write the
authenticatedPagefixture + the typed seed helper library (support/seed/) - Write
support/(world, hooks, env, api-client) andglobalSetup/globalTeardown - Write one automated smoke test (plain
test(...), not BDD): navigates/login, logs in as admin, asserts/projects - Write representative
.features covering: (a) a login scenario with POM, (b) a project CRUD flow using seed helpers + apiClient + UI assertion, (c) one network-mocked scenario - Prove
fullyParallel: truewith 4+ workers,E2E_prefix isolation works, storageState per role works
Phase B.1 exit: the smoke test + representative features run green twice in a row. Architecture is proven.
Phase B.2 — Fill out in curriculum order. Walk modules 1 → 12, add each module's scope in teaching order:
- For each module, add the scenarios + POMs + fixtures that module introduces
- Commits roughly align with module boundaries from day one — track a running
scripts/modules.manifest.jsonas you go - Do not reach for future-module features early (no
storageStatein Module 01-scope commits, etc.) - Every module's concept must be demonstrated somewhere in the suite
Exit criteria for Phase B: npx nx e2e web-e2e runs green, twice in a row, in under 120 seconds (target: 90).
Because Phase B.2 built in curriculum order, Phase C is lightweight:
- Write all 12
modules/NN-name/README.mdfiles based on the reference suite - Audit the running manifest — split/merge commits only where a module needs cleaner boundaries; no wholesale rewrite
- Finalize
scripts/modules.manifest.jsonandscripts/rebuild-tags.mjs - Tag
NN-start/NN-completefor all modules - Test participant workflow:
npm run module:begin 03→ edit →npm run module:compare 03works
Exit criteria for Phase C: git checkout 00-setup works; every tag checks out a coherent state; participant workflow scripts work.
- Write
docs/intro.md,docs/participant-workflow.md,docs/cheatsheet.md,docs/troubleshooting.md - Write project
README.mdwith quick start + link todocs/intro.md - Dry-run all 12 modules as a participant (fresh clone, checkout tags, follow READMEs)
- Log friction, fix, re-tag
- Second dry run, clean
Exit criteria for Phase D: a smart beginner could complete the workshop with no outside help.
- No premature abstractions. Three similar lines is fine; helper when it repeats a fourth time.
- No comments describing what code does. Locators, good names, and test-IDs carry the meaning.
- Locator strategy per §3.5 — layered role → label → text → testid → scope+filter. Test-IDs kebab-case, no dynamic IDs baked in.
- Error handling only at system boundaries (HTTP input, external APIs). Trust internal code.
- File naming: kebab-case for components/modules, PascalCase for page-object classes.
- Imports: absolute paths via
tsconfig.base.jsonpathswhen crossingapps/. - Module system: ESM in
apps/webandapps/web-e2e. CJS inapps/api(Nx + NestJS default). No bash scripts — all repo scripts are.mjs. - No
libs/for v1. Each app defines its own types locally. Revisit if and when duplication becomes painful.
When you start:
- Confirm the target directory is empty (or only contains this
PLAN.mdandCLAUDE.md) - Read
PLAN.mdandCLAUDE.mdtop to bottom - Propose a concrete starting step (probably: scaffold Nx workspace + initial commit) and ask to proceed
- Don't run
npx create-nx-workspacein a non-empty directory without checking - Track progress with TaskCreate/TaskUpdate — one task per phase, subtasks as needed
When spawning subagents via the Agent tool for code generation, file exploration, or research tasks, pass model: "sonnet" unless the task specifically requires Opus-level reasoning (architectural design calls, ambiguous spec resolution). Default to Sonnet for:
- Scaffolding files (Nx generators, boilerplate)
- Writing page objects, step definitions, fixtures
- Writing seed data, DTOs, validation schemas
- Writing module README content from a given outline
- Codebase exploration (
Exploresubagent) - Any task where the approach is already decided and execution is mechanical
Opus stays the parent/orchestrator. Document this rule in CLAUDE.md so it survives across sessions.
- GitHub org / publishing
- Whether to add a
solutions/alternative for participants who don't want to use git tags - Optional bonus modules (CI, visual, a11y, Docker) once v1 is shipped
- Video walkthroughs
End of plan.