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
80%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Optimize this skill with Tessl
npx tessl skill review --optimize ./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.