| name | e2e-testing |
| description | End-to-end testing patterns and best practices for web applications using Playwright, Cypress, Selenium, and Puppeteer. Covers Page Object Model, test fixtures, selector strategies, async handling, visual regression testing, and flaky test prevention. Includes QA expertise for acceptance testing, smoke testing, cross-browser testing, and test reliability. Use when setting up E2E tests, debugging test failures, improving test reliability, or implementing browser automation. Trigger keywords: e2e, e2e testing, end-to-end, end-to-end tests, Playwright, Cypress, Selenium, Puppeteer, Page Object Model, page object, test fixtures, selectors, locator, locators, data-testid, async tests, visual regression, visual testing, screenshot, flaky tests, flakiness, browser testing, browser automation, UI test, UI testing, acceptance test, acceptance testing, smoke test, smoke testing, integration test, wait, waits, assertion, assertions, test data, test isolation. |
E2E Testing
Overview
End-to-end (E2E) testing validates complete user flows through the application, ensuring all components work together correctly. This skill covers modern E2E testing patterns using Playwright and Cypress, including architectural patterns, selector strategies, and techniques for building reliable, maintainable test suites.
Instructions
1. Choose Your Framework
Playwright vs Cypress Comparison:
| Feature | Playwright | Cypress |
|---|
| Multi-browser | Chrome, Firefox, Safari, Edge | Chrome, Firefox, Edge |
| Multi-tab/window | Yes | Limited |
| Network interception | Powerful | Good |
| Parallel execution | Built-in | Requires Dashboard |
| Language support | JS, TS, Python, .NET, Java | JS, TS |
| iframes | Full support | Limited |
| Mobile emulation | Excellent | Basic |
Playwright Setup:
npm init playwright@latest
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
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",
},
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"] } },
],
: {
: ,
: ,
: !process..,
},
});
Cypress Setup:
npm install cypress --save-dev
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
viewportWidth: 1280,
viewportHeight: 720,
video: false,
screenshotOnRunFailure: true,
retries: { runMode: 2, openMode: 0 },
setupNodeEvents(on, config) {
},
},
});
2. Implement Page Object Model (POM)
Playwright Page Object:
import { Page, Locator } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
this.submitButton = page.getByRole("button", { name: "Sign in" });
this.errorMessage = page.getByRole("alert");
}
async goto() {
await ..();
}
() {
..(email);
..(password);
..();
}
(): <> {
( ..()) ?? ;
}
}
Cypress Page Object:
export class LoginPage {
visit() {
cy.visit("/login");
return this;
}
getEmailInput() {
return cy.findByLabelText("Email");
}
getPasswordInput() {
return cy.findByLabelText("Password");
}
getSubmitButton() {
return cy.findByRole("button", { name: "Sign in" });
}
login(email: string, password: string) {
this.getEmailInput().type(email);
this.getPasswordInput().type(password);
this.getSubmitButton().click();
return this;
}
}
Page Object Composition:
import { Page } from "@playwright/test";
import { LoginPage } from "./LoginPage";
import { DashboardPage } from "./DashboardPage";
import { CheckoutPage } from "./CheckoutPage";
export class App {
readonly login: LoginPage;
readonly dashboard: DashboardPage;
readonly checkout: CheckoutPage;
constructor(page: Page) {
this.login = new LoginPage(page);
this.dashboard = new DashboardPage(page);
this.checkout = new CheckoutPage(page);
}
}
test("user can complete purchase", async ({ page }) => {
const app = new App(page);
app..();
app..(, );
app..();
app..();
});
3. Manage Test Fixtures and Data
Playwright Fixtures:
import { test as base } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
type AuthFixtures = {
authenticatedPage: Page;
loginPage: LoginPage;
};
export const test = base.extend<AuthFixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
authenticatedPage: async ({ page }, use) => {
await page.goto("/login");
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Password").fill("password123");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL();
(page);
},
});
test = base.<>({
: ({ browser }, use) => {
context = browser.({
: ,
});
page = context.();
(page);
context.();
},
});
Test Data Factories:
import { faker } from "@faker-js/faker";
export const UserFactory = {
create(overrides = {}) {
return {
email: faker.internet.email(),
password: faker.internet.password({ length: 12 }),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
...overrides,
};
},
createAdmin(overrides = {}) {
return this.create({ role: "admin", ...overrides });
},
};
export const ProductFactory = {
create(overrides = {}) {
return {
name: faker.commerce.productName(),
price: parseFloat(faker.commerce.price()),
description: faker.commerce.productDescription(),
sku: faker.string.alphanumeric().(),
...overrides,
};
},
};
Database Seeding:
import { test as base } from "@playwright/test";
import { prisma } from "../../src/lib/prisma";
import { UserFactory, ProductFactory } from "./factories";
export const test = base.extend({
testUser: async ({}, use) => {
const userData = UserFactory.create();
const user = await prisma.user.create({ data: userData });
await use(user);
await prisma.user.delete({ where: { id: user.id } });
},
seededProducts: async ({}, use) => {
const products = await Promise.all(
Array.from({ length: 5 }, () =>
prisma.product.create({ data: ProductFactory.create() }),
),
);
(products);
prisma..({
: { : { : products.( p.) } },
});
},
});
4. Apply Selector Strategies
Selector Priority (Best to Worst):
- Accessibility roles and labels
- data-testid attributes
- Text content
- CSS selectors
- XPath (avoid)
Playwright Selector Examples:
page.getByRole("button", { name: "Submit" });
page.getByRole("textbox", { name: "Email" });
page.getByRole("link", { name: "Learn more" });
page.getByLabel("Password");
page.getByPlaceholder("Enter your email");
page.getByText("Welcome back");
page.getByTestId("user-avatar");
page.getByTestId("product-card-123");
page.locator("table tbody tr:first-child");
page.locator(".modal-content");
page
.getByTestId("product-list")
.getByRole("listitem")
.filter({ hasText: "Widget" })
.getByRole("button", { name: "Add to cart" });
Adding Test IDs to Components:
function ProductCard({ product }: { product: Product }) {
return (
<div data-testid={`product-card-${product.id}`}>
<h3 data-testid="product-name">{product.name}</h3>
<span data-testid="product-price">${product.price}</span>
<button data-testid="add-to-cart-btn">Add to Cart</button>
</div>
);
}
module.exports = {
env: {
production: {
plugins: [["react-remove-properties", { properties: ["data-testid"] }]],
},
},
};
5. Handle Async Operations and Waits
Auto-waiting in Playwright:
await page.getByRole("button").click();
await page.waitForURL("/dashboard");
await page.waitForResponse("/api/users");
await page.waitForLoadState("networkidle");
await expect(page.getByTestId("loading")).toBeHidden();
await expect(page.getByRole("table")).toBeVisible();
Network Request Handling:
const responsePromise = page.waitForResponse("/api/products");
await page.getByRole("button", { name: "Load Products" }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
await page.route("/api/products", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Mocked Product" }]),
});
});
await page.route("/api/user", async (route) => {
const response = await route.fetch();
const json = await response.json();
json.isAdmin = true;
await route.fulfill({ response, json });
});
Handling Loading States:
async function waitForDataLoad(page: Page) {
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
await expect(page.getByRole("table")).toHaveCount(1);
await page.waitForLoadState("networkidle");
}
6. Implement Visual Regression Testing
Playwright Visual Comparisons:
test("homepage visual", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("homepage.png");
});
test("button states", async ({ page }) => {
await page.goto("/components/button");
const button = page.getByRole("button", { name: "Click me" });
await expect(button).toHaveScreenshot("button-default.png");
await button.hover();
await expect(button).toHaveScreenshot("button-hover.png");
});
test("full page visual", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveScreenshot("dashboard.png", {
fullPage: true,
mask: [page.getByTestId()],
: ,
});
});
Visual Testing Configuration:
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixels: 100,
maxDiffPixelRatio: 0.01,
threshold: 0.2,
animations: "disabled",
},
},
use: {
viewport: { width: 1280, height: 720 },
},
});
Handling Dynamic Content:
await expect(page).toHaveScreenshot({
mask: [
page.getByTestId("current-date"),
page.getByTestId("user-avatar"),
page.locator(".advertisement"),
],
});
await page.emulateMedia({ reducedMotion: "reduce" });
await page.clock.setFixedTime(new Date("2024-01-15T10:00:00"));
7. Prevent Flaky Tests
Common Flakiness Causes and Solutions:
await page.click("#submit");
await page.waitForTimeout(2000);
expect(await page.textContent(".result")).toBe("Success");
await page.click("#submit");
await expect(page.getByText("Success")).toBeVisible();
const items = await page.locator(".list-item").all();
await items[2].click();
await page.getByRole("listitem").filter({ hasText: "Target Item" }).click();
await page.click('a[href="/dashboard"]');
await expect(page.locator(".dashboard")).toBeVisible();
await page.click('a[href="/dashboard"]');
await page.waitForURL("/dashboard");
await expect(page.locator(".dashboard")).toBeVisible();
Test Isolation:
test.beforeEach(async ({ page }) => {
await page.context().clearCookies();
await page.evaluate(() => localStorage.clear());
await page.goto("/");
});
test("create user", async ({ page }) => {
const uniqueEmail = `test-${Date.now()}@example.com`;
});
Retry Strategies:
export default defineConfig({
retries: process.env.CI ? 2 : 0,
use: {
trace: "on-first-retry",
},
});
test("potentially flaky test", async ({ page }) => {
test.info().annotations.push({ type: "retries", description: "3" });
});
Debugging Flaky Tests:
await context.tracing.start({ screenshots: true, snapshots: true });
await context.tracing.stop({ path: "trace.zip" });
await page.pause();
8. Implement Playwright-Specific Patterns
Playwright Advanced Features:
test("multiple users", async ({ browser }) => {
const userContext = await browser.newContext();
const adminContext = await browser.newContext();
const userPage = await userContext.newPage();
const adminPage = await adminContext.newPage();
await userPage.goto("/");
await adminPage.goto("/admin");
await adminPage.getByRole("button", { name: "Broadcast" }).click();
await expect(userPage.getByRole("alert")).toBeVisible();
await userContext.close();
await adminContext.close();
});
test("mobile navigation", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
page.();
(page.(, { : })).();
});
(, ({ context, page }) => {
context.({ : , : - });
context.([]);
page.();
(page.()).();
});
(, ({ context, page }) => {
page.();
context.();
page.();
(page.()).();
});
Playwright API Request Context:
test.beforeAll(async ({ request }) => {
const response = await request.post("/api/users", {
data: { email: "test@example.com", password: "secure123" },
});
expect(response.ok()).toBeTruthy();
});
test("order creation", 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");
});
9. Apply QA Best Practices
Test Pyramid Strategy:
E2E (5-10%) ← Smoke tests, critical paths
Integration (20-30%) ← Component integration
Unit Tests (60-75%) ← Business logic, utilities
Smoke Test Suite (Must-Pass Before Release):
test.describe("Smoke Tests", () => {
test("homepage loads", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/Home/);
await expect(page.getByRole("navigation")).toBeVisible();
});
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");
});
test("critical API endpoints respond", async ({ request }) => {
const endpoints = [, , ];
( endpoint endpoints) {
response = request.(endpoint);
(response.()).();
}
});
});
Acceptance Testing Patterns:
test.describe("User Story: Purchase Flow", () => {
test("As a customer, I want to buy a product so I can receive it at home", async ({
page,
}) => {
await page.goto("/products/widget-123");
await page.getByRole("button", { name: "Add to Cart" }).click();
await page.getByRole("link", { name: "Checkout" }).click();
await page.getByLabel("Address").fill("123 Main St");
await page.getByLabel("City").fill("Anytown");
await page.getByLabel("Card number").fill("4242424242424242");
await page.getByRole("button", { name: "Place Order" }).();
(page.()).();
(page.()).();
});
});
Cross-Browser Testing Strategy:
export default defineConfig({
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile-chrome", use: { ...devices["Pixel 5"] } },
{ name: "mobile-safari", use: { ...devices["iPhone 13"] } },
{ name: "edge", use: { ...devices["Desktop Edge"], channel: "msedge" } },
{
name: "chrome",
use: { ...devices["Desktop Chrome"], channel: "chrome" },
},
],
});
test.describe("Critical Flow", () => {
test("checkout works", async ({ page, browserName }) => {
});
});
test.describe(, {
test.( browserName !== );
(, ({ page }) => {
});
});
Test Observability and Reporting:
export default defineConfig({
reporter: [
["html", { outputFolder: "test-results/html" }],
["junit", { outputFile: "test-results/junit.xml" }],
["json", { outputFile: "test-results/results.json" }],
["./custom-reporter.ts"],
],
use: {
trace: "retain-on-failure",
video: "retain-on-failure",
screenshot: "only-on-failure",
},
});
class CustomReporter {
onTestEnd(test, result) {
if (result.status === "failed") {
}
}
onEnd(result) {
const passRate = (result.passed / result.total) * 100;
}
}
Best Practices
-
Keep Tests Independent
- No shared state between tests
- Each test sets up and tears down its own data
- Tests can run in any order
- Use database transactions or isolated test databases
-
Use Descriptive Test Names
test('user sees error message when submitting empty form', ...);
test('admin can delete user from management panel', ...);
test('form validation', ...);
test('delete user', ...);
-
Follow AAA Pattern (Arrange-Act-Assert)
test("product added to cart", async ({ page }) => {
await page.goto("/products");
await page
.getByTestId("product-1")
.getByRole("button", { name: "Add" })
.click();
await expect(page.getByTestId("cart-count")).toHaveText("1");
});
-
Minimize Test Scope
- Test one user flow per test
- Break complex flows into smaller tests
- Use fixtures for common setup
Examples
Example: Complete E2E Test Suite
import { test, expect } from "@playwright/test";
import { App } from "./pages";
import { UserFactory, ProductFactory } from "./fixtures/factories";
test.describe("Checkout Flow", () => {
let app: App;
test.beforeEach(async ({ page }) => {
app = new App(page);
});
test("guest user can complete checkout", async ({ page }) => {
await page.goto("/products");
await page
.getByTestId("product-card")
.first()
.getByRole("button", { name: "Add to Cart" })
.click();
await expect(page.getByTestId("cart-count")).toHaveText("1");
await page.getByRole(, { : }).();
page.();
page.().();
page.().();
page.().();
page.(, { : }).();
page.().();
page.().();
page.().();
page.(, { : }).();
(
page.(, { : }),
).();
(page.()).();
});
(, ({ page }) => {
page.();
page.().();
page.(, { : }).();
page.().();
page.(, { : }).();
(page.()).();
});
});
Example: API Mocking for Edge Cases
import { test, expect } from "@playwright/test";
test.describe("Error Handling", () => {
test("shows friendly error when API fails", async ({ page }) => {
await page.route("/api/products", (route) =>
route.fulfill({ status: 500, body: "Internal Server Error" }),
);
await page.goto("/products");
await expect(page.getByRole("alert")).toContainText(
"Unable to load products. Please try again.",
);
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
});
test("handles network timeout gracefully", async ({ page }) => {
await page.route("/api/products", async (route) => {
await new Promise( (resolve, ));
route.();
});
page.();
(page.()).();
});
});