Learn-skills.dev e2e-test-conventions
git clone https://github.com/NeverSight/learn-skills.dev
T=$(mktemp -d) && git clone --depth=1 https://github.com/NeverSight/learn-skills.dev "$T" && mkdir -p ~/.claude/skills && cp -r "$T/data/skills-md/agentmantis/test-skills/e2e-test-conventions" ~/.claude/skills/neversight-learn-skills-dev-e2e-test-conventions && rm -rf "$T"
data/skills-md/agentmantis/test-skills/e2e-test-conventions/SKILL.mdE2E Test Conventions
These conventions apply to all Playwright E2E test code. Read and follow them whenever generating, modifying, or reviewing tests.
Technology Stack
- Playwright for browser automation and test running
- TypeScript for all test code (no plain JavaScript)
- Framework-agnostic — adapt selectors and helpers to whatever UI framework the project uses
Project Structure
The
e2e/ directory follows this layout:
e2e/ ├── auth/ # Authentication setup (runs before all tests) │ └── auth.setup.ts # Logs in and saves session state ├── fixtures/ # Custom Playwright fixtures │ └── base.ts # Extends Playwright's test with custom fixtures ├── helpers/ # Shared utility functions (NOT page objects) │ └── env-config.ts # Environment resolution ├── poms/ # Page Object Models (one file per page) │ └── base.page.ts # Abstract base class — all POMs extend this ├── test-data/ # External test data (JSON files) ├── tests/ # Test specs organised by suite │ ├── handover/ # Ticket-driven handover tests (temporary) │ ├── regression/ # Full regression tests (permanent) │ └── smoke/ # Quick critical-path checks ├── playwright.config.ts ├── tsconfig.json └── .env.example # Template for environment variables
Rules:
- Do NOT create files outside this structure
- Do NOT move existing files without explicit permission
See references/folder-structure.md for details on each directory.
Browser × Suite Configuration
The Playwright config defines projects using
{browser}:{suite} naming:
Browsers:
chromium, firefox, webkit, mobile-chrome, mobile-safari
Suites:
| Suite | Directory | Purpose | Lifecycle |
|---|---|---|---|
| | Full regression | Permanent |
| | Ticket-driven handover | Temporary — promote or delete |
| | Critical-path sanity | Permanent |
Every project depends on the
setup project which handles authentication.
Running Tests
# From e2e/ directory npx playwright test # All browsers × all suites npx playwright test --project="chromium:regression" # Single project npx playwright test --project="*:smoke" # One suite, all browsers npx playwright test --project="firefox:*" # One browser, all suites
Naming Conventions
| Type | Pattern | Example |
|---|---|---|
| Page Object Model | in | |
| Regression spec | in | |
| Smoke spec | in | |
| Handover spec | in | |
| Test data | in | |
The
{feature} name must match across POM, spec, and test-data files.
Test Independence and Parallelism
Every Test Must Be Fully Independent
- Each test runs in isolation and in any order
- A test must never depend on state created by a previous test
- Each test navigates to its page via the POM's navigation method
Parallel Safety
All tests run in parallel across multiple workers. To avoid collisions:
- Append unique suffixes (timestamp + random ID) to all test data values
- Never share mutable state between tests
- Each test creates its own data, verifies it, and cleans it up
Authentication
Authentication is performed once in a setup project (
auth/auth.setup.ts) that runs before all test projects. The session state is saved to a file and reused via Playwright's storageState configuration.
NEVER write login logic in specs, POMs,
beforeEach, or beforeAll. Authentication is already handled.
If the application stores auth tokens in IndexedDB (Firebase Auth, Supabase, AWS Amplify, etc.), use the
indexedDB option:
await page.context().storageState({ path: authFile, indexedDB: true });
See references/auth-setup.md for the full pattern.
Navigation
- Always use direct URL navigation (
)page.goto('/dashboard') - Do NOT click through menus or sidebars — menu state and animations cause flaky tests
- After navigating, assert the URL and a key heading/element are visible
BasePage vs Derived POM Methods
BasePage is the single home for reusable helper methods that are useful across multiple pages. Derived POMs should stay focused on page-specific behavior only.
When a method belongs in BasePage
BasePageMove a helper to
BasePage when it is:
- Reusable across multiple POMs — e.g., dismissing a modal, waiting for a toast notification, or checking a loading spinner
- Not tightly coupled to a single page — it works the same regardless of which page is active
- Generic enough to be inherited cleanly — no page-specific selectors or assumptions
- Likely to be duplicated if left in a feature-specific POM
Examples of
BasePage helpers:
— waits for and verifies a toast notificationwaitForToast(message)
— closes any open modal dialogdismissModal()
— waits for a loading spinner to disappearwaitForLoadingComplete()
— counts rows in a data table present on many pagesgetTableRowCount()
When a method belongs in a derived POM
Keep a helper in the specific POM when it is:
- Unique to one page — e.g., filling a page-specific form
- Dependent on page-specific structure — uses selectors that only exist on that page
- Not expected to be reused elsewhere
Why this matters
- Reduces duplication — shared logic lives in one place instead of being copied across POMs
- Centralises shared behavior — updates to a common helper propagate to all POMs automatically
- Keeps derived POMs small and focused — each POM only contains what is specific to its page
- Improves discoverability — developers know to look at
for shared utilitiesBasePage
Rule
If you find yourself writing the same helper in a second POM, promote it to
immediately. Do not leave duplicate helpers scattered across feature POMs.BasePage
Selectors and Locator Strategy
Use this priority order:
— buttons, headings, dialogs (most resilient)getByRole()
— form fieldsgetByLabel()
— visible text contentgetByText()
— input placeholdersgetByPlaceholder()
with CSS /locator()
— last resortfilter()
Tips:
- Dynamically rendered attributes (tooltips, popovers) may not be in the DOM at query time — use
to match child contentfilter() - Icon buttons often lack visible text — match by
or child icon contentaria-label - Prefer role-based and label-based selectors over CSS classes (brittle and framework-specific)
See references/selector-priority.md for examples.
Environment Configuration
Each environment has its own
.env.{env} file in the e2e/ directory:
| File | Environment |
|---|---|
| Local development |
| Development server |
| Test server |
| UAT server |
| Production |
Every file uses the same variable names — only the values differ:
BASE_URL="https://your-app.example.com" LOGIN_EMAIL="test-user@example.com" LOGIN_PASSWORD="your-password" AUTH_FILE="e2e/.auth/user.json"
Selecting the Active Environment
TEST_ENV is required. If it is not set, env-config.ts throws immediately — the test run will not start. This prevents accidental E2E runs against production when someone forgets to set the variable.
TEST_ENV=dev npx playwright test # loads .env then .env.dev TEST_ENV=production npx playwright test # loads .env then .env.production npx playwright test # ❌ ERROR — TEST_ENV is not set
Two-Layer Loading
helpers/env-config.ts reads TEST_ENV and loads environment variables in two layers via dotenv:
— base file (can holde2e/.env
and all variables)TEST_ENV
— optional environment-specific overridee2e/.env.{env}
Both files are optional. If neither exists the process relies on variables already present in the environment (e.g. injected by CI or a container).
dotenv never overwrites a variable that is already set, so CLI exports and CI-injected values always win.
The module exports helper functions —
getEnvConfig(), getBaseUrl(), getCredentials(), and getAuthFilePath() — instead of a static constant. Loading runs once per process; subsequent calls are no-ops.
Rules
is required — missing it throws an error so E2E runs never silently target productionTEST_ENV- All variables (
,BASE_URL
,LOGIN_EMAIL
,LOGIN_PASSWORD
) are required — missing ones throw an errorAUTH_FILE - Never fall back to hardcoded defaults for required environment values; throw an error if they are missing
files contain secrets and are git-ignored.env.*
is the only env file committed — it serves as the template.env.example
Test Data
- All test data lives in
e2e/test-data/{feature}.json - Never hardcode data in spec or POM files
- Use a custom fixture to load and stamp test data with unique suffixes
- Always import
from your custom fixtures, not fromtest@playwright/test
Spec File Rules
- All page interaction goes through POMs — never call
,page.getByRole()
, etc. directly in specspage.locator() - The only acceptable uses of
in a spec: passing to POM constructor, or creating contexts inpagebeforeAll - Use
with a manually created browser context to call POMtest.beforeAll
for cleanupsetUp() - Each test navigates independently via POM navigation methods