's interaction lifecycle. The implementation follows three architectural principles: context isolation, explicit step boundaries, and draft-to-production refinement.
Step 1: Treat page as an Interaction Context, Not a DOM Wrapper
The page object represents a single browser tab with its own JavaScript execution context, network stack, and rendering pipeline. Every method called on page is automatically synchronized with the browser's event loop. This means page.goto(), page.click(), and page.fill() do not execute immediately; they wait until the target element is actionable (visible, enabled, stable, and receiving events).
Architecture Decision: Always initialize tests with the page fixture provided by @playwright/test. This ensures proper context isolation, automatic cleanup, and consistent state management across parallel workers.
import { test, expect } from '@playwright/test';
test('user creates a new project', async ({ page }) => {
// page is already scoped to a fresh browser context
await page.goto('/dashboard');
// Actionability check runs automatically before interaction
await page.getByRole('button', { name: 'New Project' }).click();
});
Step 2: Enforce Explicit Step Boundaries
Flat test functions obscure failure points. Playwright's test.step API creates logical checkpoints that integrate with reporters, trace viewers, and CI logs. Each step isolates a user action or verification phase, making failures immediately actionable.
Architecture Decision: Structure tests around user intents, not DOM operations. Group related interactions into named steps. This improves report readability and enables step-level retry logic in advanced configurations.
test('user creates a new project', async ({ page }) => {
await test.step('Navigate to project dashboard', async () => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/dashboard$/);
});
await test.step('Open project creation modal', async () => {
await page.getByRole('button', { name: 'New Project' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
});
await test.step('Submit project details', async () => {
await page.getByLabel('Project Name').fill('Q4 Infrastructure');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Project created successfully')).toBeVisible();
});
});
Step 3: Use Codegen as a Discovery Draft, Not Production Code
Playwright's Codegen tool records user interactions and outputs TypeScript. It excels at selector discovery and flow mapping but generates verbose, fragile code by default. Production tests require deliberate refactoring.
Architecture Decision: Run Codegen to capture raw interactions, then manually refactor the output. Replace generated CSS/XPath selectors with ARIA-based locators, remove redundant waits, inject web-first assertions, and rename the test to reflect business intent.
npx playwright codegen https://demo.taskflow.io/dashboard
Generated output typically contains:
- Hardcoded CSS selectors
- Unnecessary
waitForSelector calls
- Missing assertions
- Generic test names like
test('test', ...)
Refactored production code applies role-based locators, explicit step boundaries, and auto-retrying expectations. This transforms a mechanical recording into a maintainable specification.
Step 4: Leverage Web-First Assertions
Traditional assertions (expect(element).toBeVisible()) execute once and fail immediately if the condition isn't met. Playwright's web-first assertions (expect(locator).toBeVisible()) automatically retry until the condition passes or the timeout expires. This eliminates manual polling and race condition handling.
Architecture Decision: Always use locator-based assertions instead of element-based checks. The framework handles polling, reducing boilerplate and improving reliability.
// β Fragile: executes once, fails on timing mismatch
const status = page.locator('.status-badge');
expect(await status.textContent()).toBe('Active');
// β
Resilient: auto-retries until condition matches
await expect(page.getByRole('status')).toHaveText('Active');
Pitfall Guide
1. The Monolithic Test Trap
Explanation: Combining navigation, multiple interactions, and several assertions into a single test function. When it fails, developers cannot determine which phase broke.
Fix: Split tests into single-responsibility scenarios. Use test.step to create logical boundaries. Each test should verify one user outcome.
2. Selector Fragility
Explanation: Relying on CSS classes, IDs, or XPath expressions that change during UI refactors. These selectors break frequently and require constant maintenance.
Fix: Prioritize ARIA roles and accessible labels. Use getByRole(), getByLabel(), and getByPlaceholder() as primary locators. Fall back to getByText() only when semantic markup is unavailable.
3. Codegen Blind Trust
Explanation: Copying recorded output directly into test suites without refactoring. Generated code includes implementation details, redundant waits, and missing assertions.
Fix: Treat Codegen as a prototype. Record the flow, then manually rewrite using role-based locators, explicit steps, and web-first assertions. Add business-meaningful test names.
4. Assertion Deficiency
Explanation: Verifying only the final state or skipping assertions entirely. Tests pass even when intermediate steps fail silently, creating false confidence.
Fix: Assert at every critical boundary. Verify URL changes, modal visibility, network responses, and UI state transitions. Use expect after each major interaction.
5. Flakiness Misattribution
Explanation: Blaming Playwright for intermittent failures when the root cause is poor test design, missing actionability checks, or unhandled async operations.
Fix: Enable trace collection (trace: 'on-first-retry'). Inspect network logs, DOM snapshots, and step boundaries. Fix timing issues with explicit waits or auto-retrying assertions instead of adding arbitrary sleep() calls.
6. Ignoring Actionability States
Explanation: Assuming elements are ready for interaction immediately after navigation. Modern SPAs render progressively; elements may exist in the DOM but remain disabled or obscured.
Fix: Trust Playwright's built-in actionability checks. Avoid manual waitForSelector unless intercepting specific network events. Use toBeVisible(), toBeEnabled(), and toBeEditable() to validate readiness.
7. Cross-Test State Pollution
Explanation: Tests sharing browser context, cookies, or local storage across parallel workers. One test's side effects corrupt another's initial state.
Fix: Always use the page fixture provided by @playwright/test. It creates isolated browser contexts per test. Avoid global state mutations. Reset data via API calls in beforeEach hooks when necessary.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Discovering selectors for a new UI component | Codegen + manual refactor | Fast discovery, ensures semantic accuracy | Low (one-time setup) |
| Verifying complex form validation | Web-first assertions + step boundaries | Auto-retries handle async validation states | Medium (improves reliability) |
| Testing authenticated flows | API-driven setup + isolated page context | Avoids UI login overhead, ensures clean state | High (reduces CI time) |
| Handling dynamic content (infinite scroll, lazy load) | waitForSelector + intersection observer checks | Prevents premature interaction attempts | Medium (requires careful timing) |
| Parallel CI execution | Built-in workers config + context isolation | Maximizes throughput without state leakage | High (scales linearly) |
Configuration Template
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [
['html', { open: 'never' }],
['list'],
['junit', { outputFile: 'results.xml' }]
],
use: {
baseURL: 'https://demo.taskflow.io',
trace: 'on-first-retry',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
actionTimeout: 10000,
navigationTimeout: 15000
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
}
]
});
Quick Start Guide
- Initialize the project: Run
npm init playwright@latest and select TypeScript, GitHub Actions, and Playwright Test.
- Configure base URL: Set
baseURL in playwright.config.ts to avoid hardcoded URLs in tests.
- Write your first structured test: Create
tests/auth.spec.ts using test.step, role-based locators, and web-first assertions.
- Run with trace collection: Execute
npx playwright test --trace on to capture execution snapshots for debugging.
- Integrate with CI: Commit the generated GitHub Actions workflow. Playwright handles browser installation and parallel execution automatically.