playwright
SKILL.md
Playwright Best Practices
Setup
npm init playwright@latest
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "html",
use: {
baseURL: "http://localhost:3000",
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", use: { ...devices["iPhone 14"] } },
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
Locators
Use user-facing locators for resilient tests:
// Preferred (resilient)
page.getByRole("button", { name: "Submit" });
page.getByLabel("Email");
page.getByPlaceholder("Search...");
page.getByText("Welcome back");
page.getByTestId("user-avatar"); // last resort
// Avoid (brittle)
page.locator(".btn-primary");
page.locator("#submit-btn");
page.locator("div > span:nth-child(2)");
Locator Priority
getByRole— buttons, links, headings, inputs (most accessible)getByLabel— form fieldsgetByPlaceholder— inputs without visible labelsgetByText— non-interactive elementsgetByTestId— when no better option exists
Filtering and Chaining
page.getByRole("listitem").filter({ hasText: "Product 1" });
page.getByRole("list").getByRole("listitem").first();
page.getByRole("dialog").getByRole("button", { name: "Confirm" });
Assertions
Playwright auto-waits for assertions to pass:
// Element assertions
await expect(page.getByText("Success")).toBeVisible();
await expect(page.getByRole("button")).toBeEnabled();
await expect(page.getByLabel("Email")).toHaveValue("test@example.com");
await expect(page.getByRole("alert")).toContainText("Saved");
await expect(page.getByRole("listitem")).toHaveCount(5);
// Page assertions
await expect(page).toHaveURL("/dashboard");
await expect(page).toHaveTitle("Dashboard");
Never use manual waitForTimeout — use auto-waiting assertions and locators instead.
Page Object Model
Encapsulate page interactions in classes:
// e2e/pages/login-page.ts
import { type Locator, type Page, expect } from "@playwright/test";
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
this.submitButton = page.getByRole("button", { name: "Sign in" });
this.errorMessage = page.getByRole("alert");
}
async goto() {
await this.page.goto("/login");
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
Fixtures
Extend the base test with reusable setup:
// e2e/fixtures.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;
authenticatedPage: Page;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
authenticatedPage: async ({ page }, use) => {
await page.goto("/login");
await page.getByLabel("Email").fill("admin@test.com");
await page.getByLabel("Password").fill("password");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL("/dashboard");
await use(page);
},
});
export { expect } from "@playwright/test";
Use in tests:
import { test, expect } from "./fixtures";
test("login with valid credentials", async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login("user@test.com", "password123");
await expect(page).toHaveURL("/dashboard");
});
test("dashboard shows user data", async ({ authenticatedPage }) => {
await expect(authenticatedPage.getByText("Welcome")).toBeVisible();
});
Writing Tests
Structure
import { test, expect } from "./fixtures";
test.describe("User Registration", () => {
test("creates account with valid data", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("Name").fill("Alice");
await page.getByLabel("Email").fill("alice@test.com");
await page.getByLabel("Password").fill("securePass123");
await page.getByRole("button", { name: "Create Account" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(page.getByText("Welcome, Alice")).toBeVisible();
});
test("shows error for duplicate email", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("Email").fill("existing@test.com");
await page.getByLabel("Password").fill("password123");
await page.getByRole("button", { name: "Create Account" }).click();
await expect(page.getByRole("alert")).toContainText("already exists");
});
});
Principles
- Each test is independent — no shared state between tests.
- Test user-visible behavior, not implementation.
- Use auto-waiting — no
sleep()orwaitForTimeout(). - One logical flow per test.
API Testing
Test API endpoints directly:
test("API creates a user", async ({ request }) => {
const response = await request.post("/api/users", {
data: { name: "Alice", email: "alice@test.com" },
});
expect(response.ok()).toBeTruthy();
const user = await response.json();
expect(user.name).toBe("Alice");
});
API Setup in Tests
Seed data via API before UI tests:
test.beforeEach(async ({ request }) => {
await request.post("/api/test/reset");
await request.post("/api/users", {
data: { name: "Test User", email: "test@test.com" },
});
});
Authentication State
Save and reuse auth state to avoid logging in every test:
// e2e/auth.setup.ts
import { test as setup } from "@playwright/test";
const authFile = "e2e/.auth/user.json";
setup("authenticate", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("admin@test.com");
await page.getByLabel("Password").fill("password");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL("/dashboard");
await page.context().storageState({ path: authFile });
});
// playwright.config.ts
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
dependencies: ["setup"],
use: { storageState: "e2e/.auth/user.json" },
},
],
Visual Regression
test("homepage matches snapshot", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("homepage.png", {
maxDiffPixelRatio: 0.01,
});
});
test("button variants", async ({ page }) => {
await page.goto("/storybook/button");
await expect(page.getByTestId("button-group")).toHaveScreenshot();
});
Update snapshots: npx playwright test --update-snapshots.
CI Integration
# .github/workflows/e2e.yml
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: "npm" }
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 7
File Structure
e2e/
├── fixtures.ts # custom test fixtures
├── pages/ # page objects
│ ├── login-page.ts
│ └── dashboard-page.ts
├── auth.setup.ts # authentication setup
├── .auth/ # stored auth state (gitignored)
├── tests/
│ ├── auth.spec.ts
│ ├── dashboard.spec.ts
│ └── users.spec.ts
└── playwright.config.ts
Weekly Installs
2
Repository
grahamcrackers/skillsFirst Seen
14 days ago
Security Audits
Installed on
cline2
github-copilot2
codex2
kimi-cli2
gemini-cli2
cursor2