Playwright Test Automation Best Practices for EventHub
Overview
This document defines the testing standards, patterns, and best practices for writing Playwright E2E tests in the EventHub project. All test automation agents and code reviewers MUST follow these guidelines.
1. Project Test Setup
Config Reference
- Test directory:
./tests - Base URL:
http://localhost:3000 - Timeout: 30s per test, 5s per assertion
- Browser: Chromium only (Desktop Chrome)
- Parallel execution: Disabled (
fullyParallel: false) - Reporter: HTML
- Screenshots: Only on failure
- Video: Retain on failure
File Naming Convention
- Test files:
tests/<feature-name>.spec.js - Use descriptive kebab-case names:
booking-flow.spec.js,cross-user-booking.spec.js - Group related tests in the same file using
test.describe()
2. Locator Strategy (Priority Order)
Always choose locators in this priority order for reliability and readability:
Priority 1: data-testid (Most Preferred)
page.getByTestId("event-card");
page.getByTestId("book-now-btn");
Use for: Elements that need stable test hooks. Ask developers to add data-testid attributes.
Priority 2: Accessibility Roles
page.getByRole("link", { name: "Browse Events ->" });
page.getByRole("button", { name: "Submit" });
Use for: Links, buttons, headings — anything with semantic HTML roles.
Priority 3: Labels and Placeholders
page.getByLabel("Full Name");
page.getByLabel("Password");
page.getByPlaceholder("you@email.com");
page.getByPlaceholder("+91 98765 43210");
Use for: Form inputs that have associated labels or placeholder text.
Priority 4: Element IDs
page.locator("#login-btn");
page.locator("#event-title-input");
page.locator("#customer-email");
page.locator("#check-refund-btn");
Use for: Elements with explicit, stable IDs. Prefer getByTestId over raw IDs.
Priority 5: CSS Classes (Last Resort)
page.locator(".confirm-booking-btn");
page.locator(".booking-ref");
Use for: Only when no better locator exists. Classes can change with styling.
NEVER Use
- XPath selectors (fragile, hard to read)
- Complex CSS chains (
.parent > .child:nth-child(3)) - Index-based selectors without filtering
3. Filtering and Scoping Patterns
Filter cards by content
const eventCards = page.getByTestId("event-card");
const targetCard = eventCards.filter({ hasText: eventTitle }).first();
Filter by nested element
const matchingCard = bookingCards.filter({
has: page.locator(".booking-ref", { hasText: bookingRef }),
});
Scope actions to a parent
await targetCard.getByTestId("book-now-btn").click();
4. Assertion Patterns
Visibility Checks
await expect(page.getByText("Event created!")).toBeVisible();
await expect(banner).not.toBeVisible();
URL Assertions
await expect(page).toHaveURL(`${BASE_URL}/bookings`);
await expect(page).toHaveURL(/\/events\/\d+/); // Regex for dynamic IDs
Content Assertions
await expect(result).toContainText("Eligible for refund");
await expect(matchingCard).toContainText(eventTitle);
Numeric/Value Assertions
expect(await eventCards.count()).toBe(6);
expect(seatsAfterBooking).toBe(seatsBeforeBooking - 1);
Custom Timeout for Slow Operations
await expect(targetCard).toBeVisible({ timeout: 5000 });
await expect(page.locator("#refund-spinner")).not.toBeVisible({
timeout: 6000,
});
5. Test Structure Patterns
Standard Test Structure
import { test, expect } from "@playwright/test";
const BASE_URL = "http://localhost:3000";
// Credentials as constants at top
const USER_EMAIL = "admin@test.com";
const USER_PASSWORD = "Magiclife1!";
// Reusable helpers
async function login(page) {
await page.goto(`${BASE_URL}/login`);
await page.getByPlaceholder("you@email.com").fill(USER_EMAIL);
await page.getByLabel("Password").fill(USER_PASSWORD);
await page.locator("#login-btn").click();
await expect(
page.getByRole("link", { name: "Browse Events ->" }),
).toBeVisible();
}
test("descriptive test name that explains what is validated", async ({
page,
}) => {
// Step 1: Setup (login, navigate)
await login(page);
// Step 2: Action (interact with UI)
// ...
// Step 3: Assert (verify outcomes)
// ...
});
Multi-Step Test with Comments
Use // -- Step N: Description -- comment blocks to separate logical steps:
test("create event, book it, verify seats", async ({ page }) => {
// -- Step 1: Log in --
await login(page);
// -- Step 2: Create event --
// ... actions ...
// -- Step 3: Book the event --
// ... actions ...
// -- Step 4: Verify seat reduction --
// ... assertions ...
});
Page Object Model (POM)
For larger test suites, extract page interactions into reusable page classes:
// pages/LoginPage.js
class LoginPage {
constructor(page) {
this.page = page;
this.emailInput = page.getByPlaceholder("you@email.com");
this.passwordInput = page.getByLabel("Password");
this.loginBtn = page.locator("#login-btn");
}
async goto() {
await this.page.goto(`${BASE_URL}/login`);
}
async login(email, password) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginBtn.click();
}
}
// In test file
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(USER_EMAIL, USER_PASSWORD);
POM Rules:
- One page class per page/major component
- Store locators as class properties in the constructor
- Methods represent user actions, not low-level steps
- Keep assertions in test files, not in page objects
- Page files live in
tests/pages/directory
6. API Mocking (Route Interception)
When to Mock
- Testing UI behavior for specific data states (e.g., empty list, exact count)
- Testing conditional UI elements (banners, feature flags)
- Isolating frontend logic from backend state
Pattern
await page.route("**/api/events**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mockedResponse),
});
});
Mock Data
Define mock responses as constants at the top of the file:
const SIX_EVENTS_RESPONSE = {
data: [...],
pagination: { page: 1, totalPages: 1, total: 6, limit: 12 },
};
### Test Users
| User | Email | Password | Purpose |
|---------------|----------------------------|-------------|------------------|
| Gmail User | rahulshetty1@gmail.com | Magiclife1! | Primary tester |
| Yahoo User | rahulshetty1@yahoo.com | Magiclife1! | Cross-user tests |
---
## 8. Dynamic Data Handling
### Unique Test Data
Generate unique data to avoid test pollution:
```javascript
const eventTitle = `Test Event ${Date.now()}`;
9. Wait Strategies
DO: Use auto-waiting with expect
await expect(page.getByText("Event created!")).toBeVisible(); // Auto-waits
DO: Use waitUntil for navigation
await page.goto(`${BASE_URL}/bookings/${id}`, { waitUntil: "networkidle" });
DON'T: Use arbitrary sleeps
// BAD - Never do this
await page.waitForTimeout(2000);
Exception: Testing timed UI (spinners)
// OK when testing that a spinner shows for a specific duration
await expect(page.locator("#refund-spinner")).toBeVisible();
await expect(page.locator("#refund-spinner")).not.toBeVisible({
timeout: 6000,
});
10. Debugging Tips
Console Logging in Tests
console.log(`Created event: "${eventTitle}"`);
console.log(`Booking confirmed. Ref: ${bookingRef}`);
console.log(`Seats before: ${before}, after: ${after}`);
Run Single Test
npx playwright test tests/booking-flow.spec.js --reporter=line
Run with UI Mode
npx playwright test --ui
View HTML Report
npx playwright show-report
11. Anti-Patterns to Avoid
| Anti-Pattern | Why It's Bad | Do Instead |
|---|---|---|
page.waitForTimeout(N) |
Flaky, wastes time | Use expect().toBeVisible() |
page.locator('div > span:nth-child(2)') |
Fragile CSS path | Use data-testid |
| Hardcoded booking/event IDs | Tests break when DB changes | Generate data dynamically |
test.only() left in code |
Skips other tests in CI | Remove before commit |
| No assertions after action | Test passes but proves nothing | Always assert outcomes |
| Shared state between tests | Order-dependent failures | Each test self-contained |
| Testing implementation details | Breaks on refactor | Test user-visible behavior |