Playwright Advanced Usage
Playwright has emerged as a cornerstone of modern end-to-end testing, offering far more than basic click, input, and assertion capabilities. Its architecture is designed to handle complex web applications with reliability, speed, and maintainability at scale. Mastering its advanced features allows teams to build test suites that are not only robust but also a pleasure to maintain over time.
Custom Fixtures and Test Organization
One of the most powerful yet underutilized aspects of Playwright Test is its fixture system. Fixtures allow developers to encapsulate page objects, authentication states, test data, and setup logic into reusable dependencies. This abstraction eliminates boilerplate and ensures that each test receives exactly what it needs, no more, no less.
The test.extend API is the primary mechanism for defining custom fixtures. By creating a type that describes the fixture shape and extending the base test, you can inject these dependencies directly into test functions. This pattern is particularly effective when paired with page object models, as it separates concerns and keeps test bodies declarative.
import { test as base, Page } from '@playwright/test';
class TodoPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/todos');
}
async add(text: string) {
await this.page.getByPlaceholder('What needs to be done?').fill(text);
await this.page.getByRole('button', { name: 'Add' }).click();
}
}
type Fixtures = {
todoPage: TodoPage;
};
export const test = base.extend<Fixtures>({
todoPage: async ({ page }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await use(todoPage);
},
});
When a fixture is declared this way, tests can simply reference todoPage without manually initializing the page or navigating to the starting point. For scenarios where setup should happen automatically—such as collecting console messages or attaching screenshots on every run—fixtures can be marked as auto. This ensures the fixture is available without explicit declaration in each test function.
Network Interception and Mocking
Complex applications often involve third-party APIs, edge cases, and loading states that are difficult to predict. Playwright’s page.route method provides granular control over network traffic, enabling testers to simulate success, failure, and transformation scenarios without modifying the application under test.
await page.route('**/api/users', async route => {
const json = [{ id: 1, name: 'Alice' }];
await route.fulfill({ json });
});
await page.goto('/users');
await expect(page.getByText('Alice')).toBeVisible();
The route.fulfill method returns a custom response, while route.continue allows modifications to headers or URL before forwarding the request. When a test needs to verify error handling, route.abort can simulate network failures. Additionally, page.waitForResponse can be used in conjunction with routing to assert that specific endpoints were called with the expected payloads.
Authentication State Reuse
For test suites that require user login, repeatedly executing the UI flow to reach an authenticated state adds unnecessary runtime and can introduce flakiness. Playwright addresses this with storageState, a mechanism to persist and restore browser context such as cookies, localStorage, and sessionStorage.
In a global setup, the authenticated state is saved once:
// Global setup hook
await page.context().storageState({ path: 'auth.json' });
The configuration then references this file so that every test starts from a logged-in state:
use: {
storageState: 'auth.json',
}
This approach maintains test isolation—each test runs in a fresh browser context—but avoids the overhead of re-performing login actions. It is especially valuable in large suites where login steps would otherwise dominate execution time.
Multi-Page, Multi-Context, and Multi-Browser Testing
Modern web applications often require testing across multiple users, tenants, or browser engines. Playwright’s browser.newContext() creates completely isolated environments, each with its own cookies, cache, and storage. This is ideal for scenarios like multi-user chat applications, where two pages must interact without cross-contaminating state.
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const pageA = await contextA.newPage();
const pageB = await contextB.newPage();
await pageA.goto('/chat');
await pageB.goto('/chat');
For cross-browser compatibility, Playwright’s configuration file supports projects definitions. This allows a single test file to run simultaneously against Chromium, Firefox, and WebKit, ensuring that behavior is consistent across rendering engines. Each project can have its own set of test files, base URL, or viewport settings, providing a flexible way to maintain comprehensive compatibility coverage.
Tracing and Debugging
When a test fails, understanding exactly what happened in the browser is critical. Playwright’s Trace Viewer captures a complete snapshot of a test execution, including DOM snapshots, network requests, console messages, and video recordings. Enabling tracing is straightforward:
use: {
trace: 'on-first-retry',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
}
After a test run, npx playwright show-trace trace.zip opens the Trace Viewer in a browser, allowing developers to step through the execution timeline, inspect element states at any point, and diagnose flakiness. For interactive debugging, await page.pause() halts execution at a specific line, opening the Playwright Inspector where you can evaluate expressions, modify DOM, and resume step-by-step.
Advanced Selectors and Shadow DOM
Selecting elements in modern web apps often involves Shadow DOM or complex component structures. Playwright’s selector engine automatically pierces Shadow DOM, meaning you can use high-level locators without manually navigating through root and host elements.
The recommended approach is to use user-facing selectors:
getByRole– locate by ARIA role (e.g., button, checkbox, menuitem)getByText– match visible text contentgetByLabel– associate with form label textgetByTestId– target adata-testidattribute
These not only reduce brittleness but also improve accessibility coverage. When deeper access is required, locator('css=...') and locator('xpath=...') provide escape hatches. Additionally, Playwright allows registering custom selector engines via selectors.register, enabling teams to enforce domain-specific conventions or integrate with internal component libraries.
Parallel Execution and Test Sharding
Execution speed is a primary concern in continuous integration environments. Playwright Test runs tests in parallel by default, distributing test files across available workers. For finer-grained control, you can configure parallel execution within a describe block:
test.describe.configure({ mode: 'parallel' });
In CI pipelines, sharding divides tests across multiple machines or containers. Using the --shard flag, a suite can be split into N parts, each identified by an index:
npx playwright test --shard=1/3
npx playwright test --shard=2/3
npx playwright test --shard=3/3
Combined with the workers configuration, this maximizes utilization of multi-core machines, reducing feedback loops from minutes to seconds. The result is a faster development cycle and quicker detection of regressions.
Summary
Playwright’s advanced capabilities revolve around four pillars: reusability, isolation, debuggability, and scalability. Custom fixtures structure test code for clarity and maintainability. Network mocking and auth state reuse eliminate redundant setup and enable precise scenario testing. Multi-context and multi-browser configurations ensure comprehensive coverage across user scenarios and rendering engines. Meanwhile, Trace Viewer, advanced selectors, and parallel execution provide the tools needed to diagnose, select, and run tests efficiently.
The key to leveraging these features is incremental adoption. Rather than integrating every capability at once, teams should start with fixtures and auth reuse, then progressively introduce network interception, tracing, and sharding as test complexity grows. This measured approach keeps the test suite lean, understandable, and aligned with the evolving needs of the product.