Writing E2E Tests
Page objects, auth fixtures, API mocking, and test patterns.
Follow these patterns to write maintainable Playwright tests that survive UI changes and run reliably in CI.
Page Object Model
Encapsulate page interactions in reusable classes:
// e2e/pages/login.page.ts
import { type Page, expect } from "@playwright/test";
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto("/login");
}
async login(email: string, password: string) {
await this.page.getByLabel("Email").fill(email);
await this.page.getByLabel("Password").fill(password);
await this.page.getByRole("button", { name: "Sign in" }).click();
}
async expectRedirectToDashboard() {
await expect(this.page).toHaveURL(/\/dashboard/);
}
}// e2e/specs/auth.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/login.page";
test("user can log in", async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login(process.env.E2E_TEST_EMAIL!, process.env.E2E_TEST_PASSWORD!);
await login.expectRedirectToDashboard();
});Auth fixture (shared login)
Avoid repeating login in every test:
// e2e/fixtures/auth.fixture.ts
import { test as base, type Page } from "@playwright/test";
import { LoginPage } from "../pages/login.page";
type AuthFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
const login = new LoginPage(page);
await login.goto();
await login.login(process.env.E2E_TEST_EMAIL!, process.env.E2E_TEST_PASSWORD!);
await login.expectRedirectToDashboard();
await use(page);
},
});
export { expect } from "@playwright/test";// e2e/specs/dashboard.spec.ts
import { test, expect } from "../fixtures/auth.fixture";
test("dashboard shows user name", async ({ authenticatedPage }) => {
await expect(authenticatedPage.getByTestId("user-name")).toBeVisible();
});Locator best practices
| Prefer | Avoid |
|---|---|
getByRole("button", { name: "Save" }) | page.locator(".btn-primary") |
getByLabel("Email") | page.locator("#email-input") |
getByTestId("checkout-form") | XPath selectors |
getByText("Order confirmed") | Deep CSS chains |
API testing (no browser)
Use Playwright's request fixture for API-level tests:
test("API returns projects for authenticated user", async ({ request }) => {
const loginRes = await request.post("/api/auth/login", {
data: { email: process.env.E2E_TEST_EMAIL, password: process.env.E2E_TEST_PASSWORD },
});
expect(loginRes.ok()).toBeTruthy();
const { token } = await loginRes.json();
const projectsRes = await request.get("/api/v1/projects", {
headers: { Authorization: `Bearer ${token}` },
});
expect(projectsRes.ok()).toBeTruthy();
const { data } = await projectsRes.json();
expect(data.length).toBeGreaterThan(0);
});Mocking external services
Mock third-party calls to keep tests fast and deterministic:
test("checkout shows success", async ({ page }) => {
await page.route("**/api.stripe.com/**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "pi_mock", status: "succeeded" }),
}),
);
await page.goto("/checkout");
await page.getByRole("button", { name: "Pay now" }).click();
await expect(page.getByText("Payment successful")).toBeVisible();
});Test organization
e2e/specs/
├── smoke.spec.ts # Fast checks — run on every PR
├── auth.spec.ts # Login, logout, session refresh
├── dashboard.spec.ts # Core authenticated flows
└── checkout.spec.ts # Payment flow (Beta only — mutates data)| Suite | When to run | Target |
|---|---|---|
smoke | Every PR | Alpha preview |
auth, dashboard | Every PR | Alpha preview |
checkout | Beta merge + nightly | Beta |
Tag tests to control CI scope:
test("full checkout flow @beta @mutating", async ({ page }) => {
// ...
});# CI: smoke + auth only (PR)
pnpm exec playwright test --grep-invert "@beta"
# CI: full suite (Beta)
pnpm exec playwright testDebugging failed tests
# Run in debug mode — step through
pnpm exec playwright test --debug
# Run with trace viewer
pnpm exec playwright test --trace on
pnpm exec playwright show-trace trace.zipIn CI, traces and screenshots attach to the GitHub Actions summary automatically.