E2E Testing
Rules
- Playwright for E2E — cross-browser, auto-wait, built-in test runner, excellent CI support
- Locator priority:
getByRole() > getByLabel() > getByPlaceholder() > getByTestId() > CSS selectors
- Page Object Model: encapsulate page interactions in classes —
LoginPage.login(email, password)
- Test isolation: each test starts with fresh state — use
beforeEach for setup, avoid test interdependence
- Auto-wait: Playwright waits for elements automatically — don't add manual
waitForTimeout() calls
- Assertions: use
expect(locator) web-first assertions — toBeVisible(), toHaveText(), toHaveURL()
- Parallel execution: tests run in parallel by default — ensure no shared mutable state between tests
- CI: run in headless mode, configure retries (
retries: 2 in CI), upload trace on failure
- Visual regression:
expect(page).toHaveScreenshot() — update baselines with --update-snapshots
Patterns
class LoginPage {
constructor(private page: Page) {}
async goto() { await this.page.goto("/login"); }
async login(email: string, password: string) {
await this.page.getByLabel("Email").fill(email);
await this.page.getByLabel("Password").fill(password);
await this.page.getByRole("button", { name: "Sign in" }).click();
await this.page.waitForURL("/dashboard");
}
}
export default defineConfig({
retries: process.env.CI ? 2 : 0,
use: { trace: "on-first-retry", screenshot: "only-on-failure" },
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile", use: { ...devices["Pixel 5"] } },
],
});
Avoid
page.waitForTimeout() — use auto-wait or waitForURL(), waitForResponse()
- CSS selectors for interactive elements — use role-based locators for resilience
- Tests that depend on other tests' state — each test must be independently runnable
- Testing third-party services directly — mock external APIs with
page.route()
- Skipping mobile viewports — test at least one mobile device in your project matrix