| name | e2e-testing |
| description | Playwright E2E testing patterns. Use in Phase 4 (Verification) by Tester agents. Covers Page Object Model, test structure, configuration, flaky test strategies, artifact management, and CI/CD integration. |
E2E Testing Patterns
Playwright patterns for stable, fast, maintainable E2E tests.
This is the deterministic layer. For the exploratory goal-verification + test-generation layer above it (Phase 4.5), see the agentic-testing skill — its Generator emits specs that MUST follow the conventions here.
Test File Organization
tests/
├── e2e/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── register.spec.ts
│ ├── features/
│ │ ├── crud.spec.ts
│ │ └── search.spec.ts
│ └── api/
│ └── endpoints.spec.ts
├── fixtures/
│ ├── auth.ts
│ └── data.ts
├── pages/ # Page Object Models
│ ├── login.page.ts
│ └── dashboard.page.ts
└── playwright.config.ts
Page Object Model
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');
await this.page.waitForLoadState('networkidle');
}
async search(query: string) {
await this.searchInput.fill(query);
await this.page.waitForResponse(r => r.url().includes('/api/search'));
}
}
Test Structure
import { test, expect } from '@playwright/test';
import { ItemsPage } from '../pages/items.page';
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');
await expect(itemsPage.itemCards.first()).toContainText(/test/i);
});
test('should handle no results', async ({ page }) => {
await itemsPage.search('xyznonexistent');
await expect(page.locator('[data-testid="no-results"]')).toBeVisible();
});
});
Selector Priority
getByRole() — Accessibility roles
getByLabel() — Form labels
getByPlaceholder() — Input placeholders
getByText() — Visible text
getByTestId() — data-testid (last resort)
Flaky Test Fixes
| Cause | Bad | Good |
|---|
| Race condition | page.click(selector) | page.locator(selector).click() |
| Network timing | page.waitForTimeout(5000) | page.waitForResponse(...) |
| Animation | Click during animation | waitFor({ state: 'visible' }) |
Wait Patterns
const responsePromise = page.waitForResponse(
r => r.url().includes('/api/data') && r.status() === 200
);
await page.locator('[data-testid="submit"]').click();
await responsePromise;
await page.locator('[data-testid="modal"]').waitFor({ state: 'visible' });
Artifact Layout (_test/ — gitignored)
All E2E run outputs live under a single project-root _test/ folder — gitignored, never committed
(leading _ = harness "special/owned", same family as _docs/_note). One dated, named folder per run;
screenshots isolated in their own subfolder.
_test/ # GITIGNORED — all E2E run outputs
<YYYY-MM-DD>-<test-name>/ # one folder per run/suite (date + what was tested, kebab)
screenshots/ # screenshots ONLY here — never scattered at run root
artifacts/ # Playwright outputDir: traces, videos, failure shots
report/ # HTML report + results.json
run.log
.auth/ # shared session/state tokens (cross-run) — also gitignored
Rules (MUST):
_test/ MUST be gitignored. Before the first run, ensure the project .gitignore contains _test/
(append if missing) — it holds screenshots, traces, videos, reports, and live session tokens.
- Every run writes under
_test/<YYYY-MM-DD>-<test-name>/; that run's screenshots go in its screenshots/.
_test/ = throwaway. It holds run artifacts (from any run) + on-demand/exploratory specs
(e.g. scenario-to-e2e validation runs). Durable CI test code (committed suites, agentic-testing
crystallized specs) lives in the project's real test dir (tests//e2e/) — only its artifacts land in
_test/. To keep a generated spec in CI, promote it out of _test/ into the committed test dir.
const RUN = `_test/${process.env.E2E_RUN}`;
await page.screenshot({ path: `${RUN}/screenshots/dashboard.png` });
await page.locator('[data-testid="chart"]').screenshot({ path: `${RUN}/screenshots/chart.png` });
Configuration
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
outputDir: `_test/${process.env.E2E_RUN}/artifacts`,
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: [
['html', { outputFolder: `_test/${process.env.E2E_RUN}/report`, open: 'never' }],
['json', { outputFile: `_test/${process.env.E2E_RUN}/report/results.json` }],
],
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: 'mobile', use: { ...devices['Pixel 5'] } },
],
});
See also
skills/agent-browser-e2e/SKILL.md — when the agent-browser CLI + skill are installed, prefer it as the on-demand browser driver for E2E/QA/smoke and headless (Auth-Vault) login. This deterministic Playwright layer is the fallback and the crystallization target for verified flows.