E2E Testing Patterns
Build reliable, fast E2E test suites with Playwright and Cypress. Critical user journey coverage, flaky test elimination, CI/CD integration.
Build reliable, fast E2E test suites with Playwright and Cypress. Critical user journey coverage, flaky test elimination, CI/CD integration.
Real data. Real impact.
Emerging
Developers
Per week
Open source
Skills give you superpowers. Install in 30 seconds.
Test what users do, not how code works. E2E tests prove the system works as a whole — they're your confidence to ship.
npx clawhub@latest install e2e-testing-patterns
Provides patterns for building end-to-end test suites that:
/\ /E2E\ ← FEW: Critical paths only (this skill) /─────\ /Integr\ ← MORE: Component interactions, API contracts /────────\ /Unit Tests\ ← MANY: Fast, isolated, cover edge cases /────────────\
| E2E Tests ✓ | NOT E2E Tests ✗ |
|---|---|
| Critical user journeys (login → dashboard → action → logout) | Unit-level logic (use unit tests) |
| Multi-step flows (checkout, onboarding wizard) | API contracts (use integration tests) |
| Cross-browser compatibility | Edge cases (too slow, use unit tests) |
| Real API integration | Internal implementation details |
| Authentication flows | Component visual states (use Storybook) |
Rule of thumb: If it would devastate your business to break, E2E test it. If it's just inconvenient, test it faster with unit/integration tests.
| Principle | Why | How |
|---|---|---|
| Test behavior, not implementation | Survives refactors | Assert on user-visible outcomes, not DOM structure |
| Independent tests | Parallelizable, debuggable | Each test creates its own data, cleans up after |
| Deterministic waits | No flakiness | Wait for conditions, not fixed timeouts |
| Stable selectors | Survives UI changes | Use , roles, labels — never CSS classes |
| Fast feedback | Developers run them | Mock external services, parallelize, shard |
// playwright.config.ts import { defineConfig, devices } from "@playwright/test";export default defineConfig({ testDir: "./e2e", timeout: 30000, expect: { timeout: 5000 }, fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [["html"], ["junit", { outputFile: "results.xml" }]], use: { baseURL: "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"] } }, { name: "webkit", use: { ...devices["Desktop Safari"] } }, { name: "mobile", use: { ...devices["iPhone 13"] } }, ], });
Encapsulate page logic. Tests read like user stories.
// pages/LoginPage.ts import { Page, Locator } from "@playwright/test";export class LoginPage { readonly page: Page; readonly emailInput: Locator; readonly passwordInput: Locator; readonly loginButton: Locator; readonly errorMessage: Locator;
constructor(page: Page) { this.page = page; this.emailInput = page.getByLabel("Email"); this.passwordInput = page.getByLabel("Password"); this.loginButton = page.getByRole("button", { name: "Login" }); this.errorMessage = page.getByRole("alert"); }
async goto() { await this.page.goto("/login"); }
async login(email: string, password: string) { await this.emailInput.fill(email); await this.passwordInput.fill(password); await this.loginButton.click(); } }
// tests/login.spec.ts import { test, expect } from "@playwright/test"; import { LoginPage } from "../pages/LoginPage";
test("successful login redirects to dashboard", async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login("user@example.com", "password123");
await expect(page).toHaveURL("/dashboard"); await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); });
Create and clean up test data automatically.
// fixtures/test-data.ts import { test as base } from "@playwright/test";export const test = base.extend<{ testUser: TestUser }>({ testUser: async ({}, use) => { // Setup: Create user const user = await createTestUser({ email:
, password: "Test123!@#", });test-${Date.now()}@example.comawait use(user); // Teardown: Clean up await deleteTestUser(user.id);}, });
// Usage — testUser is created before, deleted after test("user can update profile", async ({ page, testUser }) => { await page.goto("/login"); await page.getByLabel("Email").fill(testUser.email); // ... });
Never use fixed timeouts. Wait for specific conditions.
// ❌ FLAKY: Fixed timeout await page.waitForTimeout(3000);// ✅ STABLE: Wait for conditions await page.waitForLoadState("networkidle"); await page.waitForURL("/dashboard");
// ✅ BEST: Auto-waiting assertions await expect(page.getByText("Welcome")).toBeVisible(); await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled();
// Wait for API response const responsePromise = page.waitForResponse( (r) => r.url().includes("/api/users") && r.status() === 200 ); await page.getByRole("button", { name: "Load" }).click(); await responsePromise;
Isolate tests from real external services.
test("shows error when API fails", async ({ page }) => { // Mock the API response await page.route("**/api/users", (route) => { route.fulfill({ status: 500, body: JSON.stringify({ error: "Server Error" }), }); });await page.goto("/users"); await expect(page.getByText("Failed to load users")).toBeVisible(); });
test("handles slow network gracefully", async ({ page }) => { await page.route("**/api/data", async (route) => { await new Promise((r) => setTimeout(r, 3000)); // Simulate delay await route.continue(); });
await page.goto("/dashboard"); await expect(page.getByText("Loading...")).toBeVisible(); });
// cypress/support/commands.ts declare global { namespace Cypress { interface Chainable { login(email: string, password: string): Chainable<void>; dataCy(value: string): Chainable<JQuery<HTMLElement>>; } } }Cypress.Commands.add("login", (email, password) => { cy.visit("/login"); cy.get('[data-testid="email"]').type(email); cy.get('[data-testid="password"]').type(password); cy.get('[data-testid="login-button"]').click(); cy.url().should("include", "/dashboard"); });
Cypress.Commands.add("dataCy", (value) => { return cy.get(
); });[data-cy="${value}"]// Usage cy.login("user@example.com", "password"); cy.dataCy("submit-button").click();
// Mock API cy.intercept("GET", "/api/users", { statusCode: 200, body: [{ id: 1, name: "John" }], }).as("getUsers");cy.visit("/users"); cy.wait("@getUsers"); cy.get('[data-testid="user-list"]').children().should("have.length", 1);
| Priority | Selector Type | Example | Why |
|---|---|---|---|
| 1 | Role + name | | Accessible, user-facing |
| 2 | Label | | Accessible, semantic |
| 3 | data-testid | | Stable, explicit for testing |
| 4 | Text content | | User-facing |
| ❌ | CSS classes | | Breaks on styling changes |
| ❌ | DOM structure | | Breaks on any restructure |
// ❌ BAD: Brittle selectors cy.get(".btn.btn-primary.submit-button").click(); cy.get("div > form > div:nth-child(2) > input").type("text");// ✅ GOOD: Stable selectors page.getByRole("button", { name: "Submit" }).click(); page.getByLabel("Email address").fill("user@example.com"); page.getByTestId("email-input").fill("user@example.com");
// Playwright visual comparisons test("homepage looks correct", async ({ page }) => { await page.goto("/"); await expect(page).toHaveScreenshot("homepage.png", { fullPage: true, maxDiffPixels: 100, }); });test("button states", async ({ page }) => { const button = page.getByRole("button", { name: "Submit" });
await expect(button).toHaveScreenshot("button-default.png");
await button.hover(); await expect(button).toHaveScreenshot("button-hover.png"); });
// npm install @axe-core/playwright import AxeBuilder from "@axe-core/playwright";test("page has no accessibility violations", async ({ page }) => { await page.goto("/");
const results = await new AxeBuilder({ page }) .exclude("#third-party-widget") // Exclude things you can't control .analyze();
expect(results.violations).toEqual([]); });
# Run in headed mode (see the browser) npx playwright test --headedDebug mode (step through)
npx playwright test --debug
Show trace viewer for failed tests
npx playwright show-report
// Add test steps for better failure reports test("checkout flow", async ({ page }) => { await test.step("Add item to cart", async () => { await page.goto("/products"); await page.getByRole("button", { name: "Add to Cart" }).click(); }); await test.step("Complete checkout", async () => { await page.goto("/checkout"); // ... if this fails, you know which step }); }); // Pause for manual inspection await page.pause();
When a test fails intermittently, check:
| Issue | Fix |
|---|---|
Fixed calls | Replace with or expect assertions |
| Race conditions on page load | Wait for or specific elements |
| Test data pollution | Ensure tests create/clean their own data |
| Animation timing | Wait for animations to complete or disable them |
| Viewport inconsistency | Set explicit viewport in config |
| Random test order issues | Tests must be independent |
| Third-party service flakiness | Mock external APIs |
# GitHub Actions example name: E2E Tests on: [push, pull_request]jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci - run: npx playwright install --with-deps - run: npm run build - run: npm run start & npx wait-on http://localhost:3000 - run: npx playwright test - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/
waitForTimeout() or cy.wait(ms) — they cause flaky tests and slow down suites// Navigation await page.goto("/path"); await page.goBack(); await page.reload();// Interactions await page.click("selector"); await page.fill("selector", "text"); await page.type("selector", "text"); // Types character by character await page.selectOption("select", "value"); await page.check("checkbox");
// Assertions await expect(page).toHaveURL("/expected"); await expect(locator).toBeVisible(); await expect(locator).toHaveText("expected"); await expect(locator).toBeEnabled(); await expect(locator).toHaveCount(3);
// Navigation cy.visit("/path"); cy.go("back"); cy.reload();// Interactions cy.get("selector").click(); cy.get("selector").type("text"); cy.get("selector").clear().type("text"); cy.get("select").select("value"); cy.get("checkbox").check();
// Assertions cy.url().should("include", "/expected"); cy.get("selector").should("be.visible"); cy.get("selector").should("have.text", "expected"); cy.get("selector").should("have.length", 3);
No automatic installation available. Please visit the source repository for installation instructions.
View Installation Instructions1,500+ AI skills, agents & workflows. Install in 30 seconds. Part of the Torly.ai family.
© 2026 Torly.ai. All rights reserved.