Playwright E2E Testing Standards
Purpose
Progressive disclosure of Playwright end-to-end testing standards for agents writing E2E tests.
Authoritative Source: docs/explanation/software-engineering/automation-testing/tools/playwright/README.md
Usage: Auto-loaded for agents when writing Playwright E2E tests. Provides quick reference to test organization, selectors, assertions, page objects, and debugging patterns.
Quick Standards Reference
Test Organization
File Structure: Group tests by feature or page
tests/
├── e2e/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── register.spec.ts
│ ├── payments/
│ │ ├── murabaha.spec.ts
│ │ └── zakat.spec.ts
│ └── navigation.spec.ts
├── page-objects/
│ ├── pages/
│ │ ├── LoginPage.ts
│ │ └── DashboardPage.ts
│ └── components/
│ ├── Header.ts
│ └── Sidebar.ts
└── fixtures/
└── test-data.ts
Naming Conventions:
- Test files:
*.spec.ts(e.g.,login.spec.ts) - Page objects:
PascalCase(e.g.,LoginPage.ts) - Test descriptions: Behavior-focused (e.g., "successful login redirects to dashboard")
Selectors (Accessibility-First)
Priority Order: Role → Label → Text → TestID → CSS
// ✅ PASS: Accessibility-first selectors
page.getByRole("button", { name: "Submit" }); // Priority 1: Role
page.getByLabel("Email"); // Priority 2: Label
page.getByText("Welcome"); // Priority 3: Text
page.getByTestId("submit-button"); // Priority 4: TestID
page.locator("css=.button"); // Priority 5: CSS (last resort)
Avoid:
- Overly specific CSS selectors
- XPath unless necessary
- Element IDs that change frequently
- Position-based selectors
Assertions (Web-First)
Auto-Waiting Assertions: Use web-first assertions with automatic retries
// ✅ PASS: Web-first assertions (auto-wait)
await expect(page).toHaveTitle("Dashboard");
await expect(page.getByRole("heading")).toContainText("Welcome");
await expect(page.getByLabel("Email")).toBeVisible();
await expect(page.getByTestId("status")).toHaveText("Success");
// ❌ FAIL: Generic assertions (no auto-wait)
const text = await page.getByRole("heading").textContent();
expect(text).toBe("Welcome"); // No retry, flaky
Assertion Types:
- Visibility:
toBeVisible(),toBeHidden() - Text:
toHaveText(),toContainText() - Values:
toHaveValue(),toHaveAttribute() - States:
toBeEnabled(),toBeDisabled(),toBeChecked() - URL:
toHaveURL(), URL patterns with regex
Page Object Model
Class-Based Pattern: Encapsulate page locators and actions
// ✅ PASS: Page Object Model
import { Page, Locator } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByRole("textbox", { name: "Email" });
this.passwordInput = page.getByRole("textbox", { name: "Password" });
this.submitButton = page.getByRole("button", { name: "Sign in" });
}
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();
}
}
Usage in Tests:
test("successful login", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("user@example.com", "password123");
await expect(page).toHaveURL("/dashboard");
});
Configuration Standards
playwright.config.ts: Environment-specific settings
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? [["html"], ["junit"]] : "html",
use: {
baseURL: process.env.BASE_URL || "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"] },
},
],
});
Best Practices
Test Isolation: Each test independent
// ✅ PASS: Test isolation with beforeEach
test.describe("User Management", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/users");
await page.getByRole("button", { name: "Add User" }).click();
});
test("creates new user", async ({ page }) => {
// Fresh state from beforeEach
await page.getByLabel("Name").fill("John Doe");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("User created")).toBeVisible();
});
});
API Testing Integration: Combine UI and API
test("user sees their data after login", async ({ page, request }) => {
// API setup
const response = await request.post("/api/users", {
data: { name: "Test User", email: "test@example.com" },
});
const userId = (await response.json()).id;
// UI verification
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("test@example.com", "password");
await expect(page.getByText("Test User")).toBeVisible();
// API cleanup
await request.delete(`/api/users/${userId}`);
});
Anti-Patterns to Avoid
❌ Manual Waits:
// ❌ FAIL: Manual waits
await page.click("button");
await page.waitForTimeout(2000); // Arbitrary wait, flaky
// ✅ PASS: Auto-waiting
await page.click("button");
await expect(page.getByText("Success")).toBeVisible(); // Auto-retries
❌ Overly Specific Selectors:
// ❌ FAIL: Fragile CSS selector
await page.locator("div.container > div:nth-child(2) > button.primary").click();
// ✅ PASS: Semantic selector
await page.getByRole("button", { name: "Submit" }).click();
❌ Test Interdependence:
// ❌ FAIL: Tests depend on execution order
test("1. create user", async ({ page }) => {
// Creates user, stores in global state
});
test("2. edit user", async ({ page }) => {
// Depends on user from test 1
});
// ✅ PASS: Independent tests
test.describe("User Management", () => {
test.beforeEach(async ({ request }) => {
// Each test creates its own user
await request.post("/api/users", { data: testUser });
});
test("creates user", async ({ page }) => {
// Independent
});
test("edits user", async ({ page }) => {
// Independent
});
});
Debugging Tools
Trace Viewer: Post-failure debugging
# Show trace for failed tests
npx playwright show-trace trace.zip
Inspector: Step-through debugging
# Debug specific test
npx playwright test login.spec.ts --debug
Headed Mode: Visual debugging
// playwright.config.ts
use: {
headless: false, // Show browser
slowMo: 500, // Slow down actions
},
OSE Platform Context
Islamic Finance Testing
Zakat Calculator Tests:
test("calculates zakat correctly", async ({ page }) => {
await page.goto("/zakat-calculator");
await page.getByLabel("Wealth Amount").fill("100000");
await page.getByRole("button", { name: "Calculate" }).click();
// Verify 2.5% calculation
await expect(page.getByTestId("zakat-amount")).toHaveText("RM 2,500.00");
});
Murabaha Contract Tests:
test("murabaha contract workflow", async ({ page }) => {
const murabaha = new MurabahaPage(page);
await murabaha.goto();
await murabaha.createContract({
asset: "Vehicle",
cost: 50000,
profitRate: 5,
});
await expect(page.getByText("Contract Created")).toBeVisible();
await expect(page.getByTestId("total-payment")).toContainText("52,500");
});
Related Standards
See Authoritative Documentation:
- Test Organization - Test structure, fixtures, grouping
- Selectors - Accessibility-first selector strategies
- Assertions - Web-first assertions
- Page Objects - Page Object Model patterns
- Configuration - playwright.config.ts setup
- Best Practices - Production testing standards
- Anti-Patterns - Common mistakes
- Idioms - Playwright-specific patterns
- Debugging - Trace viewer, inspector
