
e2e-testing
Provides Playwright end-to-end testing patterns including Page Object Model structure, configuration, CI/CD integration, artifact management, and strategies for fixing flaky tests.
Lift+25ppscreen tier · directional
90
Trustsecurity · quality · license · upkeep
Scan Saferisk 0/100
LicenseMITserved via MCP
Skill-matched tasks (generated, public + held-out) · glm-5.3-flash@cf
+25pp95% CI [0, 75]unverified · screen tier
trigger rate 100% · 12 trials
A/B outputs — median trial per arm
Same task, same model: without the skill (left) vs with it (right).
e2e-testing--m1
baseline · 1.00
Here's a simple Playwright test using the Page Object Model:
**`pages/LoginPage.js`**
```javascript
class LoginPage {
constructor(page) {
this.page = page;
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Submit' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(username, password) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
module.exports = { LoginPage };
```
**`tests/login.spec.js`**
```javascript
const { test, expect } = require('@playwright/test');
const { LoginPage } = require('../pages/LoginPage');
test('successful login redirects to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('myuser', 'mypassword');
await expect(page).toHaveURL(/dashboard/);
});
test('invalid credentials show an error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('myuser', 'wrongpassword');
await expect(loginPage.errorMessage).toBeVisible();
});
```
**Key ideas:**
- **Page Object** — the `LoginPage` class owns the locators and actions for that page, so selectors live in one place. If the UI changes, you update only the page object.
- **Tests stay readable** — they describe *what* the user does, not *how* the DOM is structured.
- **Locators** — I used `getByLabel` and `getByRole`, which are the recommended user-facing locators in Playwright. Adjust to your actual markup (e.g., `page.locator('#username')` if there are no labels).
- **Assertions stay in the test** — a common convention is to keep `expect` calls in the spec file rather than inside the page object.
Run it with `npx playwright test`. If you're using TypeScript, the same structure works with typed fields (`private readonly page: Page`).with skill · 1.00
Here's a simple Page Object Model setup for your login page.
## 1. Page Object — `tests/pages/LoginPage.ts`
```typescript
import { Page, Locator, expect } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly usernameInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.page = page
this.usernameInput = page.locator('[data-testid="username"]')
this.passwordInput = page.locator('[data-testid="password"]')
this.submitButton = page.locator('[data-testid="submit-btn"]')
this.errorMessage = page.locator('[data-testid="error-message"]')
}
async goto() {
await this.page.goto('/login')
}
async login(username: string, password: string) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}
```
## 2. Test — `tests/e2e/auth/login.spec.ts`
```typescript
import { test, expect } from '@playwright/test'
import { LoginPage } from '../../pages/LoginPage'
test.describe('Login', () => {
let loginPage: LoginPage
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page)
await loginPage.goto()
})
test('logs in with valid credentials', async ({ page }) => {
await loginPage.login('testuser', 'correct-password')
// Adjust the assertion to whatever shows after login, e.g. a dashboard
await expect(page).toHaveURL(/dashboard/)
})
test('shows an error with invalid credentials', async () => {
await loginPage.login('testuser', 'wrong-password')
await expect(loginPage.errorMessage).toBeVisible()
await expect(loginPage.errorMessage).toContainText(/invalid/i)
})
})
```
## Key points for a beginner
- **Selectors:** I used `[data-testid="..."]` — add `data-testid` attributes to your inputs/button if you can. They're the most stable selectors (they don't break when styling or text changes). If you can't, use `page.getByLabel('Username')` or `page.getByRole('button', { name: 'Sign in' })` instead.
- **No manual waits:** Playwright's `fill()` and `click()` auto-wait for elements, so you don't need `waitForTimeout`.
- **POM benefit:** The test reads like a description of behavior; if your login page's HTML changes, you only fix `LoginPage.ts`, not every test.
- **Run it:** `npx playwright test tests/e2e/auth/login.spec.ts`
Adjust the URL assertions and error text to match your app. Want me to show how to add a fixture so logged-in state is reusable across other tests?e2e-testing--m2
baseline · 0.82
Here's a solid `playwright.config.ts` for CI with parallelism and failure artifacts:
```ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // run tests within files in parallel
workers: process.env.CI ? 2 : undefined, // limit workers in CI; local uses defaults
forbidOnly: !!process.env.CI, // fail if test.only sneaks into CI
retries: process.env.CI ? 2 : 0,
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'retain-on-failure', // trace saved only for failed tests
screenshot: 'only-on-failure',
video: 'retain-on-failure', // optional
actionTimeout: 10_000,
navigationTimeout: 15_000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
// { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
// { name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
reporter: process.env.CI
? [['html', { open: 'never' }], ['junit', { outputFile: 'results/junit.xml' }]]
: [['list']],
});
```
Key points:
- **Parallelism**: `fullyParallel: true` parallelizes tests *within* a file too. If your tests share state (same user, DB records), keep `fullyParallel: false` and rely on per-file workers, or use `test.describe.configure({ mode: 'parallel' })` selectively.
- **CI workers**: More workers isn't always better — start with 2–4 and scale based on your app's capacity under load.
- **Retries**: `retries: 2` in CI helps with flakiness; failed retries still produce artifacts.
- **Artifacts**: `trace: 'retain-on-failure'` is the sweet spot — traces are expensive to keep for every test. View them with `npx playwright show-trace test-results/.../trace.zip`.
- **CI integration**: In GitHub Actions, upload `test-results/` and `playwright-report/` with `actions/upload-artifact` using `if: always()` so artifacts survive failed runs. Playwright's official action (`microsoft/playwright-github-action`) or `npx playwright install --with-deps` in your setup step handles browsers.
If you're on GitHub Actions, also set `workers: process.env.CI ? 4 : undefined` only if your runners have enough cores — Playwright defaults to ~50% of CPU cores otherwise.with skill · 1.00
Here's a `playwright.config.ts` tuned for CI with parallelism and failure artifacts:
```typescript
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
// Parallelism
fullyParallel: true, // run tests within files in parallel too
workers: process.env.CI ? 4 : undefined, // or omit to use all cores
// CI safety
forbidOnly: !!process.env.CI, // fail if test.only is committed
retries: process.env.CI ? 2 : 0,
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['junit', { outputFile: 'playwright-results.xml' }], // for CI test reporting
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
// Artifacts on failure
screenshot: 'only-on-failure',
trace: 'retain-on-failure', // or 'on-first-retry' if you rely on retries
video: 'retain-on-failure',
actionTimeout: 10_000,
navigationTimeout: 30_000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
// add firefox/webkit/mobile as needed
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
```
Key points:
- **Parallelism:** `fullyParallel: true` parallelizes tests *within* files, not just across files. If your tests share state (e.g., a shared DB user), either set `fullyParallel: false` and use `test.describe.configure({ mode: 'parallel' })` per file, or make each test fully independent (unique users/data per test).
- **Trace choice:** `trace: 'retain-on-failure'` always captures traces for failures; `'on-first-retry'` is lighter and pairs well with `retries: 2` — pick one based on whether you debug via retries or want traces on every failure.
- **CI upload:** Make sure artifacts survive the run, e.g. in GitHub Actions:
```yaml
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 30
```
`test-results/` is where screenshots, videos, and traces land on failure — open traces with `npx playwright show-trace test-results/.../trace.zip`.e2e-testing--m3
baseline · 0.82
# Making Playwright Tests More Reliable
## Core principle: use auto-waiting, not manual waits
**Avoid** `waitForTimeout()`, `sleep()`, or arbitrary delays — they're the #1 cause of flakiness (too short = fails, too long = slow).
## Clicking a button that appears after an API call
Playwright's locators auto-wait for elements to be visible, stable, and enabled, so often this just works:
```js
await page.getByRole('button', { name: 'Submit' }).click();
```
If the button is rendered conditionally *after* the API resolves, prefer waiting on the actual condition rather than the network:
```js
// Wait for the UI state that results from the API call
await page.getByRole('button', { name: 'Submit' }).waitFor({ state: 'visible' });
await page.getByRole('button', { name: 'Submit' }).click();
```
Or use `expect` with auto-retrying assertions (recommended):
```js
const button = page.getByRole('button', { name: 'Submit' });
await expect(button).toBeVisible();
await button.click();
```
## Waiting on network responses (when you truly need it)
```js
const responsePromise = page.waitForResponse(resp =>
resp.url().includes('/api/submit') && resp.status() === 200
);
await page.getByRole('button', { name: 'Submit' }).click();
await responsePromise;
```
Set this up **before** triggering the action to avoid race conditions.
## Other key strategies
1. **Mock/stub the API** with `page.route()` so tests don't depend on real backend latency:
```js
await page.route('**/api/user', route =>
route.fulfill({ json: { name: 'Test User' } }));
```
2. **Prefer role/text-based locators** (`getByRole`, `getByText`, `getByLabel`) over brittle CSS/XPath selectors. Add `data-testid` attributes where semantic locators aren't possible.
3. **Use web-first assertions** — `expect(locator).toBeVisible()` retries automatically; `locator.isVisible()` does not.
4. **Avoid test interdependence** — each test should set up its own state. Use `storageState` for auth instead of logging in via UI in every test.
5. **Configure retries and workers** in `playwright.config.ts`:
```js
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
```
6. **Trace on failure** for debugging CI-only flakiness:
```js
use: { trace: 'on-first-retry' }
```
7. **Disable animations** which cause "element not stable" errors:
```css
*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }
```
8. **Expect no race between assertion and navigation** — for clicks that trigger navigation, use `await page.waitForURL(...)` or Playwright's auto-waiting on `click()`.
The general rule: **wait for observable state (elements, assertions), never for time.**with skill · 1.00
Here are the most effective strategies, using your example (clicking a button that appears after an API call):
## 1. Rely on auto-waiting locators, not `waitForTimeout`
Playwright locators auto-wait for the element to exist, be visible, and be actionable:
```typescript
// Bad: arbitrary sleep — slow when fast, flaky when slow
await page.waitForTimeout(3000);
await page.click('#submit');
// Good: auto-waits until the button is actionable
await page.locator('[data-testid="submit-btn"]').click();
```
## 2. Wait for the specific network response, not a timer
If the button appears after an API call, tie the wait to that call:
```typescript
const responsePromise = page.waitForResponse(
resp => resp.url().includes('/api/data') && resp.status() === 200
);
await page.locator('[data-testid="load-btn"]').click();
await responsePromise;
// Now the button is guaranteed to be rendered from real data
await page.locator('[data-testid="confirm-btn"]').click();
```
## 3. Use web-first assertions
`expect(locator).toBeVisible()` retries automatically until the timeout — never use `isVisible()` in an `if` or plain `expect` on a boolean:
```typescript
await expect(page.locator('[data-testid="confirm-btn"]')).toBeVisible({ timeout: 10_000 });
await page.locator('[data-testid="confirm-btn"]').click();
```
## 4. Prefer `waitForLoadState`/`waitForURL` over sleeps after navigation
```typescript
await page.waitForURL('**/dashboard');
// or
await page.waitForLoadState('networkidle'); // use sparingly — can hang with polling/websockets
```
## 5. Mock flaky/slow APIs
For CI stability, intercept the API so tests don't depend on backend latency:
```typescript
await page.route('**/api/data', route =>
route.fulfill({ status: 200, json: { items: [/* deterministic data */] } })
);
```
## 6. Config-level hardening
```typescript
export default defineConfig({
retries: process.env.CI ? 2 : 0, // retry only in CI
workers: process.env.CI ? 1 : undefined, // reduce parallelism if resources are constrained
use: {
trace: 'on-first-retry', // debug flaky failures via trace viewer
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10_000,
},
});
```
## 7. Find and fix the flaky tests themselves
```bash
npx playwright test tests/search.spec.ts --repeat-each=10
```
Run suspects repeatedly locally to reproduce, then quarantine genuinely broken ones with `test.fixme(true, 'Issue #123')` while fixing the root cause.
**Key principle:** every wait should be tied to a *condition* (element visible, URL changed, response received), never a duration. Traces from retries (`trace: 'on-first-retry'`) are your best tool for diagnosing the remaining flaky cases — open them with `npx playwright show-trace trace.zip`.e2e-testing--m4
baseline · 0.67
# Structuring a Large Playwright Suite
## Directory layout
```
tests/
pages/ # Page Objects
base-page.ts
login-page.ts
checkout-page.ts
components/ # Reusable UI fragments (header, modal, nav)
header.component.ts
fixtures/ # Custom fixtures extending base test
test.ts
utils/ # Helpers: api clients, data builders, auth
api-client.ts
auth.ts
data/ # Test data, JSON fixtures
e2e/ # Test specs grouped by feature
auth/
login.spec.ts
checkout/
checkout.spec.ts
playwright.config.ts
```
## Page Object best practices
**1. One class per page, extend a shared base:**
```ts
// pages/base-page.ts
import { Page } from '@playwright/test';
export abstract class BasePage {
constructor(protected readonly page: Page) {}
async goto(path: string) {
await this.page.goto(path);
}
}
```
```ts
// pages/login-page.ts
import { Locator, Page, expect } from '@playwright/test';
import { BasePage } from './base-page';
export class LoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
await expect(this.page).toHaveURL(/dashboard/);
}
}
```
Key rules:
- **Locators as class fields**, resolved lazily — never store `page.locator(...)` results from `waitFor` or reuse stale element handles.
- **Prefer user-facing locators**: `getByRole`, `getByLabel`, `getByText`, `getByTestId`. Avoid brittle CSS/XPath selectors.
- **Expose behavior, not internals.** Tests call `loginPage.login(...)`, not `fill()` + `click()` sequences. Don't put assertions in page objects except where they define "readiness" (e.g., waiting for navigation).
- **No test data or branching logic in page objects** — pass data in as parameters.
- **Extract shared components** (header, nav, modals) into separate component classes that pages compose, so you don't duplicate locators across 30 page objects.
**2. Use custom fixtures instead of instantiating pages in every test:**
```ts
// fixtures/test.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
type Fixtures = { loginPage: LoginPage };
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
export { expect } from '@playwright/test';
```
```ts
// e2e/auth/login.spec.ts
import { test, expect } from '../../fixtures/test';
test('user can log in', async ({ loginPage }) => {
await loginPage.goto('/login');
await loginPage.login('user@example.com', 'secret');
await expect(loginPage.page.getByText('Welcome')).toBeVisible();
});
```
**3. Speed up with storage state.** Log in once in a setup project, save `storageState`, and reuse it in dependent projects so most tests skip the login UI:
```ts
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
});
```
**4. Parallelism & sharding.** Set `fullyParallel: true`, make tests independent (no shared state/ordering), and shard in CI.
## GitHub Actions integration
```yaml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --shard=${{ matrix.shard }}/4
# Always upload artifacts, even on failure
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-shard-${{ matrix.shard }}
path: playwright-report/
retention-days: 7
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: test-results-shard-${{ matrix.shard }}
path: test-results/
retention-days: 7
if-no-files-found: ignore
```
Config essentials:
```ts
// playwright.config.ts
export default defineConfig({
reporter: process.env.CI
? [['html', { open: 'never' }], ['github']]
: [['html', { open: 'on-failure' }]],
retries: process.env.CI ? 2 : 0,
use: {
trace: 'retain-on-failure', // enables trace viewer debugging
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
});
```
Notes:
- `if: ${{ !cancelled() }}` ensures artifacts upload even when tests fail (unlike `always()` it respects job cancellation).
- The `github` reporter annotates failed tests directly on PRs.
- `test-results/` contains traces, screenshots, and videos for failed tests; `playwright-report/` is the HTML report. Download artifacts and open `trace.zip` via `npx playwright show-trace trace.zip`.
- For large suites, also consider `merge-reports` to combine shard reports into one HTML report, and caching browsers (`actions/cache` keyed on the Playwright version) if `--withwith skill · 0.74
# Structuring a Large Playwright Suite
## Directory Layout
```
tests/
├── e2e/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── register.spec.ts
│ ├── features/
│ │ ├── browse.spec.ts
│ │ └── search.spec.ts
│ └── api/
│ └── endpoints.spec.ts
├── pages/ # Page objects
├── fixtures/ # Custom fixtures, test data
└── playwright.config.ts
```
## Page Object Model
Keep page objects as thin wrappers around locators + actions — no assertions inside them:
```typescript
import { Page, Locator } from '@playwright/test'
export class ItemsPage {
readonly page: Page
readonly searchInput: Locator
readonly itemCards: Locator
readonly createButton: Locator
constructor(page: Page) {
this.page = page
this.searchInput = page.locator('[data-testid="search-input"]')
this.itemCards = page.locator('[data-testid="item-card"]')
this.createButton = page.locator('[data-testid="create-btn"]')
}
async goto() {
await this.page.goto('/items')
}
async search(query: string) {
await this.searchInput.fill(query)
await this.page.waitForResponse(resp => resp.url().includes('/api/search'))
}
async getItemCount() {
return await this.itemCards.count()
}
}
```
Key rules for maintainability at scale:
- **Use `data-testid` selectors** — resilient to CSS/JS refactors.
- **Locators as class fields** — created once in the constructor, auto-waiting, no `waitForTimeout`.
- **Wait for specific conditions** (`waitForResponse`, visibility) instead of arbitrary timeouts.
- **No assertions in page objects** — assertions belong in specs so failures read clearly.
- **Compose, don't inherit** — share common behavior via small helper modules or custom fixtures rather than deep class hierarchies.
## Test Files
```typescript
import { test, expect } from '@playwright/test'
import { ItemsPage } from '../../pages/ItemsPage'
test.describe('Item Search', () => {
let itemsPage: ItemsPage
test.beforeEach(async ({ page }) => {
itemsPage = new ItemsPage(page)
await itemsPage.goto()
})
test('should search by keyword', async () => {
await itemsPage.search('test')
expect(await itemsPage.getItemCount()).toBeGreaterThan(0)
await expect(itemsPage.itemCards.first()).toContainText(/test/i)
})
})
```
One spec file per feature/flow, grouped by domain folder. Use custom fixtures (`tests/fixtures/auth.ts`) for login state so specs don't repeat auth boilerplate.
## Playwright Config
```typescript
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['junit', { outputFile: 'playwright-results.xml' }],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
```
## GitHub Actions Workflow
```yaml
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
env:
BASE_URL: ${{ vars.STAGING_URL }}
- uses: actions/upload-artifact@v4
if: always() # upload even when tests fail
with:
name: playwright-report
path: playwright-report/
retention-days: 30
```
Notes:
- `if: always()` is essential — otherwise failed runs upload nothing, which is exactly when you need the report.
- `trace: 'on-first-retry'` + `retries: 2` in CI gives you a trace zip for every flaky failure; open it with `npx playwright show-trace`.
- For large suites, consider `--shard` across multiple CI jobs to cut wall-clock time, and upload per-shard artifacts.
## Flakiness Hygiene
- Diagnose flaky tests with `--repeat-each=10` and `--retries=3` locally.
- Quarantine known-flaky tests with `test.fixme(...)` referencing an issue number, rather than letting them erode trust in the suite.
- Prefer auto-waiting locators and explicit condition waits over `waitForTimeout`.Limitations
- Agent noise: single-model results vary run to run; the confidence interval reflects task-level variance.
- Screen tier (1 trial/arm) is a directional gate, not a verified claim.
- Results are for the SkillzBench Reference Agent on glm-5.3-flash@cf and do not necessarily transfer to other agents or models.
- Task coverage: Skill-matched tasks (generated, public + held-out) only; a skill can shine elsewhere.