End-to-end testing for web applications with Playwright, Cypress, Selenium, and Puppeteer.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add cosmix/loom --skill loom-e2e-testing --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Loom E2e Testing?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/cosmix-loom-e2e-testing)More formats (shields.io, HTML) on the badges page.
---
name: loom-e2e-testing
description: End-to-end testing for web applications with Playwright, Cypress, Selenium, and Puppeteer.
triggers:
- e2e
- e2e testing
- end-to-end
- end-to-end tests
- Playwright
- Cypress
- Selenium
- Puppeteer
- Page Object Model
- page object
- test fixtures
- selectors
- locator
- locators
- getByRole
- data-testid
- auto-wait
- async tests
- network interception
- route mocking
- visual regression
- visual testing
- screenshot
- trace viewer
- flaky tests
- flakiness
- browser testing
- browser automation
- UI test
- UI testing
- acceptance test
- smoke test
- storage state
- test isolation
---
# E2E Testing
## Overview
Browser E2E validates full user journeys. This file owns **selectors, auto-waiting, network interception, POM, browser flakiness, and trace/video debugging** (Playwright-first, Cypress noted where it differs). E2E is the *tip* of the pyramid — keep it thin and reserve it for critical paths; push logic down to unit/integration (`loom-test-strategy`). For test-double taxonomy and AAA see `loom-testing`.
## Framework choice
Default to **Playwright** for new suites: true multi-browser (incl. WebKit/Safari), multi-tab/origin, out-of-process network interception, built-in parallelism and trace viewer, and non-JS bindings (Python/.NET/Java). Choose **Cypress** only when the team is already invested or wants its live time-travel debugger; its in-browser model limits multi-tab, cross-origin, and true WebKit. Selenium/Puppeteer: legacy or Chrome-only automation, not greenfield E2E.
```bash
npm init playwright@latest # scaffolds config + CI + browsers
```
```typescript
// playwright.config.ts — the load-bearing knobs
export default defineConfig({
testDir: "./e2e",
fullyParallel: true, // parallel within files too
forbidOnly: !!process.env.CI, // fail CI if a .only slips in
retries: process.env.CI ? 2 : 0, // retry ONLY to surface flakes
reporter: [["html"], ["junit", { outputFile: "results.xml" }]],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry", // trace the retry, not every run
screenshot: "only-on-failure",
video: "retain-on-failure",
},
webServer: { // boots the app, waits for it
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
});
```
⚠ `retries` exists to **diagnose** flakes (a test that passes only on retry is flaky and must be fixed), never to hide them — a retry-masked bug ships. `webServer` is why `page.goto` doesn't race the server boot; without it you get connection-refused flakes.
## Selectors — user-facing first
Priority, best → worst. Higher options survive refactors and assert accessibility for free; CSS/XPath couple tests to DOM structure and shatter on markup changes.
1. **Role + accessible name** — `getByRole("button", { name: "Sign in" })`
2. **Label / placeholder / text** — `getByLabel("Email")`, `getByText("Welcome")`
3. **`data-testid`** — for elements with no stable role/text
4. CSS — only for structural selection (`.modal-content`)
5. **XPath — avoid** (brittle, unreadable)
```typescript
page.getByRole("textbox", { name: "Email" });
page.getByLabel("Password");
page.getByTestId("product-card-123"); // add data-testid in the component
// chain/filter instead of nth-index (index shifts → flake)
page.getByTestId("product-list")
.getByRole("listitem").filter({ hasText: "Widget" })
.getByRole("button", { name: "Add to cart" });
```
⚠ Never select by index (`.nth(2)`, `items[2]`) or auto-generated CSS-module class hashes — both change silently. Add `data-testid` at the component and strip it in prod builds (`babel-plugin-react-remove-properties`). Cypress: use `@testing-library/cypress` (`cy.findByRole`) for the same priority.
## Auto-waiting vs explicit waits
Playwright locator actions **auto-wait** for the element to be attached, visible, stable, and enabled before acting, and web-first assertions (`expect(locator).toBeVisible()`) **retry** until they pass or time out. This eliminates almost all manual waiting.
```typescript
// RIGHT — assert the condition; it retries internally
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Success")).toBeVisible();
// WRONG — arbitrary sleep: flaky if slow, wasteful if fast
await page.click("#submit");
await page.waitForTimeout(2000); // ⛔ never
```
Use explicit waits only for things auto-wait can't see — navigation and network:
```typescript
await page.waitForURL("/dashboard"); // after a click that navigates
await page.getByTestId("spinner").waitFor({ state: "hidden" });
```
⚠ `waitForTimeout` is banned in real suites — it's the #1 source of both flakiness and slowness. `waitForLoadState("networkidle")` is discouraged by Playwright (racy on apps that poll); wait for a concrete UI condition instead. Cypress auto-retries assertions/queries but **not** raw values — re-query, don't cache elements.
## Network interception
Stub the network to make E2E deterministic and to reach error states the UI can't otherwise hit. Register the route **before** the navigation that triggers it.
```typescript
// Deterministic data — no dependence on backend state
await page.route("**/api/products", (route) =>
route.fulfill({ status: 200, contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Test Widget" }]) }));
// Force an error path
await page.route("**/api/checkout", (route) => route.fulfill({ status: 500 }));
// Assert a request happened (wait for it, don't sleep)
const resp = page.waitForResponse("**/api/order");
await page.getByRole("button", { name: "Place order" }).click();
expect((await resp).status()).toBe(201);
```
⚠ Use glob/regex (`**/api/x`) — exact strings miss query params and absolute vs relative URLs. Decide deliberately: **stub** for speed/determinism/error-injection; hit the **real** backend for a handful of true full-stack smoke tests. Cypress uses `cy.intercept()` with an alias + `cy.wait("@alias")`.
## Page Object Model
Encapsulate page structure so a UI change touches one file, not fifty tests. Expose **user intentions** (`login(email, pw)`), not raw locators.
```typescript
// e2e/pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
readonly email = () => this.page.getByLabel("Email");
readonly submit = () => this.page.getByRole("button", { name: "Sign in" });
async goto() { await this.page.goto("/login"); }
async login(email: string, pw: string) {
await this.email().fill(email);
await this.page.getByLabel("Password").fill(pw);
await this.submit().click();
}
}
```
⚠ Keep **assertions out of page objects** (except small `expect` helpers) — a POM describes the page; the test owns the verification. Prefer Playwright **fixtures** over a monolithic `App` object for composing pages/state; they scope setup/teardown automatically.
## Fixtures & auth (speed)
Logging in through the UI on every test is the biggest E2E time sink. Authenticate **once**, save `storageState`, and reuse it — cutting minutes off a suite.
```typescript
// global-setup: log in once, persist cookies/localStorage
await page.goto("/login"); /* ...fill+submit... */
await page.context().storageState({ path: "e2e/.auth/user.json" });
// then in config or a fixture:
use: { storageState: "e2e/.auth/user.json" }
```
Custom fixtures give per-test isolated data with automatic cleanup:
```typescript
export const test = base.extend<{ user: User }>({
user: async ({}, use) => {
const u = await prisma.user.create({ data: UserFactory.create() });
await use(u);
await prisma.user.delete({ where: { id: u.id } }); // teardown always runs
},
});
```
⚠ Even faster: seed state via the **API request context** rather than clicking through setup — see hybrid pattern below.
## Preventing browser flakiness
General flaky-test theory (clock, RNG, shared state) and the full cause table are in `loom-test-strategy`. Browser-specific rules:
- **Wait for conditions, never timeouts** — covered above; this is 80% of E2E flake.
- **Select by role/text/testid, never index** — DOM order shifts.
- **Isolate per test** — Playwright gives each test a fresh browser context (clean cookies/storage) by default; don't defeat it with shared global state. Mint unique data (`test-${Date.now()}@x.com`).
- **Freeze time & animations** for anything time- or motion-sensitive:
```typescript
await page.clock.setFixedTime(new Date("2024-01-15T10:00:00Z"));
await page.emulateMedia({ reducedMotion: "reduce" });
```
- **Broken-window rule:** fix or quarantine a flaky test the day it appears; a tolerated flake trains the team to ignore red.
## Debugging: trace, video, screenshot
```bash
npx playwright test --trace on # force a trace for a run
npx playwright show-trace trace.zip # timeline, DOM snapshots, network, console
npx playwright test --debug # step through with Inspector
```
Configure artifacts on failure only (`trace: "on-first-retry"`, `video/screenshot: retain/only-on-failure` — see config) so green runs stay fast. The **trace viewer** (time-travel DOM + network + console per action) is the fastest way to root-cause a CI-only failure — wire trace upload into CI artifacts. `await page.pause()` opens the Inspector mid-test for local poking.
## Visual regression
```typescript
await expect(page).toHaveScreenshot("dashboard.png", {
mask: [page.getByTestId("timestamp")], // hide dynamic regions
maxDiffPixelRatio: 0.01,
});
```
⚠ Screenshots are **OS/font/GPU-dependent** — generate and compare baselines in the *same* container you run CI in (`--update-snapshots` locally on a Mac then failing in Linux CI is the classic trap). Disable animations, mask dynamic content (dates, avatars, ads), and pin the viewport. Treat baselines as reviewed artifacts, not auto-accepted.
## Hybrid API + UI
Do setup and teardown over HTTP (fast, reliable); reserve the browser for the user-facing assertion. Cuts runtime and removes setup-related flake.
```typescript
test("cart shows seeded item", async ({ page, request }) => {
await request.post("/api/cart/add", { data: { productId: "123", quantity: 2 } });
await page.goto("/cart");
await expect(page.getByTestId("cart-item")).toHaveCount(1);
await expect(page.getByTestId("quantity")).toHaveText("2");
});
```
## Cross-browser scope
Run the full matrix (Chromium/Firefox/WebKit + mobile viewports) on **critical paths only**; run the rest on Chromium to keep CI fast. Skip project-specifically rather than duplicating tests:
```typescript
test.describe("Admin panel", () => {
test.skip(({ browserName }) => browserName !== "chromium"); // Chrome-only
});
```
## Smoke suite
A tiny must-pass set gating every release — homepage renders, auth works, key APIs return < 500:
```typescript
test("user can sign in", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("user@example.com");
await page.getByLabel("Password").fill("password123");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
});
```
## Verify before done
- [ ] Selectors are role/label/text/testid — no index, XPath, or generated-class-hash selectors
- [ ] Zero `waitForTimeout`/`sleep`; waits target a condition, URL, or response
- [ ] Auth via `storageState`; per-test data is unique; cleanup in fixture teardown
- [ ] Network stubbed where determinism/error-injection is needed; real backend only for true smoke tests
- [ ] `retries` used to *surface* flakes, not mask them; any retry-only pass is triaged
- [ ] trace/video/screenshot retained on failure and uploaded as CI artifacts
- [ ] E2E limited to critical journeys; logic-level cases pushed down (`loom-test-strategy`)
- [ ] Visual baselines generated in the CI container, with dynamic regions masked
- [ ] No `.only` left in the suite (`forbidOnly` on in CI)
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!