Testing Guide
Playwright Testing Best Practices: Complete 2026 Guide
Everything you need to write fast, reliable, and maintainable Playwright tests. From locator strategies to CI/CD integration and AI-powered self-healing.
“Playwright has become the most widely adopted end-to-end testing framework in the JavaScript ecosystem, and its momentum continues to grow.”
1. Why Playwright Is Winning
Playwright has established itself as one of the dominant end-to-end testing frameworks in the JavaScript ecosystem. Its weekly npm downloads overtook Cypress and have kept climbing, and its GitHub star count puts it among the most popular testing projects on the platform. With multi-browser support out of the box and strong test stability in our experience across enterprise deployments, Playwright is the framework that many engineering teams are standardizing on.
Several factors drive this adoption. First, Playwright supports Chromium, Firefox, and WebKit from a single API, eliminating the need for separate browser-specific test suites. Second, its auto-waiting mechanism resolves the vast majority of timing issues that plague other frameworks. Third, the built-in test runner (introduced in Playwright Test) provides parallel execution, test fixtures, and rich reporting without requiring third-party plugins.
Microsoft backs the project with a full-time engineering team, ensuring consistent releases and rapid bug fixes. The community has grown to include thousands of contributors, and the ecosystem of plugins, integrations, and tutorials is larger than ever. Whether you are testing a simple marketing site or a complex SPA with real-time collaboration features, Playwright has the tooling to handle it.
The rest of this guide covers the specific techniques and patterns that separate productive Playwright test suites from brittle, unmaintainable ones. Each section includes TypeScript examples you can copy directly into your project.
2. Locator Best Practices
The single biggest factor in test reliability is how you locate elements on the page. Fragile selectors (CSS classes, XPath expressions, deeply nested DOM paths) are the number one cause of test breakage during routine UI refactors. Playwright provides a hierarchy of user-facing locators that mirror how real users interact with your application.
The Locator Priority Hierarchy
Follow this priority order when choosing locators. The higher up the list, the more resilient and accessible the locator will be:
- getByRole targets elements by their ARIA role and accessible name. This is the most resilient locator because it reflects how assistive technologies see your page.
- getByText finds elements by their visible text content. Useful for buttons, links, and headings that have unique labels.
- getByLabel locates form controls by their associated label. Ideal for input fields, selects, and textareas.
- getByPlaceholder targets inputs by placeholder text when labels are absent.
- getByTestId uses a custom data-testid attribute. A reliable fallback when semantic locators are ambiguous.
// Preferred: role-based locator
await page.getByRole('button', { name: 'Submit order' }).click();
// Good: text-based locator
await page.getByText('Welcome back, Jane').isVisible();
// Good: label-based locator for forms
await page.getByLabel('Email address').fill('jane@example.com');
// Fallback: test ID when semantics are ambiguous
await page.getByTestId('cart-total').toContainText('$49.99');
// Avoid: CSS selectors tied to implementation details
// await page.locator('.btn-primary.submit-cta').click(); // fragileChaining and Filtering Locators
When a single locator matches multiple elements, chain or filter to narrow the selection. This is far more maintainable than constructing a complex CSS selector string.
// Filter a list of cards to find one by heading text
const productCard = page
.getByRole('article')
.filter({ hasText: 'Wireless Headphones' });
await productCard.getByRole('button', { name: 'Add to cart' }).click();
// Chain locators to scope within a specific section
const sidebar = page.getByRole('complementary');
await sidebar.getByRole('link', { name: 'Settings' }).click();Adopting user-facing locators is also a forcing function for better accessibility. If you cannot easily target an element with getByRole, that is a signal your markup may be missing proper ARIA attributes, and your real users with screen readers are probably struggling with the same element.
3. Writing Reliable Assertions
Playwright assertions come in two flavors: web-first (auto-retrying) and generic. For end-to-end tests, you should almost always prefer the web-first assertions from the expect API. These automatically retry until the condition is met or the timeout expires, eliminating the need for manual waits and sleep calls.
Auto-Retrying Assertions
// Auto-retries until the element is visible (up to timeout)
await expect(page.getByRole('alert')).toBeVisible();
// Auto-retries until text content matches
await expect(page.getByTestId('status')).toContainText('Complete');
// Auto-retries until the element has the expected attribute
await expect(page.getByRole('button', { name: 'Save' }))
.toBeEnabled();
// Auto-retries until the URL matches
await expect(page).toHaveURL(/\/dashboard/);
// Auto-retries until the page title matches
await expect(page).toHaveTitle('Dashboard | MyApp');Custom Matchers with expect.extend
For domain-specific assertions that you repeat across many tests, create custom matchers. This keeps test code expressive and reduces duplication.
// fixtures/custom-matchers.ts
import { expect as baseExpect, type Locator } from '@playwright/test';
export const expect = baseExpect.extend({
async toBeLoggedInAs(page: any, username: string) {
const avatar = page.getByRole('button', { name: username });
try {
await baseExpect(avatar).toBeVisible({ timeout: 5000 });
return { pass: true, message: () => 'User is logged in' };
} catch {
return {
pass: false,
message: () => `Expected user "${username}" to be logged in`,
};
}
},
});
// In your test file
await expect(page).toBeLoggedInAs('jane@example.com');Soft Assertions for Comprehensive Checks
When validating a page with many elements (for example, a form with multiple fields pre-filled), soft assertions let the test continue after a failure so you can see all problems at once instead of fixing them one by one.
await expect.soft(page.getByLabel('First name')).toHaveValue('Jane');
await expect.soft(page.getByLabel('Last name')).toHaveValue('Doe');
await expect.soft(page.getByLabel('Email')).toHaveValue('jane@example.com');
await expect.soft(page.getByLabel('Country')).toHaveValue('US');
// Test continues even if earlier assertions fail
// All failures are reported together at the end4. Test Isolation Patterns
Test isolation means each test starts from a clean, predictable state. Without isolation, tests become order-dependent, and a failure in one test can cascade into false failures in subsequent tests. Playwright provides three primary mechanisms for isolation: browser contexts, storage state, and test fixtures.
Browser Contexts for Parallel Isolation
Every Playwright test gets its own BrowserContext by default. This means separate cookie jars, local storage, and session state. Tests running in parallel never interfere with each other because they operate in completely independent browser sessions.
// Each test automatically gets its own context and page
import { test, expect } from '@playwright/test';
test('user can add item to cart', async ({ page }) => {
// This page has its own cookies, storage, and session
await page.goto('/shop');
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
test('cart starts empty for new user', async ({ page }) => {
// Completely independent from the test above
await page.goto('/shop');
await expect(page.getByTestId('cart-count')).toHaveText('0');
});Storage State for Authentication Reuse
Logging in through the UI for every test is slow and brittle. Instead, authenticate once in a setup project and save the storage state to a file. Subsequent tests load that state to start pre-authenticated.
// auth.setup.ts - runs once before all tests
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('secure-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Save signed-in state to a file
await page.context().storageState({
path: './playwright/.auth/user.json',
});
});
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
storageState: './playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});Custom Fixtures for Reusable Setup
Fixtures let you define reusable setup and teardown logic that can be injected into any test. They compose naturally and support automatic cleanup.
// fixtures.ts
import { test as base } from '@playwright/test';
type Fixtures = {
todoPage: any;
};
export const test = base.extend<Fixtures>({
todoPage: async ({ page }, use) => {
await page.goto('/todos');
// Seed initial data via API
await page.request.post('/api/todos', {
data: { title: 'Write tests', completed: false },
});
await page.reload();
await use(page);
// Cleanup: delete all todos after test
await page.request.delete('/api/todos/all');
},
});
// In your test
test('can complete a todo', async ({ todoPage }) => {
await todoPage.getByRole('checkbox', { name: 'Write tests' }).check();
await expect(todoPage.getByText('1 completed')).toBeVisible();
});5. Debugging Techniques
Even well-structured tests fail sometimes. Playwright ships with a suite of debugging tools that make it straightforward to diagnose failures, whether they happen locally or in CI. Knowing these tools well can cut your debugging time from hours to minutes.
Trace Viewer
The trace viewer is Playwright's most powerful debugging tool. It records every action, network request, console log, and DOM snapshot during a test run, then lets you step through the timeline in a visual UI.
// playwright.config.ts - enable traces on failure
export default defineConfig({
use: {
trace: 'on-first-retry', // captures trace only on retries
},
});
// View the trace after a failed run
// npx playwright show-trace trace.zip
// Or enable trace for a specific test during development
test('checkout flow', async ({ page, context }) => {
await context.tracing.start({ screenshots: true, snapshots: true });
// ... test steps ...
await context.tracing.stop({ path: 'checkout-trace.zip' });
});Headed Mode and Inspector
Running tests in headed mode lets you watch the browser in real time. The Playwright Inspector adds step-by-step execution with breakpoints, locator highlighting, and a locator picker.
# Run in headed mode to watch the browser
npx playwright test --headed
# Launch the Inspector for step-by-step debugging
PWDEBUG=1 npx playwright test
# Run a single test file in headed mode with slow motion
npx playwright test checkout.spec.ts --headed --slow-mo=500Video Recording and Screenshots
For CI environments where you cannot watch the browser interactively, configure Playwright to record videos and take screenshots on failure. These artifacts make it possible to debug flaky tests that only fail in CI.
// playwright.config.ts
export default defineConfig({
use: {
video: 'on-first-retry',
screenshot: 'only-on-failure',
},
// Output directory for artifacts
outputDir: './test-results',
});6. CI/CD Integration
Running Playwright tests in CI is essential for catching regressions before they reach production. The framework is designed to work well in containerized environments, and with proper configuration, you can run hundreds of tests in under five minutes using parallelization and sharding.
GitHub Actions Example with Sharding
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 20
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.shardIndex }}
path: playwright-report/
retention-days: 14
- name: Upload trace files
uses: actions/upload-artifact@v4
if: failure()
with:
name: traces-${{ matrix.shardIndex }}
path: test-results/
retention-days: 7Parallelization Configuration
Playwright runs test files in parallel by default, using worker processes. You can fine-tune parallelism at both the file level and the test level.
// playwright.config.ts
export default defineConfig({
// Number of parallel workers
workers: process.env.CI ? 4 : undefined,
// Retry failed tests in CI
retries: process.env.CI ? 2 : 0,
// Run tests within a file in parallel
fullyParallel: true,
// Reporter configuration for CI
reporter: process.env.CI
? [['html', { open: 'never' }], ['github']]
: [['html']],
});Sharding distributes test files across multiple CI runners. Combined with the fullyParallel option, you can achieve near-linear scaling: four shards will run your suite roughly four times faster. Upload and merge artifacts from each shard so you get a single unified report.
7. Advanced Patterns
Once you have the fundamentals in place, these advanced patterns will help you scale your test suite and handle complex scenarios that basic documentation does not cover.
Page Object Model
Page objects encapsulate page-specific locators and actions in a single class. This keeps tests readable and ensures that when a UI changes, you only need to update the page object, not every test that interacts with that page.
// pages/checkout.page.ts
import { type Page, type Locator } from '@playwright/test';
export class CheckoutPage {
readonly page: Page;
readonly emailInput: Locator;
readonly placeOrderButton: Locator;
readonly confirmationBanner: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.placeOrderButton = page.getByRole('button', { name: 'Place order' });
this.confirmationBanner = page.getByRole('alert');
}
async fillEmail(email: string) {
await this.emailInput.fill(email);
}
async placeOrder() {
await this.placeOrderButton.click();
}
async expectConfirmation() {
await expect(this.confirmationBanner).toContainText('Order confirmed');
}
}
// In your test
test('complete checkout', async ({ page }) => {
const checkout = new CheckoutPage(page);
await page.goto('/checkout');
await checkout.fillEmail('buyer@example.com');
await checkout.placeOrder();
await checkout.expectConfirmation();
});API Mocking with route()
Mocking API responses lets you test edge cases, error states, and slow network conditions without depending on a live backend.
test('shows error state when API fails', async ({ page }) => {
// Mock the products endpoint to return a 500 error
await page.route('**/api/products', (route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
);
await page.goto('/shop');
await expect(page.getByText('Something went wrong')).toBeVisible();
});
test('handles slow network gracefully', async ({ page }) => {
await page.route('**/api/products', async (route) => {
// Simulate a 3-second delay
await new Promise((r) => setTimeout(r, 3000));
await route.continue();
});
await page.goto('/shop');
await expect(page.getByText('Loading products...')).toBeVisible();
});Visual Comparisons
Playwright supports pixel-level screenshot comparison for catching visual regressions. Combine this with the maxDiffPixels or threshold options to account for rendering differences across environments.
test('landing page visual regression', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('landing-page.png', {
maxDiffPixelRatio: 0.01,
animations: 'disabled',
});
});
test('dark mode toggle', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('switch', { name: 'Dark mode' }).click();
await expect(page).toHaveScreenshot('dark-mode.png', {
threshold: 0.2,
});
});8. Combining Playwright with AI
The best practices above will get you a reliable test suite, but you still face the challenge of writing and maintaining all those tests by hand. This is where AI-powered tools like Assrt change the equation. Assrt sits on top of Playwright and uses AI to automate the most time-consuming parts of the testing workflow.
Auto-Discovery
Assrt crawls your application and automatically identifies testable user flows. It maps navigation paths, form submissions, authentication flows, and interactive elements into a structured test plan. No manual effort required to get started.
# Point Assrt at your running app
assrt discover http://localhost:3000
# Output: discovered 47 test scenarios
# - Login flow (email + password)
# - Registration with validation
# - Product search and filter
# - Add to cart and checkout
# - User profile update
# ... and 42 moreSelf-Healing Tests
When your UI changes and selectors break, Assrt detects the failure, analyzes the new DOM, and generates a pull request with updated locators. The output is standard Playwright code that you can review and merge on your own terms. There are no runtime AI calls in your test suite, so execution remains deterministic and fast.
AI-Generated Test Code
Assrt generates tests that follow every best practice described in this guide: role-based locators, auto-retrying assertions, proper test isolation, and page object patterns. The generated code is readable, idiomatic TypeScript that your team can extend and customize.
# Generate tests from discovered flows
assrt generate --output tests/
# The generated test follows all best practices
# - Uses getByRole and getByLabel locators
# - Includes auto-retrying assertions
# - Handles authentication via storage state
# - Follows page object patterns for complex flowsBy combining Playwright best practices with AI-powered tooling, you get the reliability and transparency of hand-written tests with a fraction of the authoring and maintenance effort. Assrt is open source, MIT licensed, and runs locally, so your code and test data never leave your network.
Related Guides
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.