e2e-testing

Write and run end-to-end tests with Playwright for user flows, page interactions, and visual regression. Use when testing user journeys, ensuring UI functionality works correctly.

allowed_tools: Read, Edit, Write, Bash, Grep, Glob

$ インストール

git clone https://github.com/sgcarstrends/sgcarstrends /tmp/sgcarstrends && cp -r /tmp/sgcarstrends/.claude/skills/e2e-testing ~/.claude/skills/sgcarstrends

// tip: Run this command in your terminal to install the skill


name: e2e-testing description: Write and run end-to-end tests with Playwright for user flows, page interactions, and visual regression. Use when testing user journeys, ensuring UI functionality works correctly. allowed-tools: Read, Edit, Write, Bash, Grep, Glob

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

1. 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");

2. 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" });

3. 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
});

4. 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

Best Practices Summary

  1. Use Semantic Selectors: Prefer role, text, label over CSS selectors
  2. Isolate Tests: Each test should be independent
  3. Auto-waiting: Let Playwright wait for elements
  4. Page Objects: Encapsulate page logic
  5. Mock APIs: Use route mocking for predictable tests
  6. Visual Testing: Compare screenshots for UI changes
  7. Accessibility: Test with axe-core
  8. CI Integration: Run tests in continuous integration