End-to-end QA
E2E tests exercise the real, running application through its public interface. They are expensive (slow, flaky, hard to debug). Use them deliberately - for the few critical flows that must work - and aggressively for everything else.
What belongs in E2E
- The login → core action → logout golden path.
- Payment / checkout / signup - anything that loses revenue or trust when broken.
- A handful of cross-page flows that span auth + data + UI state.
What does not belong in E2E:
- Form validation (unit-test the validator).
- Pure rendering (component test).
- Backend logic (integration test against the real API).
- Anything you can cover faster at a lower level.
If you find yourself writing the 30th E2E test for the same page, the pyramid is upside-down. Push them down to component or integration.
Locator discipline
- Prefer accessible queries.
page.getByRole('button', { name: 'Save' }), page.getByLabel('Email'), page.getByText(...). They double as accessibility checks.
- Fall back to test-ids only when role/label is ambiguous. Test-ids are coupling between test and DOM - keep them rare.
- Never select by CSS class. Classes change with refactors; tests should not.
- One assertion-style locator per test. Don't
await page.locator('div').nth(3) - name the thing.
Waiting and synchronization
- Wait for state, not for time.
await expect(locator).toBeVisible() retries automatically; await page.waitForTimeout(500) is a flake waiting to happen. Prefer a visible signal over a transport one; for animations, prefers-reduced-motion or disabled transitions in the test build.
- The general flake doctrine - a flaky test is broken, fix it instead of retrying it green - belongs to
qa-automation.
Network handling
- Real backend for E2E. That's the point. Hitting a mocked API is integration testing.
- Stub only third-party services you don't control (payment providers, email gateways, analytics).
- Use Playwright
route / Cypress intercept to inject deterministic responses for those edges.
- Avoid recording-and-replaying entire user journeys; tests become brittle to harmless backend changes.
Test data
- Isolated state per test. No shared user, no shared session. The next test must not depend on the previous one's side effects.
- Seed via API, not UI.
await api.createUser(...) beats await page.fill('email', ...).click('signup').
- Tear down at the end (or use a per-test transaction / namespace). Leaked test data poisons future runs.
- Generate unique identifiers per test (
crypto.randomUUID(), Date.now()) - no hardcoded test@example.com.
Browser context strategy
- One worker per test, one context per test. Isolation is non-negotiable.
storageState snapshots for "already-logged-in" tests - generate once in globalSetup, reuse across tests that don't need a login flow.
- Multi-user / multi-tab tests: spin up multiple
contexts; never share cookies between them.
Parallel CI
- In CI, default to full parallelism only when runner memory is sized for the browser worker count (
fullyParallel: true in Playwright; --parallel in Cypress Cloud).
- On a shared local machine, run one browser suite at a time and cap its workers to available memory. Never let multiple agents launch Chromium, Playwright, or Cypress concurrently unless the user explicitly asks and capacity has been verified.
- Sharding for monorepos:
--shard=1/4 across 4 CI workers cuts wall-clock by ~4×.
- Retries:
retries: 1 is reasonable; retries: 3 is masking flake - investigate instead.
- Trace + video on first retry only. Don't record everything; storage cost adds up.
Debugging a failing E2E
- Reproduce locally with
--headed --debug (Playwright) or cypress open first.
- Look at the trace / video / screenshots from CI before adding logs.
- If it's "works locally, fails in CI" - almost always timing, headless rendering, or test-data leakage. Not a real bug.
- If it's genuinely racy in the app - fix the app. The test surfaced a real bug.
Auth patterns
Three patterns - cookie injection via a test seed endpoint, storageState + globalSetup, and full mocking - with code in references/auth-patterns.md. Pick by app type; never script the login UI in every test.
CI baseline config
Every project should have these guards in playwright.config.ts:
export default defineConfig({
fullyParallel: true, // false + workers:1 only if tests share a DB file
forbidOnly: !!process.env['CI'], // prevents .only from blocking CI
retries: process.env['CI'] ? 1 : 0, // 1 retry hides transient flakes; 2+ masks real bugs
reporter: process.env['CI']
? [['github'], ['list']] // github = inline PR annotations
: 'list',
use: { trace: 'on-first-retry' }, // captures trace on flakes without bloating storage
});
Stack notes
Playwright (TypeScript)
npx playwright codegen <url> for initial scaffolding - but rewrite the generated locators to role-based ones.
expect(locator).toHaveScreenshot() for visual regression, with a generous maxDiffPixelRatio. Commit baselines per platform (Linux + macOS diverge).
test.step('description', async () => { ... }) to make trace files readable.
Cypress
- One assertion per command chain.
cy.get(...).should(...) retries; long chains amplify retries badly.
cy.session() for cached login state across tests.
- Avoid
cy.wait(ms) - it's the most common cause of flake in Cypress suites.
CLI / API E2E
- Same principles: real binary, real network, real data lifecycle. Spawn the process with
execa / subprocess, assert on stdout/stderr AND exit code.
Verification before declaring done
- Run the suite headless 3× locally with random ordering. No flakes.
- Open the trace from a CI run, walk through the failing-then-passing diff.
- Check wall-clock - if the suite went from 2 min to 8 min, the cost of "one more E2E" was real.
1---2name: e2e-qa3description: End-to-end testing with Playwright and Cypress plus real CLI and HTTP journeys: test architecture, locator discipline, network interception, flake elimination, CI parallelization. Use when writing, fixing, or debugging E2E, browser, or user-journey tests. Unit and integration testing belongs to qa-automation.4license: MIT5---67# End-to-end QA89E2E tests exercise the real, running application through its public interface. They are expensive (slow, flaky, hard to debug). Use them deliberately - for the few critical flows that *must* work - and aggressively for everything else.1011## What belongs in E2E1213- The login → core action → logout golden path.14- Payment / checkout / signup - anything that loses revenue or trust when broken.15- A handful of cross-page flows that span auth + data + UI state.1617What does **not** belong in E2E:18- Form validation (unit-test the validator).19- Pure rendering (component test).20- Backend logic (integration test against the real API).21- Anything you can cover faster at a lower level.2223If you find yourself writing the 30th E2E test for the same page, the pyramid is upside-down. Push them down to component or integration.2425## Locator discipline2627- **Prefer accessible queries.** `page.getByRole('button', { name: 'Save' })`, `page.getByLabel('Email')`, `page.getByText(...)`. They double as accessibility checks.28- **Fall back to test-ids only when role/label is ambiguous.** Test-ids are coupling between test and DOM - keep them rare.29- **Never select by CSS class.** Classes change with refactors; tests should not.30- **One assertion-style locator per test.** Don't `await page.locator('div').nth(3)` - name the thing.3132## Waiting and synchronization3334- **Wait for state, not for time.** `await expect(locator).toBeVisible()` retries automatically; `await page.waitForTimeout(500)` is a flake waiting to happen. Prefer a *visible* signal over a transport one; for animations, `prefers-reduced-motion` or disabled transitions in the test build.35- The general flake doctrine - a flaky test is broken, fix it instead of retrying it green - belongs to `qa-automation`.3637## Network handling3839- **Real backend for E2E.** That's the point. Hitting a mocked API is integration testing.40- Stub *only* third-party services you don't control (payment providers, email gateways, analytics).41- Use Playwright `route` / Cypress `intercept` to inject deterministic responses for those edges.42- Avoid recording-and-replaying entire user journeys; tests become brittle to harmless backend changes.4344## Test data4546- **Isolated state per test.** No shared user, no shared session. The next test must not depend on the previous one's side effects.47- Seed via API, not UI. `await api.createUser(...)` beats `await page.fill('email', ...).click('signup')`.48- Tear down at the end (or use a per-test transaction / namespace). Leaked test data poisons future runs.49- Generate unique identifiers per test (`crypto.randomUUID()`, `Date.now()`) - no hardcoded `test@example.com`.5051## Browser context strategy5253- One worker per test, one context per test. Isolation is non-negotiable.54- `storageState` snapshots for "already-logged-in" tests - generate once in `globalSetup`, reuse across tests that don't need a login flow.55- Multi-user / multi-tab tests: spin up multiple `context`s; never share cookies between them.5657## Parallel CI5859- In CI, default to full parallelism only when runner memory is sized for the browser worker count (`fullyParallel: true` in Playwright; `--parallel` in Cypress Cloud).60- On a shared local machine, run one browser suite at a time and cap its workers to available memory. Never let multiple agents launch Chromium, Playwright, or Cypress concurrently unless the user explicitly asks and capacity has been verified.61- Sharding for monorepos: `--shard=1/4` across 4 CI workers cuts wall-clock by ~4×.62- Retries: `retries: 1` is reasonable; `retries: 3` is masking flake - investigate instead.63- Trace + video on first retry only. Don't record everything; storage cost adds up.6465## Debugging a failing E2E66671. Reproduce locally with `--headed --debug` (Playwright) or `cypress open` first.682. Look at the trace / video / screenshots from CI before adding logs.693. If it's "works locally, fails in CI" - almost always timing, headless rendering, or test-data leakage. Not a real bug.704. If it's genuinely racy in the app - fix the app. The test surfaced a real bug.7172## Auth patterns7374Three patterns - cookie injection via a test seed endpoint, `storageState` + `globalSetup`, and full mocking - with code in `references/auth-patterns.md`. Pick by app type; never script the login UI in every test.7576## CI baseline config7778Every project should have these guards in `playwright.config.ts`:7980```ts81export default defineConfig({82 fullyParallel: true, // false + workers:1 only if tests share a DB file83 forbidOnly: !!process.env['CI'], // prevents .only from blocking CI84 retries: process.env['CI'] ? 1 : 0, // 1 retry hides transient flakes; 2+ masks real bugs85 reporter: process.env['CI']86 ? [['github'], ['list']] // github = inline PR annotations87 : 'list',88 use: { trace: 'on-first-retry' }, // captures trace on flakes without bloating storage89});90```9192## Stack notes9394### Playwright (TypeScript)95- `npx playwright codegen <url>` for initial scaffolding - but rewrite the generated locators to role-based ones.96- `expect(locator).toHaveScreenshot()` for visual regression, with a generous `maxDiffPixelRatio`. Commit baselines per platform (Linux + macOS diverge).97- `test.step('description', async () => { ... })` to make trace files readable.9899### Cypress100- One assertion per command chain. `cy.get(...).should(...)` retries; long chains amplify retries badly.101- `cy.session()` for cached login state across tests.102- Avoid `cy.wait(ms)` - it's the most common cause of flake in Cypress suites.103104### CLI / API E2E105- Same principles: real binary, real network, real data lifecycle. Spawn the process with `execa` / `subprocess`, assert on `stdout`/`stderr` AND exit code.106107## Verification before declaring done108109- Run the suite headless 3× locally with random ordering. No flakes.110- Open the trace from a CI run, walk through the failing-then-passing diff.111- Check wall-clock - if the suite went from 2 min to 8 min, the cost of "one more E2E" was real.