| name | playwright-test |
| description | Creates Playwright browser-based end-to-end tests for Next.js pages covering complete user journeys from start to finish. Use when the user asks to "write Playwright tests", "create e2e tests", "write integration tests", "test in the browser", or mentions end-to-end testing, browser tests, UI integration tests, or Playwright for Next.js.
|
Playwright Test
Instructions
Create Playwright end-to-end tests for Next.js pages based on the use case $ARGUMENTS. Playwright tests run in a real browser against the running application with a real database via Testcontainers.
End-to-end means end-to-end. Each test walks the complete user journey from entry point to
final outcome — exactly as a real user would. Tests navigate by clicking links and buttons, not
by jumping to internal URLs. If a real user must click three screens to reach a form, the test
clicks through those same three screens.
Inputs
| Input | Location | Required |
|---|
| Use case specification | docs/use_cases/UC-XXX.md | Yes |
| Frontend design | docs/designs/UC-XXX-design.html | Yes |
The use case specification defines what the system does (scenarios, alternative flows, business
rules). The frontend design defines how it looks and behaves (screens, components, states,
user actions, navigation flow). Together they determine the test scenarios and assertions:
- Test scenarios derive from the use case Main Success Scenario and Alternative Flows
- Page structure and selectors derive from the frontend design's Components and Layout sections
- Assertions derive from the frontend design's States (default, loading, empty, error, success) and Data Displayed sections
- User interactions derive from the frontend design's User Actions (triggers, results)
- Navigation expectations derive from the frontend design's Navigation Flow and Screen Map
If docs/designs/$ARGUMENTS-design.html does not exist, stop and tell the user to run /design-screens $ARGUMENTS first.
Test Philosophy: Journeys, Not Fragments
The purpose of E2E tests is to verify that the complete user journey works end-to-end. Each
test represents one path through the use case — either the Main Success Scenario or an
Alternative Flow.
How many tests per use case:
- 1 test for the Main Success Scenario (the happy path, all steps start to finish)
- 1 test per Alternative Flow that diverges meaningfully from the MSS
- Business rules are verified inline within the journey they belong to, not as separate tests
A typical use case produces 3–8 tests total, not dozens.
What makes it E2E:
- The test starts at the application's entry point (e.g., landing page, login screen)
- Every navigation happens through UI interactions (clicks, form submissions) — never
page.goto() to internal pages
- The test verifies intermediate states along the way, not just the final outcome
- The test verifies Postconditions:
- For success flows: data is visible and correctly stored in the system.
- For failure flows: the system state remains unchanged (e.g., no partial records created, original values preserved). Use API calls in
afterEach or inline to verify deep system state if UI-only verification is insufficient.
- The test ends with a verifiable outcome (data visible, confirmation shown, redirect happened)
page.goto() is only used ONCE per test — to open the application entry point (e.g., page.goto('/') or page.goto('/login')). All subsequent navigation must happen through the UI.
Traceability Convention
E2E tests use the helper at e2e/helpers/traced.ts to link each test to its
use case, change requests, and bug fixes. Use Cases are grouped with raw
test.describe(...) (not a custom wrapper) so IDE plugins (WebStorm/IntelliJ,
VSCode Playwright) can walk into the block and show gutter run icons for each
test inside.
The helper exports three functions:
uc(id) — returns the { tag } options object for test.describe().
Validates the UC doc exists.
meta(uc, { scenario, verifies?, fixes? }) — returns Playwright's
{ tag, annotation } second-arg object for a test inside the describe.
Validates referenced CR/BUG docs exist.
bug(id) — same shape, for a pure bug regression test that has no clean
UC home. Validates the BUG doc exists.
Anchor each UC group with test.describe(...) and uc(...):
import { test, expect } from '@playwright/test';
import { uc, meta } from './helpers/traced';
test.describe('UC-XXX: [Use Case Name]', uc('UC-XXX'), () => {
test('[end-to-end journey description]',
meta('UC-XXX', {
scenario: 'MSS',
verifies: ['CR-NNN'],
fixes: ['BUG-NNN'],
}),
async ({ page }) => {
});
test('[alternative flow description]',
meta('UC-XXX', { scenario: 'AF-1' }),
async ({ page }) => {
});
});
Multiple UCs per file (when journeys share a page or flow): one top-level
test.describe(...) block per UC.
Pure bug regression tests (no clean UC home) — file bug-NNN-*.spec.ts:
import { test, expect } from '@playwright/test';
import { bug } from './helpers/traced';
test('[regression description]', bug('BUG-NNN'), async ({ page }) => {
});
Scenario types (the required scenario field on meta('UC-NNN', {...})):
'MSS' — Main Success Scenario (one per UC)
'AF-N' — Alternative Flow N (e.g., 'AF-1', 'AF-2')
'EX-N' — Exception path N
The helper validates that referenced UCs, CRs, and BUGs exist as files under
docs/use_cases/, docs/change_requests/, and docs/bugs/ at registration
time. A typo'd CR-002 fails before any browser starts.
Why raw test.describe() instead of a custom wrapper: JetBrains'
Playwright plugin (and the VSCode equivalent) only walks test() and
test.describe() calls — it does not enter callbacks of arbitrary helper
functions. A useCase() wrapper that calls test.describe() internally
works at runtime but hides the inner tests from the IDE's AST walker. Keeping
test.describe(...) literally in source is what makes gutter run/debug icons
appear for each test.
The UC id is repeated three times per describe (title, uc(), each meta()).
This is intentional: a single source of truth would require either fragile
module-level state or a wrapper the IDE can't see into.
Inline comments — when a single line/assertion exists because of a CR or
BUG, leave a one-line marker comment so a code reader sees it without reading
tags:
await page.locator('input[type="month"]').first().fill('2025-06');
await expect(page.getByText('Error')).toBeVisible();
await expect(page.locator('table')).toContainText(['New Item']);
DO NOT
- Navigate with
page.goto() to internal pages — only use page.goto() once per test to open the entry point. All other navigation must happen through clicking links, buttons, and submitting forms. This is the most important rule: if you bypass navigation, you are not testing E2E
- Write one test per step or per screen — each test must cover the full journey. A test that only checks "form displays correctly" is not an E2E test
- Create separate tests for business rules — verify business rules inline within the journey where they apply
- Run tests on multiple browsers — use Chromium only. The
playwright.config.ts must have exactly one project
- Access the database directly in tests — use the test API endpoints (
/api/e2e/users) instead
- Use
waitForLoadState('networkidle') — it waits for zero network activity for 500ms, but Next.js dev-server HMR websockets, analytics pings, and prefetch requests can delay or prevent it from settling, causing flaky timeouts in CI. Instead, wait for the specific UI element that signals the page is ready (a heading, a table row, a form field)
- Skip waiting for page loads or network requests to complete
- Use hard-coded delays (
page.waitForTimeout) instead of proper waits
- Delete all data in cleanup (only remove data created during the test)
- Hard-code database connection strings —
DATABASE_URL is injected by global setup
- Write tests that contradict the frontend design (e.g., asserting a table when the design specifies cards)
- Skip, exclude, or filter out tests — never use
--grep-invert, --grep, test.skip(), test.fixme(), or any other mechanism to avoid running tests. All tests must run and pass
- Claim tests pass without verifying output — you must check the actual Playwright output for "X passed" and "0 failed" before declaring success
- Sleep or wait between test retries — when tests fail, diagnose and fix the root cause immediately, then re-run. Never use
sleep, setTimeout, or any delay between retry attempts. The fix-then-rerun cycle must be immediate
- Ignore or work around database-dependent tests — Testcontainers provides the database; if Docker is not running, stop and tell the user instead of skipping DB tests
- Write no-op or trivially-true tests — every test must contain meaningful assertions that would fail if the feature were broken (e.g., never assert
expect(true).toBe(true) or assert only that a page loads without checking content)
- Register a test or describe without the helper — every
test(...) call in an E2E spec must be tagged via meta('UC-NNN', { scenario, ... }) (inside a test.describe) or bug('BUG-NNN') (pure regression, module scope). Every UC test.describe(...) must pass uc('UC-NNN') as its second arg. Raw test('title', async (...) => ...) or test.describe('title', () => {...}) with no helper metadata is a violation — it loses the traceability link, the HTML report annotation, and the registration-time doc validation
Nexa Rules Gate
Read and follow ${CLAUDE_PLUGIN_ROOT}/shared/readiness/NEXA_RULES_GATE.md.
Sprint Branch Gate
Read and follow ${CLAUDE_PLUGIN_ROOT}/shared/readiness/SPRINT_BRANCH_GATE.md.
Test User Provisioning
Every E2E test requires an authenticated user. User provisioning operates at two levels:
Suite-Level User (default)
Each test file provisions one shared user for all tests in the file. This user is a standard,
fully-valid user suitable for happy-path scenarios.
Lifecycle (managed via test.beforeAll / test.afterAll):
- Before all tests: Create a user directly in the database via the test API endpoint:
- Email:
e2e-{random8chars}@example.com
- Password: bcrypt-hashed (plaintext stored in a variable for login)
account_type: as needed by the use case (default to the most common type)
status: "ACTIVE"
email_confirmed: true
- All consent flags:
true
auth_provider: "EMAIL"
created_at / updated_at: current timestamp
- After each test: Clear cookies and storage to prevent session leakage between tests.
- After all tests: Delete the user and all related records via the test API endpoint.
Per-Test User Override
Individual tests that need a user with different characteristics (e.g., inactive status,
unconfirmed email, a different account type) provision their own user, overriding the suite-level user.
Lifecycle (managed via test.beforeEach / test.afterEach or inline):
- Before the test: Create a custom user directly in the database via the test API endpoint:
- Email:
e2e-{random8chars}@example.com
- Password: bcrypt-hashed (plaintext stored in a variable for login)
account_type: as needed by the specific test scenario
status: as needed by the test scenario (e.g., "SUSPENDED", "PENDING")
email_confirmed: as needed (e.g., false for unconfirmed-email flows)
- All consent flags:
true
auth_provider: "EMAIL"
created_at / updated_at: current timestamp
- Start of test: Log in as this custom user via the login page UI.
- After the test: Delete this custom user and all related records via the test API endpoint (use
finally blocks to ensure cleanup runs even on failure). Cookies are cleared by the suite-level afterEach.
Test API Endpoint
The application must expose a test-only API endpoint for user provisioning and cleanup. This endpoint
is only available when NODE_ENV=test:
POST /api/e2e/users — Creates a user in the database. Accepts the user fields above. Returns the created user with id.
DELETE /api/e2e/users/:id — Deletes the user and all related records (cascade).
If this endpoint does not exist in the project, create it before writing tests. Use the template at
templates/e2e-users-api.ts.
Test User Helper
Use the helper at templates/test-user.ts to provision and clean up users.
If e2e/helpers/test-user.ts does not exist, create it from the template.
Other Test Data
| Approach | Location | Purpose |
|---|
| Prisma seed | prisma/seed.ts | Baseline reference data (non-user) |
| API calls | Within test setup | Test-specific entity data |
| Manual cleanup | afterEach hooks | Remove data created during test |
Test Data Conventions
- Use only
example.com for test emails and accounts (e.g., user@example.com, admin@example.com). This is an IANA-reserved domain that will never route real mail.
Testcontainers Global Setup
Before writing tests, ensure the project has a global setup file that starts a PostgreSQL
Testcontainer, runs Prisma migrations, and seeds the database. The dev server is handled
separately by Playwright's webServer option.
If e2e/global-setup.ts does not exist, create it using templates/global-setup.ts.
.env.e2e — Single Source of Truth
All E2E test environment variables live in a .env.e2e file at the project root. The global
setup loads it via dotenv, and the webServer command sources it before starting the dev
server. This eliminates dynamic env file generation and scattered env: blocks.
NODE_ENV=test
DATABASE_URL=postgresql://test:test@localhost:5432/testdb
DIRECT_URL=postgresql://test:test@localhost:5432/testdb
NEXT_PUBLIC_APP_URL=http://localhost:3000
AUTH_SECRET=test-secret-for-vitest-at-least-32-characters-long
AUTH_URL=http://localhost:3000
E2E_TEST=true
Add project-specific env vars as needed. Commit this file (add !.env.e2e to .gitignore
if .env* is ignored).
Playwright Configuration
Single browser only — use Chromium. Do NOT add Firefox or WebKit projects. Cross-browser
testing is not the purpose of E2E tests; verifying user journeys is.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
globalTeardown: './e2e/global-teardown.ts',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 3 : 6,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
webServer: {
command: 'bash -c \'set -a; source .env.e2e; set +a; exec npx next dev\'',
url: 'http://localhost:3000',
reuseExistingServer: false,
timeout: 60_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
Templates
Common Patterns
Suite-Level User Provisioning
import { test, expect } from '@playwright/test';
import { createTestUser, deleteTestUser, type TestUser } from './helpers/test-user';
let suiteUser: TestUser;
test.beforeAll(async ({ request }) => {
suiteUser = await createTestUser(request, { accountType: 'BUYER' });
});
test.afterEach(async ({ context }) => {
await context.clearCookies();
});
test.afterAll(async ({ request }) => {
await deleteTestUser(request, suiteUser.id);
});
Login via UI (start of each test)
test.describe('UC-XXX: [Use Case Name]', uc('UC-XXX'), () => {
test('user signs in and lands on dashboard',
meta('UC-XXX', { scenario: 'MSS' }),
async ({ page }) => {
await page.goto('/login');
await expect(page.getByLabel('Email')).toBeVisible();
await page.getByLabel('Email').fill(suiteUser.email);
await page.getByLabel('Password').fill(suiteUser.plainPassword);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
});
Per-Test User Override
test.describe('UC-XXX: [Use Case Name]', uc('UC-XXX'), () => {
test('suspended user sees account-locked screen',
meta('UC-XXX', { scenario: 'AF-2' }),
async ({ page, request }) => {
const suspendedUser = await createTestUser(request, {
accountType: 'BUYER',
status: 'SUSPENDED',
});
try {
await page.goto('/login');
await expect(page.getByLabel('Email')).toBeVisible();
await page.getByLabel('Email').fill(suspendedUser.email);
await page.getByLabel('Password').fill(suspendedUser.plainPassword);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Account suspended')).toBeVisible();
} finally {
await deleteTestUser(request, suspendedUser.id);
}
});
});
Entry Point — the only page.goto() in the test
await page.goto('/');
await expect(page.getByRole('heading')).toBeVisible();
Navigate Through the UI (never via URL)
await page.getByRole('link', { name: 'Items' }).click();
await expect(page.getByRole('heading', { name: 'Items' })).toBeVisible();
await page.getByRole('button', { name: 'Add New' }).click();
await page.locator('table tbody tr').first().click();
await expect(page.getByRole('heading')).toContainText('Item Details');
Verify Intermediate State Along the Journey
const rows = page.locator('table tbody tr');
await expect(rows.first()).toBeVisible();
await expect(rows).toHaveCount(10);
await page.getByRole('button', { name: 'Create' }).click();
Form Interactions
await page.getByLabel('Name').fill('Test Value');
await page.getByLabel('Category').selectOption('option-1');
await page.getByLabel('Active').check();
await page.getByRole('button', { name: 'Save' }).click();
Verify Final Outcome
await expect(page.getByText('Item created successfully')).toBeVisible();
await expect(page.locator('table tbody tr')).toContainText(['Test Value']);
Cleanup Entity Data Created During Test
await page.request.delete(`/api/examples/${createdId}`);
Dialog Interactions
page.on('dialog', dialog => dialog.accept());
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
Assertions Reference
| Assertion Type | Example |
|---|
| Text content | await expect(locator).toHaveText('Expected') |
| Input value | await expect(locator).toHaveValue('value') |
| Element count | await expect(locator).toHaveCount(5) |
| Visibility | await expect(locator).toBeVisible() |
| URL | await expect(page).toHaveURL('/path') |
| Enabled | await expect(locator).toBeEnabled() |
| Contains text | await expect(locator).toContainText('partial') |
Tracking
Read and follow the Before Implementation steps in ${CLAUDE_PLUGIN_ROOT}/shared/tracking/TRACKING.md.
Workflow
- Read the use case specification from
docs/use_cases/
- Read the frontend design from
docs/designs/ — extract screens, components, states, and navigation flow
- Plan the journeys — map each test to a complete path through the application:
- One test for the MSS: entry point → each screen in sequence → final outcome
- One test per AF that diverges from the MSS
- Note the entry point URL (the only
page.goto() allowed) and every UI interaction needed to complete each journey
- Identify which business rules are verified inline within each journey
- Use TodoWrite to create a task for each journey test (expect 3–8 tests total, not dozens)
- Ensure Testcontainers global setup exists (
e2e/global-setup.ts); create from template if missing
- Ensure global teardown exists (
e2e/global-teardown.ts); create from template if missing
- Ensure
playwright.config.ts references the global setup/teardown and has only Chromium (one project)
- Ensure the test user helper exists (
e2e/helpers/test-user.ts); create from template if missing
- Ensure the traceability helper exists (
e2e/helpers/traced.ts); create from templates/traced.ts if missing. Do not modify the template.
- Ensure the E2E users API endpoint exists (
app/api/e2e/users/route.ts and app/api/e2e/users/[id]/route.ts); create from template if missing
- Create test file using the template. Group every UC's tests in a top-level
test.describe('UC-NNN: ...', uc('UC-NNN'), () => {...}), and pass meta('UC-NNN', { scenario, ... }) (or bug('BUG-NNN') for pure regressions) as each test's second arg (see Traceability Convention)
- For each journey test:
- Set up suite-level user in
test.beforeAll (create via test API, log in via UI)
- For tests needing a custom user, create a per-test user override (see Common Patterns)
- Read the spec for the use case and identify which CR-NNN and BUG-NNN IDs (if any) the test verifies — populate the
verifies and fixes arrays in the meta(...) call accordingly
page.goto() to the login page (the only goto in the test), log in via UI
- After every navigation or action that loads a new page, wait for a specific UI element (heading, table row, form field) — never
waitForLoadState('networkidle')
- Navigate through each screen by clicking links, buttons, and submitting forms
- Verify intermediate states along the way (page loaded, data displayed, form visible)
- Perform the key interactions (fill forms, click actions, confirm dialogs)
- Assert the final outcome (success message, data in list, redirect to expected page)
- Clean up test-specific entity data if created during test
- Clean up per-test override users in
finally blocks or test.afterEach
- Clear cookies and storage in
test.afterEach to prevent session leakage between tests
- Delete suite user in
test.afterAll
- Run the
/code-quality skill
- Run all tests with
npx playwright test (no filters, no --grep, no --grep-invert, no --project subset)
- Verify the test results — this is mandatory before declaring success:
- The Playwright output must show 0 failed and the exit code must be 0
- If the output shows failures, timeouts, or errors, the tests did not pass — do not claim otherwise
- Count the number of passed tests and confirm it matches the number of tests you wrote
- If any test is skipped, that counts as a failure — investigate and fix it
- If a test failure reveals a route mismatch (e.g., test expects
/register but app uses /sign-up), this means the test is not navigating through the UI — fix the test to click through the real navigation instead of hardcoding URLs
- If a test fails at registration time with
[traced] CR-NNN not found under docs/... — the referenced doc does not exist. Either the ID is typo'd, the doc was renamed, or the CR/BUG was never created. Fix the reference, do not silence the helper.
- If a test fails:
- Check that Docker is running (Testcontainers requires it)
- Read the error message carefully and fix the root cause in the test or implementation
- Re-run
npx playwright test and go back to step 15
- Use
await page.screenshot() for debugging visual state if needed
- Do NOT skip, exclude, or filter out failing tests as a "fix"
- Mark todos complete
Post-Implementation Tracking
Read and follow the After Implementation steps in ${CLAUDE_PLUGIN_ROOT}/shared/tracking/TRACKING.md.