End-to-End Test Automation
PLAn agent-maintained end-to-end (E2E) suite is a set of Playwright tests that a coding agent writes from acceptance criteria and keeps passing as the UI changes, while CI proves the suite still catches regressions. The qualifier that matters: the agent writes the tests, but humans own the list of journeys, the visual baselines, and any change that weakens an assertion.
This page is for the developer who asks an agent to “add E2E tests” and the tech lead who has to trust the result. The agent produces 40 specs, CI is green, and a week later checkout breaks in production. Nobody read the specs, so nobody noticed that half of them assert a toast instead of a saved order, and three carry a waitForTimeout(5000) that hides a race.
What an agent-maintained E2E suite gives you
Section titled “What an agent-maintained E2E suite gives you”- A journey budget that decides which flows get an E2E test and which belong in cheaper layers
- A
playwright.config.tsthat makes flaky tests, straytest.onlycalls and silent snapshot updates fail in CI - A browser connection for Claude Code, Codex, and Cursor, so the agent writes locators against the real page
- Copy-paste prompts that generate tests from acceptance criteria, refactor them into fixtures, set up visual checks, and fix flakiness without weakening assertions
- Four checks that show the suite catches bugs, none of which require reading the specs line by line
The examples use Playwright Test 1.63.0 (npm latest on 2026-09-26). On Cypress the journey budget, the oracle checks and the prompt rules (persisted-outcome assertions, no sleeps, no skips) still apply; the commands, locator APIs and config keys do not.
Which user journeys deserve an E2E test?
Section titled “Which user journeys deserve an E2E test?”E2E tests are the slowest and most expensive layer, and agents write them faster than anyone can maintain them. Decide the budget before the agent writes anything. A tech lead owns this list, because it is the part of the oracle that no agent should extend or shrink on its own.
| Put it in E2E when… | Put it in a cheaper layer when… |
|---|---|
| The flow crosses the browser, the API, and storage, and a user would notice a break (sign-up, checkout, publish) | The rule is pure logic, such as price rounding or validation: unit tests |
| The bug class lives in the browser: autofill, focus, iframes, client routing, hydration | The contract is between services: API tests or integration tests |
| A regression would cost revenue or trust before monitoring caught it | The check is a one-off proof for one change: agent-driven browser verification |
Keep the list in the repository, next to the suite, as tests/e2e/JOURNEYS.md: one line per journey, the acceptance criterion it proves, and the spec file that proves it. Reviewers read that file instead of the specs. The criteria themselves come from acceptance criteria an agent can verify.
Set up the suite before the agent writes a test
Section titled “Set up the suite before the agent writes a test”An agent copies whatever the existing suite does. Put the rules in the config and the fixtures first, so the first generated test inherits them.
-
Install Playwright Test and one browser in your terminal:
Terminal window npm init playwright@latest -- --quiet --browser=chromium --install-deps -
Replace the generated
playwright.config.tswith a config that turns silent failures into loud ones:playwright.config.ts import { defineConfig, devices } from '@playwright/test';const CI = !!process.env.CI;const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';const port = new URL(baseURL).port || '3000'; // the dev server listens where the tests lookexport default defineConfig({testDir: './tests/e2e',fullyParallel: true,forbidOnly: CI, // a stray test.only fails the runretries: CI ? 1 : 0, // one retry so Playwright can label a test flaky…failOnFlakyTests: CI, // …and the flaky label fails the jobupdateSnapshots: CI ? 'none' : 'missing', // CI never writes a baselinereporter: CI ? 'blob' : 'list',use: { baseURL, trace: 'retain-on-failure' },projects: [{ name: 'setup', testMatch: /auth\.setup\.ts/ },{name: 'chromium',use: { ...devices['Desktop Chrome'], storageState: 'tests/e2e/.auth/user.json' },dependencies: ['setup'],},],webServer: {command: `npm run dev -- --port ${port}`, // assumes your dev script accepts --porturl: baseURL,reuseExistingServer: !CI,},});updateSnapshotsdefaults to'missing', which writes a new baseline whenever none exists. Setting it to'none'in CI means a screenshot test without an approved baseline fails instead of approving itself. -
Log in once through the API, not the UI, and save the session for every test:
tests/e2e/auth.setup.ts import { test as setup, expect } from '@playwright/test';setup('authenticate', async ({ request }) => {const res = await request.post('/api/auth/login', {data: { email: process.env.E2E_USER_EMAIL, password: process.env.E2E_USER_PASSWORD },});expect(res.ok()).toBeTruthy();await request.storageState({ path: 'tests/e2e/.auth/user.json' });});Add
tests/e2e/.auth/to.gitignore. Point/api/auth/loginat your own login endpoint, and use a disposable test account, never a real user. -
Protect the oracle: add
tests/e2e/,playwright.config.ts, and the snapshot folders toCODEOWNERS, so a human approves every change to them. Protecting the oracle covers deny rules that stop the agent from editing them at all.
Connect the agent to a browser
Section titled “Connect the agent to a browser”The agent writes better locators when it can open the page and read its accessibility tree than when it guesses from JSX. Playwright ships two routes: the Playwright MCP server, and the Playwright CLI with skills, which its README suggests coding agents might benefit from, because it avoids loading large tool schemas and accessibility snapshots into context. Playwright MCP vs Playwright CLI compares them in depth. The setup differs per tool:
# Browser tools for this project; --isolated keeps parallel agents from sharing a profileclaude mcp add playwright -- npx @playwright/mcp@latest --isolated
# Optional: Playwright's planner, generator and healer subagentsnpx playwright init-agents --loop=claudeinit-agents --loop=claude writes the three subagents to .claude/agents/ and a playwright-test server entry to .mcp.json. It overwrites an existing .mcp.json (checked in 1.63.0), so commit or merge your current entries first.
codex mcp add playwright -- npx @playwright/mcp@latest --isolated
# Optional: the same three test agents, written to .codex/agents/npx playwright init-agents --loop=codexAdd the server to .cursor/mcp.json:
{ "mcpServers": { "playwright": { "command": "npx", "args": ["@playwright/mcp@latest", "--isolated"] } }}init-agents 1.63.0 has no Cursor loop (its choices are claude, codex, copilot, opencode, vscode, and vscode-legacy), so use the MCP server with the prompts on this page.
To use the CLI route instead of MCP in any of the three tools, run npm install -g @playwright/cli@latest (the unscoped playwright-cli package is deprecated), then install the skill where your tool reads it. playwright-cli install --skills defaults to claude and writes .claude/skills/playwright-cli/ for Claude Code; Codex reads project skills from .agents/skills/, so run playwright-cli install --skills=agents there (both checked in @playwright/cli 0.1.22). Cursor loads skills from .agents/skills/ and .cursor/skills/, and for compatibility also from .claude/skills/ and .codex/skills/ (Cursor docs, checked 2026-10-12), so both installs above are visible to it; confirm Cursor lists the skill before you rely on it. For a skill-based browser that is not tied to Playwright, see agent-browser.
Generate tests from acceptance criteria
Section titled “Generate tests from acceptance criteria”Give the agent one journey at a time, with its criteria, and make it run what it writes. The prompt below works unchanged in Claude Code, Codex, and Cursor.
--repeat-each=5 --retries=0 is the cheap flake filter: a test that fails once in five local runs fails far more often across a CI fleet. Ask for the command output, not a summary, so the pass count is evidence rather than a claim.
Page objects or fixtures: how to keep agent-written tests maintainable
Section titled “Page objects or fixtures: how to keep agent-written tests maintainable”Agents duplicate helpers. After ten journeys you usually have four login helpers and three ways to open the pricing page. Consolidate once the suite has a few journeys, not before, and keep the assertions visible in the spec file, where a reviewer can find them.
The before-and-after numbers make the refactor verifiable without reading it: the same test titles and the same number of assertions mean the agent moved code without dropping checks. Page objects that hold assertions hide them from review; keeping expect in the spec is the rule that makes this check possible.
Add visual regression checks the agent cannot approve
Section titled “Add visual regression checks the agent cannot approve”toHaveScreenshot() compares a page against a stored baseline and disables CSS animations by default. The risk with an agent is not the comparison but the approval: npx playwright test --update-snapshots rewrites every baseline that no longer matches, including those that differ because of a bug (the flag’s preset is changed; --update-snapshots=all rewrites them all).
Baselines are named per browser and operating system, so a baseline made on a Mac fails on a Linux CI runner. Generate them in the same container image CI uses (the workflow below runs in mcr.microsoft.com/playwright:v1.63.0-noble), once, from the repository root. The container needs its own npm ci, because the host’s node_modules may hold native binaries for another OS, and the test credentials, because every test depends on the login in auth.setup.ts:
docker run --rm --ipc=host -e E2E_USER_EMAIL -e E2E_USER_PASSWORD \ -v "$PWD":/work -w /work mcr.microsoft.com/playwright:v1.63.0-noble \ /bin/bash -c 'npm ci && npx playwright test visual.spec.ts --update-snapshots=missing'On a Linux host the container writes the baselines as root, so add --user $(id -u):$(id -g) or run chown on the output afterwards. After that, a human looks at the diff image in the HTML report and approves each baseline change in the pull request; CODEOWNERS on the snapshot folder enforces it.
Run the suite in CI with sharding and a flake gate
Section titled “Run the suite in CI with sharding and a flake gate”Shard the suite across runners and merge the blob reports into one HTML report. The workflow runs on pull_request, so it runs the contributor’s code without write permissions. Forked pull requests receive no secrets, so auth.setup.ts would post empty credentials and every fork would fail E2E for a reason unrelated to the product; the if: below skips the job for forks. If you accept outside contributions, add a separate job that runs a no-auth subset for them instead. The job runs inside the Playwright image, which already contains the browsers, so there is no playwright install step.
name: e2eon: pull_requestpermissions: contents: readjobs: e2e: # Forked pull requests get no secrets, so auth.setup.ts would fail; skip them if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v1.63.0-noble # same image that made the baselines options: --user 1001 strategy: fail-fast: false matrix: shard: [1/4, 2/4, 3/4, 4/4] steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-node@v7 with: node-version: 22 - run: npm ci - run: npx playwright test --shard=${{ matrix.shard }} env: E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }} E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }} - uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} with: name: blob-report-${{ strategy.job-index }} path: blob-report retention-days: 7 report: if: ${{ !cancelled() && github.event.pull_request.head.repo.full_name == github.repository }} needs: e2e runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-node@v7 with: node-version: 22 - run: npm ci - uses: actions/download-artifact@v8 with: path: all-blob-reports pattern: blob-report-* merge-multiple: true - run: npx playwright merge-reports --reporter html ./all-blob-reports - uses: actions/upload-artifact@v7 with: name: playwright-report path: playwright-report retention-days: 14The traces from failed tests (trace: 'retain-on-failure') land in the merged report. Link that artifact from the pull request’s evidence bundle, so a reviewer opens the failing step instead of rerunning it. Short-lived per-branch environments are covered in ephemeral environments.
How do you know the suite catches regressions without reading it?
Section titled “How do you know the suite catches regressions without reading it?”A green suite proves only that the tests passed. Four checks prove the suite would fail when the product breaks, and none of them requires reading the specs.
-
Every new test goes red on the old code. Run the new spec against the base branch in a separate worktree; it must fail. Run
npm ciin that worktree first, and give it its own port throughE2E_BASE_URLwith nothing else listening on it; the config above starts the dev server on that port. OtherwisereuseExistingServerattaches to the dev server of your main checkout, and the spec tests the new code instead of the old. Agent-driven browser verification has the prompt for this red-then-green proof. -
Planted bugs get caught. Break each critical journey on purpose, one bug at a time, and count how many the suite detects. The prompt below runs this as a throwaway exercise; oracle strength turns it into a tracked mutation score.
-
Flakiness fails the build.
failOnFlakyTestswith one retry means a test that passes only on retry turns the job red instead of hiding. -
Weakening is flagged. A CI step fails when a pull request removes
expectlines or adds a skip or a sleep undertests/e2e/:# Steps in a job of a workflow that runs on pull_request- uses: actions/checkout@v7with: { fetch-depth: 0, persist-credentials: false } # the diff needs the base branch's history- name: Flag changes that weaken the E2E oracleenv: { BASE: '${{ github.base_ref }}' } # main, master, develop: whatever the PR targetsrun: |diff=$(git diff "origin/$BASE...HEAD" -- tests/e2e)removed=$(printf '%s\n' "$diff" | grep -cE '^-.*\bexpect\(' || true)added=$(printf '%s\n' "$diff" | grep -cE '^\+.*(test\.(skip|fixme)|waitForTimeout)' || true)if [ "$removed" -gt 0 ] || [ "$added" -gt 0 ]; thenecho "E2E oracle weakened: $removed expect lines removed, $added skip/fixme/sleep lines added"exit 1fiMoving an assertion also trips it. That is deliberate: the move gets a human look. Make this a separate, non-required check, and treat a red result as needing approval from the
CODEOWNERSowner oftests/e2e/.
Sign-off stays with people and CI. The tech lead owns JOURNEYS.md and approves baseline and assertion changes; the CI job is the merge gate; the reviewer checks the journey list, the planted-bug table, and the report artifact instead of the spec lines.
Fix a flaky E2E test without weakening it
Section titled “Fix a flaky E2E test without weakening it”Agents have two fast ways to turn a flaky test green: add a sleep, or loosen the assertion. Both hide the bug the test exists to catch. Forbid them in the prompt and demand the evidence.
When an agent-written E2E suite breaks
Section titled “When an agent-written E2E suite breaks”| Failure | What you see | Recovery |
|---|---|---|
| Toast-only assertions | Tests pass while the saved data is wrong | Require a reload or a second page in every journey; run the planted-bug prompt to find the gaps |
| The agent approved a broken screenshot | A pull request updates .png baselines along with the UI change | Keep updateSnapshots: 'none' in CI and CODEOWNERS on snapshot folders; review the diff image, not the file list |
| Wrong dev server | The suite passes locally against another worktree’s server on the same port | Give each worktree its own port through E2E_BASE_URL and start its dev server on that port, as the config above does; reuseExistingServer attaches to whatever answers on url |
| Parallel agents collide in the browser | Browser launch fails, or one agent sees another’s session | Start Playwright MCP with --isolated or a separate --user-data-dir per worktree |
| Healer or fixer weakens a test | A red test turns green after an expect change or a new test.fixme() | The weakening check turns red, and CODEOWNERS on tests/e2e/ routes the pull request to its owner; revert the test change and fix the product or the locator |
| Suite too slow to run on every pull request | Developers and agents skip it | Shard it; tag the critical journeys (test('…', { tag: '@critical' }, …)) and run --grep @critical per pull request, the full suite nightly |
| Baselines differ on CI only | Font or anti-aliasing differences fail every screenshot | Generate and compare baselines in the same container image; mask text-heavy dynamic areas |
| Third-party iframe blocks the flow | Payment or CAPTCHA widgets fail in the test | Use the provider’s test mode and test keys, or stub the provider at the network layer with page.route |