End-to-End Testing Skill
This skill helps you write and run comprehensive end-to-end tests using Playwright.
When to Use This Skill
-
Testing complete user flows
-
Verifying page interactions and navigation
-
Testing form submissions
-
Checking API integrations from the UI
-
Visual regression testing
-
Cross-browser compatibility testing
-
Mobile responsiveness testing
-
Before production deployments
Playwright Overview
Playwright is a modern E2E testing framework that provides:
-
Cross-browser: Chromium, Firefox, WebKit
-
Auto-waiting: Intelligent element waiting
-
Network interception: Mock API responses
-
Screenshots & videos: Visual debugging
-
Parallel execution: Fast test runs
-
TypeScript support: Type-safe tests
Project Configuration
Installation
Install Playwright (if not already installed)
pnpm add -D -w @playwright/test
Install browsers
pnpm exec playwright install
Configuration File
// playwright.config.ts import { defineConfig, devices } from "@playwright/test";
export default defineConfig({ testDir: "./apps/web/tests/e2e", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [ ["html"], ["junit", { outputFile: "test-results/junit.xml" }], ], use: { baseURL: process.env.BASE_URL || "http://localhost:3001", 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 Chrome", use: { ...devices["Pixel 5"] }, }, { name: "Mobile Safari", use: { ...devices["iPhone 12"] }, }, ], webServer: { command: "pnpm -F @sgcarstrends/web dev", url: "http://localhost:3001", reuseExistingServer: !process.env.CI, }, });
Test Structure
File Organization
apps/web/ ├── tests/ │ └── e2e/ │ ├── home.spec.ts # Homepage tests │ ├── cars/ │ │ ├── makes.spec.ts # Car makes listing │ │ └── models.spec.ts # Car models listing │ ├── coe/ │ │ └── bidding.spec.ts # COE bidding results │ ├── blog/ │ │ ├── list.spec.ts # Blog listing │ │ └── post.spec.ts # Blog post detail │ └── fixtures/ │ ├── mock-data.ts # Test data │ └── page-objects.ts # Page object models
Basic Test Example
// apps/web/tests/e2e/home.spec.ts import { test, expect } from "@playwright/test";
test.describe("Homepage", () => { test("should load successfully", async ({ page }) => { await page.goto("/");
// Check page title
await expect(page).toHaveTitle(/SG Cars Trends/);
// Check main heading
const heading = page.getByRole("heading", { name: /SG Cars Trends/ });
await expect(heading).toBeVisible();
});
test("should display navigation menu", async ({ page }) => { await page.goto("/");
// Check nav links
await expect(page.getByRole("link", { name: "Cars" })).toBeVisible();
await expect(page.getByRole("link", { name: "COE" })).toBeVisible();
await expect(page.getByRole("link", { name: "Blog" })).toBeVisible();
});
test("should navigate to cars page", async ({ page }) => { await page.goto("/");
// Click cars link
await page.getByRole("link", { name: "Cars" }).click();
// Verify URL
await expect(page).toHaveURL(/\/cars/);
// Verify page content
await expect(page.getByRole("heading", { name: /Cars/ })).toBeVisible();
}); });
Page Object Pattern
Create Page Objects
// apps/web/tests/e2e/fixtures/page-objects.ts import { Page, Locator } from "@playwright/test";
export class HomePage { readonly page: Page; readonly heading: Locator; readonly carsLink: Locator; readonly coeLink: Locator; readonly blogLink: Locator;
constructor(page: Page) { this.page = page; this.heading = page.getByRole("heading", { name: /SG Cars Trends/ }); this.carsLink = page.getByRole("link", { name: "Cars" }); this.coeLink = page.getByRole("link", { name: "COE" }); this.blogLink = page.getByRole("link", { name: "Blog" }); }
async goto() { await this.page.goto("/"); }
async navigateToCars() { await this.carsLink.click(); }
async navigateToCOE() { await this.coeLink.click(); }
async navigateToBlog() { await this.blogLink.click(); } }
export class CarsPage { readonly page: Page; readonly heading: Locator; readonly makeSelect: Locator; readonly modelSelect: Locator; readonly resultsTable: Locator;
constructor(page: Page) { this.page = page; this.heading = page.getByRole("heading", { name: /Cars/ }); this.makeSelect = page.getByLabel("Make"); this.modelSelect = page.getByLabel("Model"); this.resultsTable = page.getByRole("table"); }
async goto() { await this.page.goto("/cars"); }
async selectMake(make: string) { await this.makeSelect.click(); await this.page.getByRole("option", { name: make }).click(); }
async selectModel(model: string) { await this.modelSelect.click(); await this.page.getByRole("option", { name: model }).click(); }
async getResultsCount(): Promise<number> { const rows = await this.resultsTable.locator("tbody tr").count(); return rows; } }
Use Page Objects
// apps/web/tests/e2e/cars/makes.spec.ts import { test, expect } from "@playwright/test"; import { HomePage, CarsPage } from "../fixtures/page-objects";
test.describe("Cars Page", () => { test("should filter by make", async ({ page }) => { const homePage = new HomePage(page); const carsPage = new CarsPage(page);
// Navigate to cars page
await homePage.goto();
await homePage.navigateToCars();
// Select Toyota
await carsPage.selectMake("Toyota");
// Wait for results
await expect(carsPage.resultsTable).toBeVisible();
// Verify results contain Toyota
const firstRow = page.locator("tbody tr").first();
await expect(firstRow).toContainText("Toyota");
});
test("should filter by make and model", async ({ page }) => { const carsPage = new CarsPage(page);
await carsPage.goto();
await carsPage.selectMake("Toyota");
await carsPage.selectModel("Corolla");
// Verify results
const count = await carsPage.getResultsCount();
expect(count).toBeGreaterThan(0);
}); });
API Mocking
Mock API Responses
// apps/web/tests/e2e/cars/mocked.spec.ts import { test, expect } from "@playwright/test";
test.describe("Cars Page with Mocked API", () => { test("should display mocked car data", async ({ page }) => { // Mock API response await page.route("**/api/v1/cars/makes", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([ { make: "Toyota", count: 1000 }, { make: "Honda", count: 800 }, { make: "BMW", count: 600 }, ]), }); });
await page.goto("/cars");
// Verify mocked data is displayed
await expect(page.getByText("Toyota")).toBeVisible();
await expect(page.getByText("1000")).toBeVisible();
});
test("should handle API errors gracefully", async ({ page }) => { // Mock API error await page.route("**/api/v1/cars/makes", async (route) => { await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "Internal server error" }), }); });
await page.goto("/cars");
// Verify error message is displayed
await expect(page.getByText(/error|failed/i)).toBeVisible();
}); });
Form Testing
Test Form Submissions
// apps/web/tests/e2e/blog/comment.spec.ts import { test, expect } from "@playwright/test";
test.describe("Blog Comment Form", () => { test("should submit comment successfully", async ({ page }) => { await page.goto("/blog/test-post");
// Fill form
await page.getByLabel("Name").fill("John Doe");
await page.getByLabel("Email").fill("john@example.com");
await page.getByLabel("Comment").fill("Great article!");
// Submit
await page.getByRole("button", { name: "Submit" }).click();
// Verify success message
await expect(page.getByText(/comment submitted/i)).toBeVisible();
});
test("should validate required fields", async ({ page }) => { await page.goto("/blog/test-post");
// Submit empty form
await page.getByRole("button", { name: "Submit" }).click();
// Verify validation errors
await expect(page.getByText(/name is required/i)).toBeVisible();
await expect(page.getByText(/email is required/i)).toBeVisible();
});
test("should validate email format", async ({ page }) => { await page.goto("/blog/test-post");
await page.getByLabel("Email").fill("invalid-email");
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText(/invalid email/i)).toBeVisible();
}); });
Visual Testing
Screenshot Comparison
// apps/web/tests/e2e/visual/homepage.spec.ts import { test, expect } from "@playwright/test";
test.describe("Visual Regression", () => { test("homepage should match snapshot", async ({ page }) => { await page.goto("/");
// Wait for page to be fully loaded
await page.waitForLoadState("networkidle");
// Take screenshot and compare
await expect(page).toHaveScreenshot("homepage.png", {
fullPage: true,
maxDiffPixels: 100,
});
});
test("cars page should match snapshot", async ({ page }) => { await page.goto("/cars"); await page.waitForLoadState("networkidle");
await expect(page).toHaveScreenshot("cars-page.png", {
fullPage: true,
});
});
test("mobile homepage should match snapshot", async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); await page.goto("/"); await page.waitForLoadState("networkidle");
await expect(page).toHaveScreenshot("homepage-mobile.png", {
fullPage: true,
});
}); });
Accessibility Testing
Test Accessibility
// apps/web/tests/e2e/a11y/homepage.spec.ts import { test, expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright";
test.describe("Accessibility", () => { test("homepage should not have accessibility violations", async ({ page }) => { await page.goto("/");
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test("should have proper heading hierarchy", async ({ page }) => { await page.goto("/");
// Check h1 exists and is unique
const h1Count = await page.locator("h1").count();
expect(h1Count).toBe(1);
// Check heading order
const headings = await page.locator("h1, h2, h3, h4, h5, h6").all();
const headingLevels = await Promise.all(
headings.map((h) => h.evaluate((el) => el.tagName))
);
// H1 should come first
expect(headingLevels[0]).toBe("H1");
});
test("should have alt text on images", async ({ page }) => { await page.goto("/");
const images = await page.locator("img").all();
for (const img of images) {
const alt = await img.getAttribute("alt");
expect(alt).toBeTruthy();
}
}); });
Running Tests
Common Commands
Run all tests
pnpm exec playwright test
Run specific test file
pnpm exec playwright test home.spec.ts
Run tests in headed mode
pnpm exec playwright test --headed
Run tests in specific browser
pnpm exec playwright test --project=chromium pnpm exec playwright test --project=firefox
Run tests in debug mode
pnpm exec playwright test --debug
Run tests with UI
pnpm exec playwright test --ui
Generate test report
pnpm exec playwright show-report
Update screenshots
pnpm exec playwright test --update-snapshots
Package.json Scripts
{ "scripts": { "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:debug": "playwright test --debug", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report" } }
CI Configuration
GitHub Actions
.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@v2 - uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm"
- run: pnpm install
- run: pnpm exec playwright install --with-deps
- run: pnpm test:e2e
env:
BASE_URL: http://localhost:3001
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Best Practices
- Use Locators Wisely
// ❌ Fragile CSS selectors await page.locator(".btn-primary").click();
// ✅ Semantic selectors await page.getByRole("button", { name: "Submit" }).click();
// ✅ Text content await page.getByText("Welcome").click();
// ✅ Label await page.getByLabel("Email").fill("test@example.com");
- Auto-waiting
// ❌ Manual waiting await page.waitForTimeout(1000); await page.click("button");
// ✅ Auto-waiting await page.getByRole("button").click();
// ✅ Wait for specific state await page.getByRole("button").waitFor({ state: "visible" });
- Isolate Tests
// ❌ Tests depend on each other test("create user", async ({ page }) => { // Creates user });
test("login user", async ({ page }) => { // Assumes user from previous test exists });
// ✅ Independent tests test("create user", async ({ page }) => { // Creates user and cleans up });
test("login user", async ({ page }) => { // Creates its own user, logs in, cleans up });
- Use Fixtures
// apps/web/tests/e2e/fixtures/test.ts import { test as base } from "@playwright/test"; import { HomePage, CarsPage } from "./page-objects";
type Fixtures = { homePage: HomePage; carsPage: CarsPage; };
export const test = base.extend<Fixtures>({ homePage: async ({ page }, use) => { await use(new HomePage(page)); }, carsPage: async ({ page }, use) => { await use(new CarsPage(page)); }, });
export { expect } from "@playwright/test";
// Use in tests import { test, expect } from "./fixtures/test";
test("test with fixtures", async ({ homePage, carsPage }) => { await homePage.goto(); await homePage.navigateToCars(); // ... });
Debugging
Debug Mode
Run with debugger
pnpm exec playwright test --debug
Debug specific test
pnpm exec playwright test home.spec.ts --debug
Inspector
// Add breakpoint await page.pause();
// Log to console console.log(await page.title());
// Take screenshot await page.screenshot({ path: "debug.png" });
Trace Viewer
Generate trace
pnpm exec playwright test --trace on
View trace
pnpm exec playwright show-trace trace.zip
Troubleshooting
Tests Timing Out
// Increase timeout test("slow test", async ({ page }) => { test.setTimeout(60000); // 60 seconds
await page.goto("/slow-page"); });
// Or in config export default defineConfig({ timeout: 30000, // 30 seconds });
Flaky Tests
// Use waitForLoadState await page.goto("/"); await page.waitForLoadState("networkidle");
// Wait for specific elements await page.getByRole("button").waitFor({ state: "visible" });
// Retry assertions await expect(page.getByText("Welcome")).toBeVisible({ timeout: 10000 });
Selector Not Found
// Check element exists const button = page.getByRole("button", { name: "Submit" }); console.log(await button.count()); // 0 if not found
// Use has await expect(page.getByRole("button")).toHaveCount(1);
// Debug selectors await page.pause(); // Open inspector
References
-
Playwright Documentation: https://playwright.dev
-
Best Practices: https://playwright.dev/docs/best-practices
-
Accessibility Testing: https://playwright.dev/docs/accessibility-testing
-
Related files:
-
playwright.config.ts
-
Playwright configuration
-
Root CLAUDE.md - Testing guidelines
Best Practices Summary
-
Use Semantic Selectors: Prefer role, text, label over CSS selectors
-
Isolate Tests: Each test should be independent
-
Auto-waiting: Let Playwright wait for elements
-
Page Objects: Encapsulate page logic
-
Mock APIs: Use route mocking for predictable tests
-
Visual Testing: Compare screenshots for UI changes
-
Accessibility: Test with axe-core
-
CI Integration: Run tests in continuous integration