CtrlK
BlogDocsLog inGet started
Tessl Logo

playwright-testing-skill

Set up Playwright 1.49+ with TypeScript for E2E testing using page object model, fixtures, test.describe/test blocks, assertions, selectors, network mocking, CI configuration, and trace viewer.

85

Quality

80%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Optimize this skill with Tessl

npx tessl skill review --optimize ./testing/playwright-testing-skill/SKILL.md
SKILL.md
Quality
Evals
Security

Playwright Testing Skill

Set up Playwright 1.49+ with TypeScript for E2E testing using page object model, fixtures, test.describe/test blocks, assertions, selectors, network mocking, CI configuration, and trace viewer.

Prerequisites

  • Node.js >= 20.x
  • npm or pnpm

Scaffold Command

npm init playwright@latest

# This creates:
#   playwright.config.ts
#   tests/example.spec.ts
#   tests-examples/demo-todo-app.spec.ts

# Or manual install
npm install -D @playwright/test
npx playwright install

Project Structure

e2e/
  fixtures/
    index.ts                      # Custom fixtures (auth, page objects)
  pages/
    login.page.ts                 # Page Object Model
    dashboard.page.ts
    base.page.ts                  # Base page with shared methods
  helpers/
    auth.setup.ts                 # Auth state setup (storageState)
    test-data.ts                  # Test data builders
  specs/
    auth/
      login.spec.ts
      signup.spec.ts
    dashboard/
      dashboard.spec.ts
  global-setup.ts                 # One-time setup before all tests
  global-teardown.ts              # Cleanup after all tests
playwright.config.ts

Key Conventions

  • Use Page Object Model (POM): encapsulate selectors and actions in page classes. Tests should read like user stories.
  • Prefer data-testid attributes for selectors. Fall back to getByRole, getByText, getByLabel for accessible selectors.
  • Use Playwright fixtures to inject page objects and shared state into tests.
  • Use storageState for authenticated tests to avoid logging in before every test.
  • Use test.describe for grouping related tests. Use test.beforeEach / test.afterEach for setup/cleanup.
  • Run tests in parallel by default. Use test.describe.serial only when tests genuinely depend on order.
  • Use expect(locator) auto-waiting assertions. Avoid manual page.waitForTimeout().

Essential Patterns

Playwright Config (playwright.config.ts)

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e/specs",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ["html", { open: "never" }],
    ["list"],
    ...(process.env.CI ? [["github" as const]] : []),
  ],
  use: {
    baseURL: process.env.BASE_URL ?? "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  projects: [
    // Setup project — runs before all test projects
    {
      name: "setup",
      testMatch: /.*\.setup\.ts/,
      testDir: "./e2e/helpers",
    },
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
        storageState: "e2e/.auth/user.json",
      },
      dependencies: ["setup"],
    },
    {
      name: "firefox",
      use: {
        ...devices["Desktop Firefox"],
        storageState: "e2e/.auth/user.json",
      },
      dependencies: ["setup"],
    },
    {
      name: "mobile-chrome",
      use: {
        ...devices["Pixel 7"],
        storageState: "e2e/.auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],
  webServer: {
    command: "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 30000,
  },
});

Auth Setup (e2e/helpers/auth.setup.ts)

import { test as setup, expect } from "@playwright/test";

const authFile = "e2e/.auth/user.json";

setup("authenticate", async ({ page }) => {
  await page.goto("/auth/signin");
  await page.getByLabel("Email").fill("test@example.com");
  await page.getByLabel("Password").fill("password123");
  await page.getByRole("button", { name: "Sign In" }).click();

  // Wait for redirect to dashboard
  await page.waitForURL("/dashboard");
  await expect(page.getByText("Welcome")).toBeVisible();

  // Save signed-in state
  await page.context().storageState({ path: authFile });
});

Base Page Object (e2e/pages/base.page.ts)

import type { Page, Locator } from "@playwright/test";

export class BasePage {
  readonly page: Page;
  readonly nav: Locator;
  readonly userMenu: Locator;

  constructor(page: Page) {
    this.page = page;
    this.nav = page.getByRole("navigation");
    this.userMenu = page.getByTestId("user-menu");
  }

  async goto(path: string) {
    await this.page.goto(path);
  }

  async getToastMessage(): Promise<string> {
    const toast = this.page.getByRole("alert");
    return toast.innerText();
  }

  async logout() {
    await this.userMenu.click();
    await this.page.getByRole("menuitem", { name: "Sign Out" }).click();
  }
}

Login Page Object (e2e/pages/login.page.ts)

import type { Page } from "@playwright/test";
import { BasePage } from "./base.page";

export class LoginPage extends BasePage {
  readonly emailInput;
  readonly passwordInput;
  readonly submitButton;
  readonly errorMessage;

  constructor(page: Page) {
    super(page);
    this.emailInput = page.getByLabel("Email");
    this.passwordInput = page.getByLabel("Password");
    this.submitButton = page.getByRole("button", { name: "Sign In" });
    this.errorMessage = page.getByTestId("login-error");
  }

  async navigate() {
    await this.goto("/auth/signin");
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Dashboard Page Object (e2e/pages/dashboard.page.ts)

import type { Page } from "@playwright/test";
import { BasePage } from "./base.page";

export class DashboardPage extends BasePage {
  readonly heading;
  readonly statsCards;
  readonly recentActivity;

  constructor(page: Page) {
    super(page);
    this.heading = page.getByRole("heading", { name: "Dashboard" });
    this.statsCards = page.getByTestId("stats-card");
    this.recentActivity = page.getByTestId("recent-activity");
  }

  async navigate() {
    await this.goto("/dashboard");
  }

  async getStatValue(statName: string): Promise<string> {
    const card = this.statsCards.filter({ hasText: statName });
    return card.getByTestId("stat-value").innerText();
  }
}

Custom Fixtures (e2e/fixtures/index.ts)

import { test as base } from "@playwright/test";
import { LoginPage } from "../pages/login.page";
import { DashboardPage } from "../pages/dashboard.page";

type Fixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await use(loginPage);
  },
  dashboardPage: async ({ page }, use) => {
    const dashboardPage = new DashboardPage(page);
    await use(dashboardPage);
  },
});

export { expect } from "@playwright/test";

Test Spec — Login (e2e/specs/auth/login.spec.ts)

import { test, expect } from "../../fixtures";

test.describe("Login", () => {
  test.beforeEach(async ({ loginPage }) => {
    await loginPage.navigate();
  });

  test("should login with valid credentials", async ({ loginPage, page }) => {
    await loginPage.login("test@example.com", "password123");

    await expect(page).toHaveURL("/dashboard");
    await expect(page.getByText("Welcome")).toBeVisible();
  });

  test("should show error with invalid credentials", async ({ loginPage }) => {
    await loginPage.login("wrong@example.com", "wrongpassword");

    await expect(loginPage.errorMessage).toBeVisible();
    await expect(loginPage.errorMessage).toHaveText("Invalid credentials");
  });

  test("should require email field", async ({ loginPage }) => {
    await loginPage.passwordInput.fill("password123");
    await loginPage.submitButton.click();

    await expect(loginPage.emailInput).toHaveAttribute("aria-invalid", "true");
  });
});

Test Spec — Dashboard (e2e/specs/dashboard/dashboard.spec.ts)

import { test, expect } from "../../fixtures";

test.describe("Dashboard", () => {
  test.beforeEach(async ({ dashboardPage }) => {
    await dashboardPage.navigate();
  });

  test("should display the dashboard heading", async ({ dashboardPage }) => {
    await expect(dashboardPage.heading).toBeVisible();
  });

  test("should show at least 3 stats cards", async ({ dashboardPage }) => {
    await expect(dashboardPage.statsCards).toHaveCount(3);
  });

  test("should show recent activity", async ({ dashboardPage }) => {
    await expect(dashboardPage.recentActivity).toBeVisible();
  });
});

Network Mocking

test("should handle API error gracefully", async ({ page }) => {
  // Mock the API to return an error
  await page.route("**/api/users", (route) => {
    route.fulfill({
      status: 500,
      contentType: "application/json",
      body: JSON.stringify({ message: "Internal server error" }),
    });
  });

  await page.goto("/users");
  await expect(page.getByText("Something went wrong")).toBeVisible();
});

test("should display mocked data", async ({ page }) => {
  await page.route("**/api/users", (route) => {
    route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify([
        { id: "1", name: "Alice", email: "alice@example.com" },
        { id: "2", name: "Bob", email: "bob@example.com" },
      ]),
    });
  });

  await page.goto("/users");
  await expect(page.getByText("Alice")).toBeVisible();
  await expect(page.getByText("Bob")).toBeVisible();
});

Wait for Network Requests

test("should save form data", async ({ page }) => {
  await page.goto("/settings");
  await page.getByLabel("Name").fill("Updated Name");

  // Wait for the API call to complete after clicking save
  const [response] = await Promise.all([
    page.waitForResponse("**/api/users/*"),
    page.getByRole("button", { name: "Save" }).click(),
  ]);

  expect(response.status()).toBe(200);
  await expect(page.getByText("Settings saved")).toBeVisible();
});

Common Commands

# Run all tests
npx playwright test

# Run specific test file
npx playwright test e2e/specs/auth/login.spec.ts

# Run tests with a specific project (browser)
npx playwright test --project=chromium

# Run tests headed (visible browser)
npx playwright test --headed

# Run in debug mode (step through)
npx playwright test --debug

# Run in UI mode (interactive)
npx playwright test --ui

# Show HTML report
npx playwright show-report

# View trace files
npx playwright show-trace trace.zip

# Update snapshots
npx playwright test --update-snapshots

# Install/update browsers
npx playwright install

# Generate test code (codegen)
npx playwright codegen http://localhost:3000

Integration Notes

  • CI/CD: Pair with github-actions-ci skill. Use the official Playwright GitHub Action container. Run npx playwright install --with-deps in CI.
  • Docker: Playwright provides official Docker images: mcr.microsoft.com/playwright:v1.49.0-jammy.
  • NextAuth / Auth: Use storageState to persist auth state. Run an auth setup project before test projects.
  • Visual Regression: Use expect(page).toHaveScreenshot() for visual comparison testing. Store baseline screenshots in version control.
  • Accessibility: Use @axe-core/playwright for accessibility testing: npm install -D @axe-core/playwright.

Test Database Setup

# docker-compose.test.yml
services:
  test-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    ports:
      - "5433:5432"
    tmpfs:
      - /var/lib/postgresql/data  # RAM-backed for speed

Use DATABASE_URL=postgresql://test:test@localhost:5433/testdb in your test environment. Start with docker compose -f docker-compose.test.yml up -d before running E2E tests. Seed the database in global-setup.ts and tear down in global-teardown.ts.

Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.