| 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:
- Role:
page.getByRole('button', { name: 'Save' })
- Label:
page.getByLabel('Email address')
- Placeholder/text:
page.getByPlaceholder(...) / page.getByText(...)
- 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.).
await page.getByRole('textbox', { name: 'Email address' }).fill('a@example.com');
await page.getByRole('button', { name: 'Log in' }).click();
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.
await page.locator('div.container > div:nth-child(2) > button').click();
await page.locator('.css-1q2w3e4').click();
await page.locator('#app > main span').first().click();
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.
await expect(page.getByRole('heading', { name: 'Order complete' })).toBeVisible();
await expect(page.getByTestId('cart-count')).toHaveText('3');
await page.waitForTimeout(3000);
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.
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
test.beforeEach(async ({ page }) => {
await page.goto('/projects');
});
test('can create a project', async ({ page }) => { });
test('can search for a project', async ({ page }) => {
});
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.
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.
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.
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' });
});
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.
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.
test.fixme('can export order history as CSV', async ({ page }) => {
});
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.
export default defineConfig({
retries: process.env.CI ? 2 : 0,
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-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
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