---
name: e2e-testing
description: A pattern collection for Playwright-based E2E testing. Covers how to choose what to test in Next.js/Vue apps, selector strategy, wait strategy, test structure, test data management, flaky test countermeasures, and CI integration. Use when writing new E2E tests, reviewing E2E tests, investigating flaky tests, or analyzing test failures in CI.
---

# E2E Testing Patterns (Playwright / Next.js / Vue)

E2E tests should be "few and elite." This skill assumes Playwright and
collects patterns — and anti-patterns — for writing E2E tests that stay
robust and easy to maintain.

## 1. What should you test with E2E?

### Scope it to critical business flows

E2E tests are expensive to run and expensive to maintain. Don't aim for
exhaustive coverage — target only "the flows that would directly hurt the
business if they broke."

Things worth covering:

- Login/logout and screen transitions involving authentication
- Flows that finalize money or data — applications, orders, payments
- End-to-end paths for major forms: submission through list/detail confirmation
- Permission-based differences in what's shown or actionable (e.g. admin vs. regular user)

Things that should NOT be E2E tests (leave these to unit/component tests):

- Every variation of a validation message
- Pure logic such as date formatting or amount calculations
- Individual component rendering variations (combinations of props)
- Fine-grained branches of error handling

### Where to draw the line with unit tests

| What you want to verify | Appropriate layer |
|---|---|
| Function/class logic | Unit test |
| Component rendering/events | Component test |
| API input/output contract | API test |
| Business flow spanning multiple screens | E2E test |

Always ask yourself "can this only be verified through E2E?" Don't push
something into E2E that could be written at a lower layer.

## 2. Selector strategy

### Priority order

Prefer attributes that are visible to the user. Work through this list top to bottom:

1. Role: `page.getByRole('button', { name: 'Save' })`
2. Label: `page.getByLabel('Email address')`
3. Placeholder/text: `page.getByPlaceholder(...)` / `page.getByText(...)`
4. Test ID: `page.getByTestId('order-summary')`

Always use role and label when an element can be identified that way —
it doubles as an accessibility check. Reserve test IDs for elements with
no visual cue (containers, rows in a dynamic list, etc.).

```typescript
// Good: identify by the name the user perceives
await page.getByRole('textbox', { name: 'Email address' }).fill('a@example.com');
await page.getByRole('button', { name: 'Log in' }).click();

// Acceptable: use a test ID for a row with no visual cue
const row = page.getByTestId('user-row-123');
await row.getByRole('button', { name: 'Edit' }).click();
```

### No selectors that depend on CSS structure

Selectors that depend on DOM structure or framework-generated class names
are forbidden. They break easily during refactors, and when they break
it's not obvious why.

```typescript
// Forbidden: depends on structure / auto-generated classes
await page.locator('div.container > div:nth-child(2) > button').click();
await page.locator('.css-1q2w3e4').click();          // CSS-in-JS generated class
await page.locator('#app > main span').first().click(); // depends on ordering
```

This also applies to attributes generated by Vue's scoped CSS
(`data-v-xxxxx`) and to Next.js/Tailwind utility classes — don't depend on
those either.

## 3. Wait strategy

### Trust auto-waiting and web-first assertions

Playwright's locator actions and `expect(locator)`-style assertions
automatically retry until the element is actionable or the condition
holds. Rely on this by default.

```typescript
// Good: an assertion that retries automatically
await expect(page.getByRole('heading', { name: 'Order complete' })).toBeVisible();
await expect(page.getByTestId('cart-count')).toHaveText('3');

// Forbidden: a fixed sleep. Flaky on slow environments, wasteful on fast ones
await page.waitForTimeout(3000);

// Forbidden: an assertion evaluated immediately, with no retry — vulnerable to races
expect(await page.getByTestId('cart-count').textContent()).toBe('3');
```

Also avoid depending on `networkidle`. In Next.js/Vue apps that use
polling or SSE, the network may never go idle — or idle may trigger too
early. What you should wait for isn't network silence, but the result
that's visible to the user.

### Arm response waits before the action, not after

When you need to wait for a specific response, start `waitForResponse`
*before* the click that triggers it. If you start waiting afterward, you
can lose the race and miss it.

```typescript
// Good: arm the wait first, then perform the action
const responsePromise = page.waitForResponse(
  (res) => res.url().includes('/api/orders') && res.status() === 201
);
await page.getByRole('button', { name: 'Confirm order' }).click();
await responsePromise;
await expect(page.getByText('Your order has been received')).toBeVisible();
```

## 4. Test structure

### Test independence

Every test must be able to run alone, in any order.

- Don't carry variables, created data, or login state across tests
- Set up prerequisite data at the start of each test (or in `beforeEach`) — don't rely on shared fixtures
- Never let one test's "output" become another test's "input"
- Avoid `test.describe.serial` as a rule; if a sequence is truly required, fold it into a single test

```typescript
test.beforeEach(async ({ page }) => {
  // Each test sets up its own prerequisites, independent of other tests' results
  await page.goto('/projects');
});

test('can create a project', async ({ page }) => { /* ... */ });
test('can search for a project', async ({ page }) => {
  // The data to search for is created within this test (or via API setup)
});
```

### Use the Page Object Model in moderation

Use a Page Object to consolidate "a sequence of operations repeated
across multiple tests." Don't build a giant class that wraps every
element on the screen.

```typescript
// Good: a thin Page Object that exposes only business-level operations
export class LoginPage {
  constructor(private page: Page) {}

  async login(email: string, password: string) {
    await this.page.goto('/login');
    await this.page.getByLabel('Email address').fill(email);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Log in' }).click();
  }
}
```

Signs of over-abstraction (avoid these):

- A pile of methods that are each called from only one test
- Assertions buried inside the Page Object, making failures hard to trace
- Three layers of wrapper around what could be a single `getByRole` call

Only extract a shared helper after you've repeated something two or three
times. Don't abstract preemptively.

## 5. Test data management

### Generate unique data

Fixed-value data collides under parallel execution or retries. Generate a
value that's unique per run.

```typescript
// Include a unique identifier for each run
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const email = `e2e-${unique}@example.com`;
const projectName = `E2E Test Project-${unique}`;
```

Prioritize "a design that can't collide" over cleanup after the fact.
Make sure that even if cleanup fails, it doesn't drag other tests down
with it.

### Reuse authentication state (storageState)

Don't repeat a UI login in every test. Log in once in a setup project,
save the resulting state, and reuse it across all tests.

```typescript
// auth.setup.ts: create and save authentication state once
import { test as setup } from '@playwright/test';

setup('save authentication state', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email address').fill(process.env.E2E_USER!);
  await page.getByLabel('Password').fill(process.env.E2E_PASS!);
  await page.getByRole('button', { name: 'Log in' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
```

```typescript
// playwright.config.ts: make setup a dependency and reuse the storageState
projects: [
  { name: 'setup', testMatch: /auth\.setup\.ts/ },
  {
    name: 'chromium',
    use: { storageState: 'playwright/.auth/user.json' },
    dependencies: ['setup'],
  },
],
```

The one exception is testing the login feature itself — that should still
go through the UI rather than reusing `storageState`.

### Mock external APIs (route)

Mock external APIs you don't control (payment providers, maps, other
external SaaS) with `page.route` to make the test deterministic.

```typescript
// Mock the external payment API and pin down a success response
await page.route('**/api/external/payment', async (route) => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ status: 'succeeded', id: 'pay_test_001' }),
  });
});
```

Don't mock your own backend as a rule. The moment you do, everything
downstream stops being an E2E test. Only mock things outside your own
system boundary.

## 6. Flaky test countermeasures

### Symptom-to-cause-to-fix table

| Symptom | Typical cause | Fix |
|---|---|---|
| Passes locally but fails in CI | Fixed sleeps, dependence on machine speed | Replace with web-first assertions |
| Element occasionally not found | Evaluated immediately, before rendering completes | Rely on `expect(locator)`'s auto-retry |
| Fails only under parallel execution | Data collisions between tests | Generate unique data; restore independence |
| Fails when order changes | Depends on a previous test's result | Have each test set up its own prerequisites |
| Click doesn't register | Competing with an animation or overlay | Assert the target is stably visible before acting |
| Can't capture the response you're waiting for | `waitForResponse` armed after the action | Arm the wait before the action |

### Where retries fit in

Retries (`retries`) are a safety net for absorbing transient
infrastructure failures — they are not a "cure" for flakiness. It's fine
to set something like `retries: 2` in CI, but any test that only passes
after a retry should be logged as flaky and investigated. "It has
retries, so we don't need to fix it" is not an acceptable conclusion.

### Quarantining requires a ticket number

If root-causing takes time, you may temporarily quarantine a test — but
only with a ticket number attached. A `skip`/`fixme` with no ticket
number should be sent back in review.

```typescript
// Always leave a ticket number and reason. skip without one is forbidden
test.fixme('can export order history as CSV', async ({ page }) => {
  // TICKET-1234: investigating an issue where the download event isn't captured in CI only
});
```

## 7. CI integration

### Traces and screenshots on failure

Always retain the evidence needed to reproduce a failure. Recording
everything all the time wastes storage and run time, so limit it to
failures and retries.

```typescript
// playwright.config.ts: keep evidence only on failure
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    trace: 'on-first-retry',        // record a trace on retry
    screenshot: 'only-on-failure',  // screenshot only on failure
    video: 'retain-on-failure',     // keep video only on failure
  },
  reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
});
```

In CI, save the trace and HTML report as artifacts, and investigate
failures with `npx playwright show-trace` rather than guessing from logs
alone.

### Parallelism and sharding basics

- Control in-machine parallelism with workers (`workers`). If tests are
  truly independent, the default parallel execution works as-is
- As the test count grows, split across multiple machines:
  `npx playwright test --shard=1/4`
- If a test starts failing under parallel execution, fix the test's
  independence (data collisions, shared state) rather than lowering
  parallelism

```yaml
# CI example: matrix run with 4-way sharding
strategy:
  matrix:
    shard: [1/4, 2/4, 3/4, 4/4]
steps:
  - run: npx playwright test --shard=${{ matrix.shard }}
```

## Related

- Use `/test-coverage` to check coverage
- Use `/code-review` to review implementation and test code
