playwright
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
More from grahamcrackers/skills
bulletproof-react-patterns
Bulletproof React architecture patterns for scalable, maintainable applications. Covers feature-based project structure, component patterns, state management boundaries, API layer design, error handling, security, and testing strategies. Use when structuring a React project, designing application architecture, organizing features, or when the user asks about React project structure or scalable patterns.
45react-aria-components
React Aria Components patterns for building accessible, unstyled UI with composition-based architecture. Covers component structure, styling with Tailwind and CSS, render props, collections, forms, selections, overlays, and drag-and-drop. Use when building accessible components, using react-aria-components, creating design systems, or when the user asks about React Aria, accessible UI primitives, or headless component libraries.
17clean-code-principles
Clean code principles for readable, maintainable TypeScript and React codebases. Covers naming, functions, abstraction, composition, error handling, comments, and code smells. Use when writing new code, refactoring, reviewing code quality, or when the user asks about clean code, readability, or maintainability.
10typescript-best-practices
Core TypeScript conventions for type safety, inference, and clean code. Use when writing TypeScript, reviewing TypeScript code, creating interfaces/types, or when the user asks about TypeScript patterns, conventions, or best practices.
9tanstack-query
TanStack Query v5 patterns for server state management, caching, mutations, optimistic updates, and query organization. Use when working with TanStack Query, React Query, server state, data fetching hooks, or when the user asks about caching strategies, query invalidation, or mutation patterns.
8zustand
Zustand state management patterns for React including store design, selectors, slices, middleware (immer, persist, devtools), and async actions. Use when managing client-side state, creating stores, working with Zustand, or when the user asks about global state management, store patterns, or state persistence.
7