UXDL Docs

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:

typescript
// 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/);
  }
}
typescript
// 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:

typescript
// 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";
typescript
// 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

PreferAvoid
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:

typescript
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:

typescript
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

plaintext
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)
SuiteWhen to runTarget
smokeEvery PRAlpha preview
auth, dashboardEvery PRAlpha preview
checkoutBeta merge + nightlyBeta

Tag tests to control CI scope:

typescript
test("full checkout flow @beta @mutating", async ({ page }) => {
  // ...
});
bash
# CI: smoke + auth only (PR)
pnpm exec playwright test --grep-invert "@beta"
 
# CI: full suite (Beta)
pnpm exec playwright test

Debugging failed tests

bash
# 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.zip

In CI, traces and screenshots attach to the GitHub Actions summary automatically.

Official documentation