Chapter 34: End-to-End Testing with Playwright
E2E tests verify complete user flows in a real browser. They catch integration issues that unit and component tests miss — broken API contracts, routing errors, rendering bugs, and authentication flows.
Configuration
// playwright.config.ts
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", { open: "never" }],
["list"],
],
use: {
baseURL: "http://localhost:5173",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
webServer: {
command: "pnpm dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
});
Page Object Model
Encapsulate page interactions in reusable classes:
// e2e/pages/login-page.ts
import { type Page, type Locator, expect } 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 this.page.goto("/login");
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
async expectRedirectToDashboard() {
await expect(this.page).toHaveURL(/\/dashboard/);
}
}
// e2e/pages/projects-page.ts
import { type Page, type Locator, expect } from "@playwright/test";
export class ProjectsPage {
readonly page: Page;
readonly heading: Locator;
readonly createButton: Locator;
readonly searchInput: Locator;
readonly projectCards: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole("heading", { name: "Projects" });
this.createButton = page.getByRole("button", { name: "Create Project" });
this.searchInput = page.getByPlaceholder("Search projects");
this.projectCards = page.getByTestId("project-card");
}
async goto() {
await this.page.goto("/projects");
}
async createProject(name: string, description?: string) {
await this.createButton.click();
await this.page.getByLabel("Project Name").fill(name);
if (description) {
await this.page.getByLabel("Description").fill(description);
}
await this.page.getByRole("button", { name: "Create" }).click();
}
async search(query: string) {
await this.searchInput.fill(query);
}
async expectProjectCount(count: number) {
await expect(this.projectCards).toHaveCount(count);
}
async expectProjectVisible(name: string) {
await expect(this.page.getByText(name)).toBeVisible();
}
}
Writing E2E Tests
// e2e/tests/auth.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/login-page";
test.describe("Authentication", () => {
test("user can log in with valid credentials", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("[email protected]", "demo-password");
await loginPage.expectRedirectToDashboard();
});
test("shows error for invalid credentials", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("[email protected]", "wrong-password");
await loginPage.expectError("Invalid credentials");
});
test("redirects to login when not authenticated", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
});
});
// e2e/tests/projects.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/login-page";
import { ProjectsPage } from "../pages/projects-page";
test.describe("Projects", () => {
test.beforeEach(async ({ page }) => {
// Login before each test
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("[email protected]", "demo-password");
await loginPage.expectRedirectToDashboard();
});
test("can create a new project", async ({ page }) => {
const projectsPage = new ProjectsPage(page);
await projectsPage.goto();
await projectsPage.createProject("E2E Test Project", "Created by Playwright");
await projectsPage.expectProjectVisible("E2E Test Project");
});
test("can search projects", async ({ page }) => {
const projectsPage = new ProjectsPage(page);
await projectsPage.goto();
await projectsPage.search("E2E Test");
// URL should update with search param
await expect(page).toHaveURL(/search=E2E/);
});
});
CI Integration
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install
- run: pnpm playwright install --with-deps chromium
- run: pnpm test:e2e
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
Debugging Failed Tests
Playwright provides excellent debugging tools:
# Run with UI mode (visual debugger)
pnpm playwright test --ui
# Run with headed browser (see the browser)
pnpm playwright test --headed
# Generate and view trace
pnpm playwright test --trace on
pnpm playwright show-trace trace.zip
# Show last HTML report
pnpm playwright show-report
Summary
- ✅ Playwright for cross-browser E2E testing
- ✅ Page Object Model for maintainable, reusable test code
- ✅ Auto-waiting eliminates flaky tests from timing issues
- ✅ Trace recording captures every step for debugging failures
- ✅ CI integration with artifact upload for failure analysis
- ✅ Auto-start dev server via
webServerconfig