Playwright E2E Testing
Overview
This project uses Playwright for E2E testing with a focus on user-centric testing that avoids implementation details.
Test Configuration
-
Unit tests: Vitest for client components and synchronous server components
-
E2E tests: Playwright with automatic dev server startup
-
Location: e2e/ directory
-
HTML reporter: Enabled with trace collection on retry
-
Duration: Full E2E suite takes ~15 minutes
Core Philosophy: Test Like a User
Users interact with what they see, not technical implementation details.
✅ Test User-Visible Behavior
-
Wait for text to appear/disappear
-
Check for visible elements
-
Interact with labeled buttons and links
-
Verify content changes
❌ Avoid Testing Implementation Details
-
Don't assert on URLs or pathnames
-
Don't check cookies or localStorage
-
Don't wait for networkidle or technical states
-
Don't verify internal state or data structures
Best Practices
- Use Semantic Locators
Always prefer role-based locators that match how users perceive the page.
✅ Good:
page.getByRole("button", { name: "Submit" }); page.getByRole("heading", { name: "Welcome" }); page.getByRole("link", { name: "Learn More" }); page.getByLabel("Email address"); page.getByPlaceholder("Enter your name"); page.getByText("Success!");
❌ Bad:
page.locator('button[aria-label="Submit"]'); // CSS selector page.locator(".submit-btn"); // Class name page.locator("#submit"); // ID page.locator('[data-testid="submit"]'); // Test ID
- Wait for Visible Changes
Wait for actual UI changes users would see, not technical state.
✅ Good:
await page.getByRole("button", { name: "Load More" }).click(); await expect(page.getByRole("heading", { name: "Results" })).toBeVisible();
❌ Bad:
await page.waitForLoadState("networkidle"); await page.waitForTimeout(500); await page.waitForFunction(() => window.location.pathname === "/results");
- Simplify Test Setup
Minimize beforeEach steps and avoid redundant operations.
✅ Good:
test("user can browse products", async ({ page }) => { await page.goto("/"); await expect(page.getByRole("heading", { name: "Products" })).toBeVisible();
// Test continues... });
❌ Bad:
test.beforeEach(async ({ page }) => { await page.goto("/"); await page.waitForLoadState("networkidle"); await page.context().clearCookies(); await page.reload(); await page.waitForLoadState("networkidle"); });
test("user can browse products", async ({ page }) => { // Test continues... });
Complete Example: Before & After
❌ Bad: Testing Implementation Details
test("language switch", async ({ page }) => { await page.goto("/"); await page.waitForLoadState("networkidle"); // Technical state await page.context().clearCookies(); await page.reload(); await page.waitForLoadState("networkidle");
const button = page.locator('button[aria-label="Select a language"]'); // CSS selector await button.click(); await page.waitForTimeout(200); // Arbitrary wait
const option = page.locator('a[aria-label="切换至中文"]'); await option.click(); await page.waitForFunction(() => window.location.pathname.includes("/zh")); // URL check
const cookies = await page.context().cookies(); // Implementation detail expect(cookies.find((c) => c.name === "NEXT_LOCALE")?.value).toBe("zh"); });
✅ Good: Testing User-Visible Behavior
test("language switch", async ({ page }) => { await page.goto("/"); await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
await page.getByRole("button", { name: "Select a language" }).click(); await page.getByRole("link", { name: "切换至中文" }).click();
// Wait for actual content to change await expect(page.getByRole("heading", { name: "欢迎" })).toBeVisible(); });
Common Patterns
Navigation and Verification
await page.goto("/products"); await expect(page.getByRole("heading", { name: "Our Products" })).toBeVisible();
Form Interaction
await page.getByLabel("Email").fill("user@example.com"); await page.getByLabel("Password").fill("secure123"); await page.getByRole("button", { name: "Sign In" }).click(); await expect(page.getByText("Welcome back!")).toBeVisible();
List Interaction
await page.getByRole("button", { name: "Action" }).first().click(); await expect(page.getByText("Action completed")).toBeVisible();
Conditional Elements
if (await page.getByRole("button", { name: "Accept" }).isVisible()) { await page.getByRole("button", { name: "Accept" }).click(); }
Running Tests
pnpm test:e2e # Run all E2E tests (auto-starts dev server) pnpm test:e2e e2e/some-file.spec.ts # Run specific test file pnpm test:e2e --grep "test name" # Run tests matching pattern pnpm test # Run unit tests (Vitest)
Verification Workflow
IMPORTANT: After writing or modifying E2E tests, YOU must run them to verify they pass.
-
Run the specific test file or use --grep to run just the new test
-
If the test fails, fix the issue and re-run
-
Only report completion after the test passes
Do NOT tell the user to run the tests themselves - run them and report the results.
Key Reminders
-
Think like a user - What would the user see and do?
-
Use semantic locators - Roles, labels, text users see
-
Wait for content - Visible elements, not technical state
-
Avoid implementation - No URLs, cookies, localStorage assertions
-
Keep it simple - Minimal setup, clear test flow