From nexa-claude-nextjs
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/nexa-claude-nextjs:playwright-testThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
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.
| 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:
If docs/designs/$ARGUMENTS-design.html does not exist, stop and tell the user to run /design-screens $ARGUMENTS first.
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:
A typical use case produces 3–8 tests total, not dozens.
What makes it E2E:
page.goto() to internal pagesafterEach or inline to verify deep system state if UI-only verification is insufficient.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.
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'], // optional: change requests this test asserts the delta of
fixes: ['BUG-NNN'], // optional: bugs this test guards against regressing
}),
async ({ page }) => {
// ... test body
});
test('[alternative flow description]',
meta('UC-XXX', { scenario: 'AF-1' }),
async ({ page }) => {
// ... test body
});
});
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 }) => {
// ... test body
});
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 NThe 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:
// CR-003: month/year picker
await page.locator('input[type="month"]').first().fill('2025-06');
// Verifies BR-001: [Rule Name]
await expect(page.getByText('Error')).toBeVisible();
// Verifies Success Postcondition: [Postcondition Name]
await expect(page.locator('table')).toContainText(['New Item']);
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 E2Eplaywright.config.ts must have exactly one project/api/e2e/users) insteadwaitForLoadState('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)page.waitForTimeout) instead of proper waitsDATABASE_URL is injected by global setup--grep-invert, --grep, test.skip(), test.fixme(), or any other mechanism to avoid running tests. All tests must run and passsleep, setTimeout, or any delay between retry attempts. The fix-then-rerun cycle must be immediateexpect(true).toBe(true) or assert only that a page loads without checking content)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 validationRead and follow ${CLAUDE_PLUGIN_ROOT}/shared/readiness/NEXA_RULES_GATE.md.
Read and follow ${CLAUDE_PLUGIN_ROOT}/shared/readiness/SPRINT_BRANCH_GATE.md.
Every E2E test requires an authenticated user. User provisioning operates at two levels:
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):
e2e-{random8chars}@example.comaccount_type: as needed by the use case (default to the most common type)status: "ACTIVE"email_confirmed: truetrueauth_provider: "EMAIL"created_at / updated_at: current timestampIndividual 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):
e2e-{random8chars}@example.comaccount_type: as needed by the specific test scenariostatus: as needed by the test scenario (e.g., "SUSPENDED", "PENDING")email_confirmed: as needed (e.g., false for unconfirmed-email flows)trueauth_provider: "EMAIL"created_at / updated_at: current timestampfinally blocks to ensure cleanup runs even on failure). Cookies are cleared by the suite-level afterEach.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.
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.
| 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 |
example.com for test emails and accounts (e.g., [email protected], [email protected]). This is an IANA-reserved domain that will never route real mail.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 TruthAll 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).
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: {
// .env.e2e is the single source of truth for E2E test env vars.
// `set -a` exports all sourced vars into the process environment.
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'] },
},
// Do NOT add firefox or webkit — single browser only for E2E
],
});
e2e/helpers/traced.ts. Do not modify; a future plugin update may bring fixes.import { test, expect } from '@playwright/test';
import { createTestUser, deleteTestUser, type TestUser } from './helpers/test-user';
let suiteUser: TestUser;
test.beforeAll(async ({ request }) => {
// Create the suite-level user via test API
suiteUser = await createTestUser(request, { accountType: 'BUYER' });
});
test.afterEach(async ({ context }) => {
// Clear cookies and storage between tests to prevent session leakage
await context.clearCookies();
});
test.afterAll(async ({ request }) => {
await deleteTestUser(request, suiteUser.id);
});
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 }) => {
// Start at login — the ONLY page.goto() allowed
await page.goto('/login');
await expect(page.getByLabel('Email')).toBeVisible();
// Log in as the suite user
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();
// ... continue the journey
});
});
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 }) => {
// Create a custom user for this test
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();
// Verify suspended user sees the locked screen
await expect(page.getByText('Account suspended')).toBeVisible();
} finally {
// Always clean up the per-test user
await deleteTestUser(request, suspendedUser.id);
}
});
});
page.goto() in the test// Start at the application entry point — the ONLY page.goto() allowed
await page.goto('/');
await expect(page.getByRole('heading')).toBeVisible();
// Click a navigation link to reach the next screen
await page.getByRole('link', { name: 'Items' }).click();
await expect(page.getByRole('heading', { name: 'Items' })).toBeVisible();
// Click a button to open a form/modal
await page.getByRole('button', { name: 'Add New' }).click();
// Click a table row to navigate to detail
await page.locator('table tbody tr').first().click();
await expect(page.getByRole('heading')).toContainText('Item Details');
// After navigating to the list screen, verify it loaded correctly
const rows = page.locator('table tbody tr');
await expect(rows.first()).toBeVisible();
await expect(rows).toHaveCount(10);
// Then continue the journey...
await page.getByRole('button', { name: 'Create' }).click();
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();
// After form submission, verify the result is visible
await expect(page.getByText('Item created successfully')).toBeVisible();
await expect(page.locator('table tbody tr')).toContainText(['Test Value']);
// Clean up non-user test data via API at end of test
await page.request.delete(`/api/examples/${createdId}`);
page.on('dialog', dialog => dialog.accept());
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
| 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') |
Read and follow the Before Implementation steps in ${CLAUDE_PLUGIN_ROOT}/shared/tracking/TRACKING.md.
docs/use_cases/docs/designs/ — extract screens, components, states, and navigation flowpage.goto() allowed) and every UI interaction needed to complete each journeye2e/global-setup.ts); create from template if missinge2e/global-teardown.ts); create from template if missingplaywright.config.ts references the global setup/teardown and has only Chromium (one project)e2e/helpers/test-user.ts); create from template if missinge2e/helpers/traced.ts); create from templates/traced.ts if missing. Do not modify the template.app/api/e2e/users/route.ts and app/api/e2e/users/[id]/route.ts); create from template if missingtest.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)test.beforeAll (create via test API, log in via UI)verifies and fixes arrays in the meta(...) call accordinglypage.goto() to the login page (the only goto in the test), log in via UIwaitForLoadState('networkidle')finally blocks or test.afterEachtest.afterEach to prevent session leakage between teststest.afterAll/code-quality skillnpx playwright test (no filters, no --grep, no --grep-invert, no --project subset)/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[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.npx playwright test and go back to step 15await page.screenshot() for debugging visual state if neededRead and follow the After Implementation steps in ${CLAUDE_PLUGIN_ROOT}/shared/tracking/TRACKING.md.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub nexadevapp/nexa-claude-skills-marketplace --plugin nexa-claude-nextjs