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.
68
80%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No known issues
Fix and improve this skill with Tessl
tessl review fix ./testing/playwright-testing-skill/SKILL.mdSet 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.
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 installe2e/
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.tsdata-testid attributes for selectors. Fall back to getByRole, getByText, getByLabel for accessible selectors.storageState for authenticated tests to avoid logging in before every test.test.describe for grouping related tests. Use test.beforeEach / test.afterEach for setup/cleanup.test.describe.serial only when tests genuinely depend on order.expect(locator) auto-waiting assertions. Avoid manual page.waitForTimeout().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,
},
});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 });
});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();
}
}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();
}
}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();
}
}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";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");
});
});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();
});
});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();
});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();
});# 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:3000github-actions-ci skill. Use the official Playwright GitHub Action container. Run npx playwright install --with-deps in CI.mcr.microsoft.com/playwright:v1.49.0-jammy.storageState to persist auth state. Run an auth setup project before test projects.expect(page).toHaveScreenshot() for visual comparison testing. Store baseline screenshots in version control.@axe-core/playwright for accessibility testing: npm install -D @axe-core/playwright.# 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 speedUse 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.
181fcbc
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.