Tool · Template
AI test automation standards.
Hand a coding agent a test suite with no guidance and it writes tests its own way: brittle selectors, fixed waits, empty test IDs, every convention you have ignored. This is the file that makes it write them your way. Pick your framework, copy it, and drop it at your repo root as CLAUDE.md or AGENTS.md.
Each tab is a real, complete standards file for that runner, not a generic template with the commands swapped. Claude Code reads CLAUDE.md; Cursor, Codex, and Copilot read AGENTS.md. It is one file with two names, so download whichever your tools expect. Drop it at your repo root, edit the bracketed parts for your app, and commit it, so the whole team and every agent share one set of conventions.
# Cypress End-to-End Test Suite: standards for AI agents
This file is for a Cypress end-to-end suite. Drop it at your repo root as CLAUDE.md
(Claude Code reads it) or AGENTS.md (Cursor, Codex, and Copilot read it). It is what
makes a coding agent write Cypress tests your way instead of its own. Edit the
[bracketed] parts for your app.
## Architecture
- Driver pattern: all Cypress interactions go through driver utility functions, never
raw cy commands in specs.
- Page Object Model (POM): all selectors, text, and URL params live in page object files.
- Role-based organisation: tests organised by feature > role > spec.
- Path alias: ~/ maps to cypress/ (configured in tsconfig.json).
## Critical rules
### 1. Driver-only interactions
ALL Cypress interactions MUST use driver utilities. Never use raw cy.get(), cy.contains(),
cy.url(), cy.click() etc. in spec files.
Allowed exceptions:
- cy.get("@alias") for Cypress aliases (e.g. cy.get("@userName"))
- cy.get("body").then(($body) => ...) for conditional DOM checks (synchronous jQuery)
- cy.window(), cy.session() inside custom commands only
If no driver exists for what you need, create one in the appropriate driver file.
### 2. No inline values in specs
All text strings, regex, URL fragments, and selectors live in page object files. Nothing
hardcoded inline in a spec.
### 3. No inline comments
Test titles and driver names are self-documenting. Do not add comments inside spec files.
### 4. No cy.wait()
Never use cy.wait(ms) for arbitrary delays. Instead:
- waitOnAlias() after API calls
- elementShouldExist() / validateElementIsVisible() for DOM readiness
### 5. Test IDs (HARD RULE)
Every it() must start with a non-empty ID in square brackets. An empty [] never ships.
- Carry over an existing case ID from your test-case manager (e.g. [C1234]) when the test
covers a mapped scenario, including when porting it into a new spec.
- New scenario with no mapping: a short per-feature prefix plus a zero-padded number, e.g.
[chk-001], [chk-002]. Keep the prefix consistent per feature; never renumber existing IDs.
### 6. Stable selectors
Use attributes from your component library: data-* slots (e.g. [data-slot="table-body"]),
role (e.g. [role="dialog"]), aria-label, or id with a partial match ([id*="trigger-"]).
Avoid class names, DOM hierarchy, and nth-child selectors.
### 7. Reusable helpers in driver files
Multi-step workflow actions (select group + wait, open drawer, dismiss dialog) belong in
drivers_{feature}.ts files, not inline in specs.
### 8. Explicit beforeEach
Keep login, intercept setup, navigation, and dismissals explicit in each spec's beforeEach,
so you can read what each test starts with. Do not hide navigation inside drivers.
### 9. Reusable helpers in the POM
Multi-step actions shared between specs of the same feature go in that feature's page object
file (when tightly coupled to its selectors/text) or a drivers_{feature}.ts file (when they
orchestrate multiple drivers or are shared across features).
## Driver design rules
Drivers represent USER ACTIONS, not Cypress primitives.
- Good: selectGroupAndWaitForData(), openSavedView(name), openFirstRowDrawer(), dismissWelcomeDialog()
- Bad: clickButton(selector), typeIntoInput(selector) (too generic, exposes implementation)
Drivers should encapsulate selectors (callers never pass raw selectors), encapsulate waits
(intercept + action + waitOnAlias inside the driver), and represent real workflows. When a
feature exceeds ~5 helpers, give it a dedicated drivers_{feature}.ts file.
## Test isolation
Each test is independent and self-contained:
- Every it() works regardless of execution order.
- Never depend on state from a previous test.
- beforeEach sets up all required state (login, navigation, data prerequisites).
- cy.session() caches login for speed, but each test navigates fresh.
Sharing state between tests (e.g. skipping login because a previous test logged in) is forbidden.
## Data management (live environments)
Tests run against live environments with real data:
- Be resilient to data variations: no exact-count or specific-name assertions.
- Use regex for dynamic values (numericPattern: /\d+/) and "at least" assertions
(validateElementLengthIsAtLeast(selector, 1)).
- When data may not exist, use cy.get("body").then() for a synchronous check instead of a
retrying assertion that will time out.
- Never create or delete production data. Tests are read-only observers.
## Assertions philosophy
Validate what the USER sees, not implementation details:
- validateContainsElementIsVisible() for text on screen
- validateURLContainsString() for navigation state
- validateElementLengthIsAtLeast() rather than exact counts
- validateContainsElementParentTextMatches() with regex for dynamic numbers
Prefer positive assertions. Use negative ones (elementShouldNotExist) only for access-denied
and permission tests.
## Retry and flakiness
Cypress retries assertions (defaultCommandTimeout). Use it correctly. Synchronisation, in
order of preference:
1. Wait on an API alias: waitOnAlias("groups") after actions that trigger calls.
2. Validate a UI state change: validateElementIsVisible() / elementShouldExist().
3. Validate element content: validateContainsElementIsVisible("expected text").
Prevention:
- Never cy.wait(ms) for timing; wait on a real signal.
- For elements that may not exist, use cy.get("body").then() synchronous checks.
- Intercept a new API call BEFORE the click that triggers it, then wait on it.
- Fix flaky tests rather than leaning on run-mode retries.
Config: defaultCommandTimeout 20000, pageLoadTimeout 60000, retries { runMode: 2, openMode: 0 }.
## Test readability (the shape every spec follows)
describe("Admin: Feature name", { tags: ["@feature", "@regression"] }, () => {
beforeEach(() => {
cy.validLogin("admin");
interceptAndAssignAlias("GET", "**/api/**someEndpoint**", "alias");
navigateTo(PAGENAMES_URLS["Page name"]);
waitOnAlias("alias");
dismissWelcomeDialog(); // if applicable
});
it("[chk-001] Verify admin can perform the action", () => {
selectGroupAndWaitForData();
validateContainsElementIsVisible(featurePO.staticText.expectedText);
});
});
Tests are short and focused (one behaviour each). Action first, then validation. The title
says what the user can do, not how.
## API alias naming
Aliases describe the endpoint purpose (groups, userDetails, savedViews, dashboard). Avoid
generic names (api1, data, response). Suffix repeats with a number (dashboard2).
## API intercept pattern
Always set up the intercept BEFORE the action that triggers it:
interceptAndAssignAlias("GET", "**/api/**groups.list**", "groups");
navigateTo(PAGENAMES_URLS["Dashboard"]);
waitOnAlias("groups");
For calls triggered mid-test, intercept right before the action, then wait on it.
## What AI should NEVER generate
- cy.wait(5000) or any cy.wait(ms): use waitOnAlias() or element checks
- cy.get("selector").click() in a spec: use a driver
- inline selectors or inline text: they go in page objects
- conditional logic in a spec (except cy.get("body").then() for empty-state checks)
- hardcoded timeouts: use configured defaults or waitOnAlias
- comments in specs: code is self-documenting
- cy.contains("text"): use clickElementContainingText() or validateContainsElementIsVisible()
- generic driver names like clickButton(): drivers represent user actions
- tests that depend on another test's state
- exact-count assertions on live data: use "at least" or pattern matching
- it("[] ..."): an empty test ID is forbidden
When a required driver does not exist, create it rather than using a raw command.
## File structure
cypress/
e2e/{feature}/{role}/ specs organised by feature then role
pages/
common_navigation_constants.ts URL map: PAGENAMES_URLS
page_objects/{feature}_page.ts page objects per feature
support/
commands.ts custom commands (validLogin, etc.)
e2e.ts global hooks
utils/
drivers.ts core interactions: click, type, navigate, get, clear
drivers_validation.ts assertions: visibility, text, URL, existence
drivers_requestHandler.ts intercepts: interceptAndAssignAlias, waitOnAlias
drivers_tableUtils.ts table lookups
drivers_{feature}.ts feature-specific multi-step workflows
Spec naming: {feature}_{role}_spec.cy.ts (e.g. dashboard_admin_spec.cy.ts).
## Page object pattern
Each page object file exports named objects with a consistent prefix:
export const dashUrlParams = { tabOpen: "tab=open", groupId: "groupId=" };
export const dashStaticText = { title: "Dashboard", numericPattern: /\d+/ };
export const dashSelectors = { tableBody: '[data-slot="table-body"]', drawer: '[role="dialog"]' };
Import as a namespace: import * as dashPO from "~/pages/page_objects/dashboard_page";
## Import order
constants > page objects > drivers > drivers_validation > drivers_requestHandler > feature drivers
## Environment variables and login
Specs never read credentials directly. Log in by ROLE with cy.validLogin("admin"); the command
resolves the matching username plus the shared password internally. Define one env var per role
(e.g. CYPRESS_ADMIN_USERNAME, CYPRESS_EDITOR_USERNAME, CYPRESS_VIEWER_USERNAME) plus CYPRESS_PASSWORD.
## Tags
Apply tags on describe blocks, not individual tests:
describe("Feature", { tags: ["@feature", "@regression"] }, () => { ... });
Run filtered: cypress run --env grepTags=@feature,grepFilterSpecs=true
## Common patterns
Empty tables (do not let a retrying query time out):
cy.get("body").then(($body) => {
if ($body.find(`${dashPO.dashSelectors.tableBody} tr`).length > 0) { /* validate rows */ }
});
Dismiss a popup only if present:
cy.get("body").then(($body) => {
if ($body.find(`:contains("${dashPO.dashStaticText.notNow}")`).length > 0) {
clickElementContainingText("button", dashPO.dashStaticText.notNow);
}
});
## PR review checklist
- Specs use only driver utilities (zero raw cy commands except the allowed exceptions)
- All text and selectors live in page objects (zero inline values in specs)
- No inline comments, no cy.wait(ms)
- Each test is independent (reorder and they still pass)
- Imports follow the standard order; feature helpers in drivers_{feature}.ts
- beforeEach is explicit and readable; every it() has a real ID
# Playwright end-to-end test suite: standards for AI agents
This file is for a Playwright end-to-end suite. Drop it at your repo root as CLAUDE.md
(Claude Code reads it) or AGENTS.md (Cursor, Codex, and Copilot read it). It is what makes
a coding agent write Playwright tests your way instead of its own. Edit the [bracketed]
parts for your app.
## Architecture
- Page object model (POM): every locator and page action lives in a page object class.
A spec never builds a locator inline.
- Fixtures over hooks: tests receive what they need (page objects, seeded data, auth) via
test.extend, so each test is isolated and parallel-safe.
- Authentication via storageState: a setup project signs in once and saves the session;
test projects reuse it. Nobody logs in through the UI in every test.
- Role-based organisation: tests organised by feature, then role, then spec.
- Path alias: ~/ maps to tests/ (configured in tsconfig.json "paths").
## Critical rules
### 1. Locators, never raw selectors
Build locators from user-facing queries, in this order of preference:
- getByRole("button", { name: "Save" }): the default; matches what the user and a screen
reader see.
- getByLabel, getByPlaceholder, getByText: for inputs and copy.
- getByTestId("user-menu"): for elements with no accessible name or ambiguous text.
- CSS (page.locator("css=...")): last resort, only for a stable data-* attribute.
- XPath: never.
Chain and filter instead of writing a clever selector:
page.getByRole("listitem").filter({ hasText: "[Item name]" }).getByRole("button", { name: "Remove" })
### 2. Locators live in page objects, not in specs
A spec never calls page.getByRole(...) or page.locator(...) directly. It calls a page object
method. All selector knowledge stays in one place so a markup change touches one file.
### 3. Web-first assertions only for UI state
Assert through expect(locator) so Playwright auto-waits and retries:
await expect(page.getByText("Saved")).toBeVisible();
await expect(saveButton).toBeEnabled();
await expect(page).toHaveURL(/\/dashboard/);
await expect(row).toHaveText(/\d+ items/);
Never assert a plain snapshot value for UI state. This does not retry and will flake:
expect(await locator.isVisible()).toBe(true); // forbidden for UI state
### 4. No hard waits
Never page.waitForTimeout(ms). There is no acceptable arbitrary sleep. Instead:
- Lean on auto-waiting locators and web-first assertions (rules 1 and 3): they wait for the
element to be actionable or the condition to hold.
- For a real backend signal, wait on the response tied to the action (see rule 5).
- For a non-UI condition, use expect.poll(async () => ...).toBe(...) or expect(locator).toPass.
### 5. Wait on the response, not the clock
When an action triggers a request you must observe, start the wait BEFORE the action, then
await both, so a fast response cannot arrive before you start listening:
const responsePromise = page.waitForResponse((r) =>
r.url().includes("/api/[resource]") && r.request().method() === "POST");
await [page object].submit();
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
Use waitForRequest the same way when you care about the outgoing call. Prefer this over
re-querying the DOM in a loop.
### 6. Authenticate once with storageState
A setup project signs in a single time and writes the session to disk; every test project
loads it. Do not drive the login form in each test.
// auth.setup.ts
setup("authenticate as [role]", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(process.env.[ROLE]_USERNAME!);
await page.getByLabel("Password").fill(process.env.APP_PASSWORD!);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "[Home heading]" })).toBeVisible();
await page.context().storageState({ path: "playwright/.auth/[role].json" });
});
The test project sets use.storageState to that file and dependencies: ["setup"] (see config).
### 7. Fixtures give each test exactly its state
Extend the base test once, in a fixtures file, to inject page objects and seeded data. Tests
declare what they need as parameters and get a fresh instance each time.
export const test = base.extend<{ dashboardPage: DashboardPage }>({
dashboardPage: async ({ page }, use) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goto();
await use(dashboardPage);
},
});
Import this test (not the one from @playwright/test) in every spec.
### 8. Every test has a real title
Every test(...) starts with a non-empty, specific title. An empty title never ships. Carry
an existing case ID from your test-case manager when the test maps to one:
test("[C1234] User can save a [resource]", ...).
New scenario with no mapping: a short per-feature prefix plus a number, e.g. "[chk-001] ...".
Keep the prefix consistent per feature; never renumber existing IDs.
### 9. Seed state through the API, not the whole UI
To reach a starting point, create state with the request fixture (APIRequestContext), not by
clicking through screens you are not testing:
test("[chk-014] List shows a created [resource]", async ({ request, listPage }) => {
const created = await request.post("/api/[resource]", { data: { name: uniqueName } });
expect(created.ok()).toBeTruthy();
await listPage.goto();
await expect(listPage.rowByName(uniqueName)).toBeVisible();
});
Drive the UI only for the behaviour under test.
## Page object design rules
A page object is a class holding readonly locators built from this.page.getBy..., plus methods
that represent USER ACTIONS.
export class DashboardPage {
readonly page: Page;
readonly groupSelect: Locator;
readonly savedViewMenu: Locator;
constructor(page: Page) {
this.page = page;
this.groupSelect = page.getByRole("combobox", { name: "Group" });
this.savedViewMenu = page.getByRole("button", { name: "Saved views" });
}
async goto() { await this.page.goto("/dashboard"); }
async selectGroup(name: string) { await this.groupSelect.selectOption({ label: name }); }
async openSavedView(name: string) {
await this.savedViewMenu.click();
await this.page.getByRole("menuitem", { name }).click();
}
}
- Good methods: selectGroup(name), openSavedView(name), openFirstRowDrawer(), dismissWelcomeDialog().
- Bad methods: clickButton(selector), typeInto(selector): too generic, leak implementation.
Methods encapsulate the locators (callers never pass raw selectors) and represent real workflows.
A method may expose a locator (return this.rowByName(name)) so the spec can assert on it.
## Test isolation and parallelism
Playwright runs files in parallel across workers, and each test gets its own browser context
(fresh cookies and storage). Keep that guarantee:
- Every test passes regardless of order; never depend on another test's state.
- Seed unique data per test (suffix names with a timestamp or a per-worker id) so parallel
tests do not collide.
- Do not share mutable module-level variables between tests.
- Reuse login through storageState, not by running an earlier "login test" first.
## Data management (live environments)
Tests run against live environments with real, shifting data:
- No exact-count or specific-name assertions on data you did not create. Use "at least" and
regex: await expect(rows).toHaveCount(... ) is fine only for data you seeded.
- For ambient data, assert toContainText, a regex toHaveText(/\d+/), or count with a lower
bound: expect(await rows.count()).toBeGreaterThan(0).
- Guard possibly-empty UI before asserting on contents:
if (await rows.count() > 0) { await expect(rows.first()).toBeVisible(); }
- Never create or delete production data outside data you own. Treat shared records as read-only.
## Assertions philosophy
Validate what the USER sees, not implementation details:
- toBeVisible / toHaveText / toContainText for on-screen content.
- toHaveURL for navigation state.
- toBeGreaterThan or a regex rather than an exact count on live data.
Prefer positive assertions. Use negative ones (toHaveCount(0), not.toBeVisible) only for
access-denied and permission checks. Reach for expect.soft only when you genuinely want the
test to continue and report several failures at once.
## Flakiness and synchronisation
Auto-waiting and web-first assertions remove most timing problems. In order of preference:
1. Web-first assertion on the resulting UI: await expect(banner).toBeVisible().
2. Wait on the response tied to the action: the responsePromise pattern in rule 5.
3. expect.poll for a non-UI condition that has no locator.
Prevention:
- Never waitForTimeout(ms); wait on a real signal.
- Start waitForResponse before the action that triggers it.
- Fix a flaky test; do not lean on CI retries to paper over it.
Config: retries: process.env.CI ? 2 : 0, trace: "on-first-retry". Retries surface flakiness in
the report; they are not the fix.
## Test readability (the shape every spec follows)
import { test } from "~/fixtures";
import { expect } from "@playwright/test";
test.describe("Admin: [Feature name]", () => {
test("[chk-001] Admin can [do the thing]", async ({ dashboardPage }) => {
await dashboardPage.selectGroup("[Group name]");
await expect(dashboardPage.resultsTable).toBeVisible();
});
});
Tests are short and focused (one behaviour each). Action first, then assertion. The title says
what the user can do, not how. No comments inside the test: the page object method names and
the title carry the meaning.
## playwright.config.ts notes
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "html",
use: { baseURL: process.env.BASE_URL, trace: "on-first-retry" },
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/[role].json" },
dependencies: ["setup"],
},
],
});
- baseURL lets page objects call page.goto("/dashboard") with relative paths.
- fullyParallel plus the setup dependency: sign in once, then run everything in parallel.
## What AI should NEVER generate
- page.waitForTimeout(...) or any hard sleep: wait on a locator, an assertion, or a response.
- XPath, or a brittle CSS locator tied to class names or DOM hierarchy: use getByRole and friends.
- a raw page.locator(...) or page.getBy... action inside a spec when a page object exists:
add or call a page object method instead.
- logging in through the UI in every test: authenticate once via storageState.
- a non-retrying assertion for UI state, e.g. expect(await x.isVisible()).toBe(true): use
await expect(x).toBeVisible().
- tests that depend on each other or on execution order.
- exact-count or specific-name assertions on live data you did not seed: use "at least" or regex.
- a missing or empty test title: every test has a real, specific name.
When a needed page object or method does not exist, create it rather than reaching into the page.
## File structure
tests/
[feature]/[role]/[feature].[role].spec.ts specs by feature then role
pages/[feature].page.ts one page object class per feature
fixtures.ts test.extend: page objects, seeded data
auth.setup.ts signs in per role, writes storageState
playwright/.auth/[role].json saved sessions (gitignored)
playwright.config.ts projects, retries, trace, baseURL
Spec naming: [feature].[role].spec.ts (e.g. dashboard.admin.spec.ts).
## Page object conventions
- One class per feature; constructor assigns every locator to a readonly field.
- Locators are built from this.page.getBy... (role first), never stored as bare selector strings.
- Methods are user actions; a method may return a locator for the spec to assert on.
- Shared multi-step flows that span features go in a helper page object the others compose,
not copied into each spec.
## Environment variables and login
Specs never read credentials directly. The setup project resolves a username per role plus a
shared password from env: one variable per role (e.g. ADMIN_USERNAME, EDITOR_USERNAME,
VIEWER_USERNAME) plus APP_PASSWORD, and BASE_URL for the target environment. Tests pick a role
by loading that role's storageState through their project.
## PR review checklist
- Locators come from page objects only (zero page.getBy / page.locator calls in specs).
- User-facing locators use getByRole / getByLabel / getByText; no XPath, no brittle CSS.
- UI assertions are web-first (await expect(locator)...); no expect(await ...isVisible()).
- No page.waitForTimeout anywhere; waits are on locators, assertions, or responses.
- Auth comes from storageState; no UI login inside tests.
- Each test is independent and parallel-safe; seeded data is unique per test.
- Setup state is created via the request fixture, not by driving unrelated UI.
- No exact-count or specific-name assertions on live data; every test has a real title.
# WebdriverIO end-to-end test suite: standards for AI agents
This file is for a WebdriverIO end-to-end suite using modern async/await WebdriverIO
(version 8 or 9). Drop it at your repo root as CLAUDE.md (Claude Code reads it) or
AGENTS.md (Cursor, Codex, and Copilot read it). It is what makes a coding agent write
WebdriverIO tests your way instead of its own. Edit the [bracketed] parts for your app.
## Architecture
- Async/await everywhere: every WebdriverIO command returns a promise and MUST be awaited
(await $('...').click()). The only thing you do not await is the $ / $$ selector itself when
you chain straight into an action.
- Page object model: the page details (selectors, text, URLs) live in one class per page or
feature, with selectors as private getters, so a redesign touches one file. Specs reach the UI
only through that class, which extends a shared Page that owns open() and common helpers.
- Role-based organisation: specs organised by feature, then by the role under test.
- Path alias: @pageobjects maps to test/pageobjects (configured in tsconfig.json paths).
## Critical rules
### 1. Page-object-only interactions
Specs NEVER call $() or $$() directly and NEVER read or click a raw element. All UI access
goes through a page object method or getter. A redesign should touch the page object, never
a spec. If no method exists for what you need, add one to the page object.
### 2. Selectors are private to the page object
Selectors live in private getters on the page object class (private fields with a leading #).
A spec can never see or pass a selector string. Expose user actions (login(), openFirstPost()),
not the elements behind them.
### 3. Await every command
Every interaction and assertion is awaited: await this.#submitButton.click(),
await expect(page.heading).toHaveText('[Title]'). A missing await is a real bug: it races and
flakes. Do not await the bare getter (const el = await page.heading); chain the action instead.
### 4. No hard pause, ever
NEVER browser.pause(ms) to wait for timing. Wait on a real signal instead:
- await el.waitForDisplayed() before reading or asserting on an element
- await el.waitForClickable() before clicking something that mounts or enables late
- await el.waitForExist() when you only need it in the DOM
- await browser.waitUntil(condition, { timeout, timeoutMsg }) for any custom condition
### 5. Test IDs (HARD RULE)
Every it() title starts with a non-empty ID in square brackets. An empty [] never ships.
- Carry over an existing case ID from your test-case manager (e.g. [C1234]) when the test
maps to a known scenario, including when you port it into a new spec.
- New scenario with no mapping: a short per-feature prefix plus a zero-padded number, e.g.
[post-001], [post-002]. Keep the prefix consistent per feature; never renumber existing IDs.
### 6. Stable, accessible selectors
Prefer hooks that resemble how a user finds things, in this order:
- accessibility name: $('aria/Submit'), $('aria/[Search posts]')
- visible text: $('button=Subscribe'), $('a*=Read more') for a partial match
- test hooks: $('[data-testid="post-card"]') or another data-* attribute
- role and stable id: $('[role="dialog"]'), $('#search')
Avoid brittle deep CSS, nth-child, generated class names, and XPath. $ and $$ are expensive
DOM queries: limit them, and chain ($('table').$('tr')) only to combine strategies, never to
walk the tree (prefer one $('table tr td') over three chained queries).
### 7. Reusable user actions
Multi-step workflows live as methods on the page object (submitComment(text)), or as a custom
command via browser.addCommand when shared across features (login by role, dismiss a banner).
Specs call the action; they never assemble low-level commands inline.
### 8. Explicit beforeEach
Keep login, navigation, and any dismissals explicit in each spec's beforeEach, so you can read
the state each test starts from. Do not bury navigation deep inside an unrelated method.
## Page object design rules
Page objects expose USER ACTIONS, not WebdriverIO primitives.
- Good: BlogPage.search('[term]'), PostPage.submitComment('[text]'), Nav.openSection('[Resources]')
- Bad: clickButton(selector), typeInto(selector, text) (too generic, leaks the implementation)
Each page object encapsulates its selectors (callers never pass a raw selector), encapsulates its
waits (waitForClickable inside the click action), and represents real workflows. Selectors are
getters so they evaluate lazily: WebdriverIO re-queries the element on each access, avoiding
stale-element references after the DOM re-renders. Export one instance, not the class.
## Test isolation
Each test is independent and order-free:
- Every it() passes regardless of execution order; reorder the file and it still passes.
- Never depend on state a previous test created (a comment it posted, a page it left open).
- beforeEach sets up all required state (login, navigation, prerequisites).
- Cache login for speed (reuse a stored session or a token), but each test starts from a known
state and navigates fresh. Skipping login because an earlier test logged in is forbidden.
## Data management (live environments)
Tests run against a live blog with real, changing content:
- Be resilient to data variations: no exact-count and no specific-title assertions (today's
newest post will not be there next month). Use "at least" with
await expect(posts).toBeElementsArrayOfSize({ gte: 1 }), and patterns for dynamic values:
toHaveText(/\d+ comments/).
- Guard possibly-empty UI: check await page.postCards.length before asserting on the first card,
so an empty state does not fail a retrying matcher with a timeout.
- Treat the environment as read-only where you can. If a test must create content (a comment),
scope it to a throwaway account and clean it up; never mutate another user's data.
## Assertions philosophy
Validate what the USER sees, and let the assertion retry. Use expect-webdriverio matchers, which
poll and re-check until they pass or time out, instead of reading a value once:
- await expect(page.heading).toBeDisplayed() for something on screen
- await expect(page.heading).toHaveText('[Expected title]') for text
- await expect(browser).toHaveUrl(expect.stringContaining('/[slug]/')) for navigation state
- await expect(page.postCards).toBeElementsArrayOfSize({ gte: 1 }) rather than an exact count
Never assert on a one-shot read (expect(await el.isDisplayed()).toBe(true)): it does not retry and
will flake. Prefer positive assertions; use negative ones (.not, or an existence check) only for
empty-state and access-denied tests.
## Waits and flakiness
WebdriverIO matchers and waitFor* commands auto-retry. Use them; never sleep. Synchronisation, in
order of preference:
1. Auto-retrying assertion: await expect(page.results).toBeDisplayed() after an action.
2. Explicit wait on the element you are about to use: await page.modal.waitForDisplayed(),
await page.submitButton.waitForClickable().
3. Custom condition: await browser.waitUntil(async () => (await page.postCards.length) > 0,
{ timeout: 10000, timeoutMsg: 'Expected posts to render' }).
Prevention: never browser.pause(ms) for timing; for elements that may be absent branch on a
length or isExisting() check (not a retrying wait); fix the flaky test rather than leaning on
spec-level retries. Config lives in wdio.conf.ts: set waitforTimeout (the default for every
waitFor* and matcher) plus a small specFileRetries count for true environment blips.
## Test readability (the shape every spec follows)
import { BlogPage } from '@pageobjects/blog.page'
import { PostPage } from '@pageobjects/post.page'
describe('Reader: Browse and open a post', () => {
beforeEach(async () => {
await BlogPage.open()
})
it('[post-001] reader can open a post from the listing', async () => {
await BlogPage.openFirstPost()
await expect(PostPage.heading).toBeDisplayed()
await expect(browser).toHaveUrl(expect.stringContaining('/[blog]/'))
})
})
Tests are short and focused (one behaviour each). Action first, then assertion; the title says
what the user can do, not how.
## A page object, end to end
import { Page } from './base.page'
class Blog extends Page {
// private selector getters: re-queried on every access, so never stale
get #searchInput () { return $('aria/[Search posts]') }
get #firstPostLink () { return $('[data-testid="post-card"]').$('a') }
get postCards () { return $$('[data-testid="post-card"]') }
get heading () { return $('aria/[Latest posts]') }
async open () { await super.open('/[blog]') }
async search (term: string) {
await this.#searchInput.waitForDisplayed()
await this.#searchInput.setValue(term)
await browser.keys('Enter')
}
async openFirstPost () {
await this.#firstPostLink.waitForClickable()
await this.#firstPostLink.click()
}
}
export const BlogPage = new Blog()
postCards and heading are public because specs assert on them; the raw selectors behind the
actions stay private. The base Page owns open(), and a single instance is exported, not the class.
## Custom commands (shared actions)
Register reusable, cross-feature actions with browser.addCommand in the before hook of
wdio.conf.ts. Specs call the command; they never read credentials or assemble the steps:
// wdio.conf.ts before()
browser.addCommand('loginAs', async (role: string) => {
const user = users[role] // resolved from env, never inlined in a spec
await LoginPage.open()
await LoginPage.signIn(user.name, process.env.APP_PASSWORD)
})
Element-level helpers attach by passing { attachToElement: true } as the third argument to
addCommand (e.g. a waitAndClick that awaits waitForClickable() then click()). Type custom commands
by augmenting the WebdriverIO.Browser and WebdriverIO.Element interfaces in a declare global block,
and include that file in tsconfig.json.
## Credentials and login
Specs NEVER read credentials directly: log in by ROLE with await browser.loginAs('[reader]'),
which resolves the matching username plus the shared password from environment variables. Define
one variable per role (e.g. APP_READER_USERNAME, APP_AUTHOR_USERNAME, APP_ADMIN_USERNAME) plus
APP_PASSWORD. Reuse a stored session for speed, but re-establish a known state in beforeEach.
## What AI should NEVER generate
- browser.pause(5000) or any hard sleep: use waitForDisplayed / waitForClickable / waitUntil
- brittle XPath ($('//div[3]/span')) or deep CSS / nth-child: use aria, text, data-*, role
- a raw $('...').click() in a spec when a page object exists: call a page object method
- a selector string passed from, or visible in, a spec: selectors are private to the page object
- reading credentials in a spec: log in by role through the custom command
- a non-retrying assertion for UI state (expect(await el.isDisplayed()).toBe(true)): use a polling
matcher, await expect(el).toBeDisplayed()
- a missing await on a command or assertion: it races and flakes
- tests that depend on another test's state or on run order
- exact-count assertions on live data (toBeElementsArrayOfSize(12)): use { gte: 1 } or a pattern
- a public selector that should be private to the page object
- it('[] ...') with an empty test ID
When a needed page object method does not exist, add it rather than reaching for a raw command.
## File structure
test/
specs/{feature}/{role}/ specs organised by feature then role
pageobjects/
base.page.ts shared Page class: open(), common helpers
{feature}.page.ts one page object per feature, selectors as private getters
commands/
custom-commands.ts browser.addCommand definitions (loginAs, waitAndClick)
custom-commands.d.ts TypeScript augmentation for the custom commands
data/
users.ts role to username map (values from process.env)
wdio.conf.ts capabilities, services, hooks, waitforTimeout, reporters
Spec naming: {feature}.{role}.spec.ts (e.g. blog.reader.spec.ts). Page objects export one instance.
## PR review checklist
- Specs go through page objects only: zero raw $() / $$() and zero selector strings in specs
- Selectors are private getters on the page object; only asserted-on elements are public
- Every command and assertion is awaited; no bare await on a getter
- No browser.pause(ms); waits use waitForDisplayed / waitForClickable / waitUntil
- Assertions use auto-retrying expect-webdriverio matchers, never a one-shot read
- Each test is independent (reorder the file and every test still passes)
- No exact-count or specific-title assertions on live data; "at least" and patterns instead
- Credentials come from the role-based custom command, never read in a spec
- Every it() has a real, non-empty test ID
What an agent should never produce
The fast tell that an agent ignored the file, in any framework. If you see one of these in a generated spec, send it back.
You do not write the file by hand. You build it with the agent.
Do not type these rules from a blank page. Let the agent draft and prove the file while you bring the judgment. Run these three prompts in order: mine what you already correct, have the agent read your suite and draft the file, then make it prove the file with real tests. The full walkthrough is here.
Based on everything you know about how I work and every correction I have given you, list the things I correct you on the most. Group them so I can turn each into a checkable rule.
Read this test suite before you write anything. Open the specs, the page objects, the drivers, the fixtures, and the config, and work out the conventions already in use: how locators are written, how interactions are wrapped, where selectors and test data live, how tests are named and isolated, and where the suite contradicts itself. Then draft a CLAUDE.md at the repo root that turns those conventions into rules an agent can check itself against, each with a one-line example of what passes and what fails. Keep it under 200 lines. If a rule is really a multi-step procedure, leave it out and tell me to make it a skill; if it only matters in one folder, tell me to make it a path-scoped rule. Show me the draft and the biggest inconsistencies you found, and do not change a test yet.
Using only the rules in that CLAUDE.md, write three tests and run them: one simple case, one hard case with real setup, and one edge case I keep meaning to cover. Every test must assert what the user actually sees, not just that the page loaded. Assert each thing that matters on its own, and let each test fail for one clear reason; if a test checks two behaviors, split it into two. Run them until they pass, and show me the output. Then I will tell you what to fix, and you fold those fixes back into the CLAUDE.md as new rules, not just patch the tests.
