17 Playwright Best Practices That Actually Matter (With Code)
Find best practices that separate production-grade Playwright setups from tutorial code, with working examples for every pattern.
Playwright best practices are the testing patterns, locator strategies, and CI configurations that keep your test suite stable as your application scales. Following them is the difference between a suite that catches bugs and one that creates noise.
Playwright in 2026 ships with AI agents, MCP servers, and a CLI that can write tests autonomously. But raw tooling without the right patterns still produces fragile suites that break on every deploy.
This guide covers 17 Playwright best practices that separate production-grade setups from tutorial code. From role-based locators and API data seeding to CI sharding and installing battle-tested Playwright Skills with a single command.
1. Define your test coverage goals
Not all workflows deserve E2E tests. Testing everything is slower, more fragile, and burns through CI minutes without returning much value. Be strategic.
Start by listing critical user journeys: authentication, core features, transactions, and error recovery. Then use your analytics to prioritize. If 80% of users follow path A and 10% follow path B, prioritize A.

The reality of coverage:
100% coverage is a myth. After 70-80%, adding more tests costs more than the bugs they catch. Keep E2E tests to ~30% of total tests. The remaining ~70% should be unit and API tests.
// Good: Test critical happy path + common error states
test('user can complete checkout', async ({ page, request }) => {
const userId = await request.post('/api/test/user/create').then(r => r.json());
await page.goto('https://storedemo.cms.testdino.com/');
await page.getByText('Apple iPad Air').first().click();
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByText('Cart')).toBeVisible();
});
test('checkout shows error when payment fails', async ({ page }) => {
await page.route('**/api/payments', route => route.fulfill({
status: 400,
body: JSON.stringify({ error: 'Card declined' })
}));
await expect(page.getByText('Payment failed')).toBeVisible();
});
Tip: Use your analytics data quarterly to revisit coverage priorities. What users do today might change in 6 months. Keep your test portfolio aligned with reality. For a structured approach, see our Playwright automation checklist.
2. Test what users see, not how it's built
Most flaky test suites share a root cause: they test how the app is built instead of what the user sees. The Playwright team is explicit about this in their official best practices.
Click buttons by their visible label, not by a class name. Assert on text content the user actually reads, not internal state.
// Bad: Testing implementation details
await page.locator('button.bg-primary.add-to-cart').click();
expect(await page.evaluate(() => window.__cartState.added)).toBe(true);
// Good: Testing user-visible behavior
await page.getByRole('button', { name: /add to cart/i }).click();

Rule of thumb: If you delete the element's class attribute and the user experience stays the same, your test shouldn't break either. This is 1 of the most important Playwright best practices because it affects everything downstream: locator stability, assertion reliability, and maintenance cost.
3. Use stable locators
Locators are the single most important decision you make in every test. The right locator survives a complete UI redesign. The wrong one breaks when a developer renames a CSS class.
Playwright provides built-in locators with auto-waiting and retry-ability. The priority order (most stable to least):
// 1. Role-based (most resilient)
page.getByRole('button', { name: /add to cart/i });
// 2. Text-based
page.getByText('TestDino Demo Store');
// 3. Label-based (great for forms)
page.getByLabel('Search products...');
// 4. Placeholder-based
page.getByPlaceholder('Your Email');
// 5. Test ID (explicit contract)
page.getByTestId('cart-total');
// 6. CSS selector (last resort)
page.locator('.add-to-cart');
Role locators (getByRole()) mirror how screen readers interpret the page. When you use getByRole('button', { name: /add to cart/i }), you're selecting the element the way a real user would identify it. For a deeper dive into how Playwright reads the accessibility tree, see our dedicated guide.
For complex DOM structures, chain locators to narrow the scope:
const product = page.getByRole('listitem').filter({ hasText: 'Apple iPad Air' });
await product.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
Use npx playwright codegen https://storedemo.cms.testdino.com/ to auto-generate locators by recording interactions.

4. Keep tests focused and isolated
Each Playwright test runs in its own browser context with independent cookies, localStorage, session storage, and cache. No test should depend on the state left behind by another test. This is a Playwright best practice that prevents cascading failures.

Use beforeEach hooks for shared setup:
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('https://storedemo.cms.testdino.com/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('securepassword');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('My Account')).toBeVisible();
});
test('user can view their profile', async ({ page }) => {
await page.getByRole('link', { name: 'Profile' }).click();
await expect(page.getByRole('heading', { name: 'My Profile' })).toBeVisible();
});
For larger suites, logging in before every test wastes time. Use Playwright's setup project to authenticate once and share the session:
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: { storageState: './auth.json' },
},
],
});
setup('authenticate', async ({ page }) => {
await page.goto('https://storedemo.cms.testdino.com/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('My Account')).toBeVisible();
await page.context().storageState({ path: './auth.json' });
});
Now every test starts already logged in with 0 repeated login steps. For more patterns on structuring tests at scale, see our Playwright framework setup guide.
5. Write assertions that wait automatically
This is the single most common source of flakiness in Playwright suites: using manual assertions instead of web-first assertions. Understanding this Playwright best practice alone eliminates most flaky test issues.
Web-first assertions (toBeVisible(), toHaveText(), toHaveURL()) automatically retry until the condition is met or the timeout expires. Manual assertions (isVisible(), textContent()) execute once and return immediately.
// Bad: Manual assertion, checks once
expect(await page.getByText('Cart').isVisible()).toBe(true);
// Good: Web-first assertion, retries until visible or timeout
await expect(page.getByText('Cart')).toBeVisible();

The difference is subtle but critical. In the bad example, await is inside the expect. In the good example, await is before expect, which tells Playwright to retry the assertion.
Never use page.waitForTimeout(). If you find yourself writing await page.waitForTimeout(2000), you're masking a real problem. Use web-first assertions or page.waitForResponse() instead. For a deeper dive into debugging assertion failures, check our Playwright debugging guide.
6. Use APIs to seed test data
Setting up test data through the UI is slow, brittle, and defeats the purpose of automated testing. Use Playwright's request API to seed data via backend calls.

Creating a user through the UI takes ~5 seconds. The same via API takes 50 milliseconds. That's a 50x difference that compounds across hundreds of tests.
test.beforeEach(async ({ request }) => {
const userResponse = await request.post('https://storedemo.cms.testdino.com/api/test/users', {
data: {
email: '[email protected]',
password: 'secure-password-123',
firstName: 'Test',
lastName: 'User'
}
});
const user = await userResponse.json();
});
For complex scenarios, create a factory to generate consistent test data:
export class TestDataFactory {
constructor(private request: APIRequestContext, private baseUrl: string) {}
async createUser(overrides?: Partial<{ email: string; name: string }>) {
const response = await this.request.post(`${this.baseUrl}/api/test/users`, {
data: {
email: `user-${Date.now()}@test.com`,
password: 'test-password-123',
firstName: 'Test',
lastName: 'User',
...overrides
}
});
return response.json();
}
}
Tip: Create a dedicated /api/test/* endpoint on your backend that only exists in non-production environments. Always clean up test data in afterEach hooks to keep your environment clean.
7. Avoid testing 3rd party integrations
You don't own third-party APIs. They have rate limits, change without notice, and come with side effects. Don't test external auth providers, payment processors, email delivery services, analytics platforms, or embedded widgets directly.

Mock the response instead:
test('user can complete checkout', async ({ page }) => {
await page.route('**/stripe.com/v1/**', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'ch_test_123', status: 'succeeded', amount: 660 })
}));
await page.getByRole('button', { name: 'Pay with Stripe' }).click();
await expect(page.getByText('Payment successful')).toBeVisible();
});
Test your integration with the third party, not the third party itself. For more advanced network mocking patterns, see our dedicated guide.
8. Mock external dependencies with page.route()
page.route() is Playwright's network API for intercepting HTTP requests during test execution. It lets you mock, modify, or abort any outgoing request before it reaches an external server.

Mock when: third-party APIs, rate-limited services, slow endpoints, or non-deterministic data. Don't mock: your own backend during integration tests, or auth flows you need to verify E2E.
await page.route('**/api.stripe.com/v1/charges', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'ch_test_123', status: 'succeeded', amount: 660 }),
}));
await page.goto('https://storedemo.cms.testdino.com/checkout');
await page.getByRole('button', { name: 'Pay $660' }).click();
await expect(page.getByText('Payment successful')).toBeVisible();
9. Structure your project for scale
Group tests by feature, not by test type. 1 test file per workflow. Page Object Models live in a separate pages/ directory. Fixtures stay separate from tests.

| Pattern | Use when | Example |
|---|---|---|
| Page Object Model | A page has many interactions reused across 3+ test files | LoginPage.ts with login(), forgotPassword() methods |
| Fixtures | Shared setup/teardown logic tied to the test lifecycle | Auth fixture providing a logged-in page |
| Helper functions | 1-off utility functions used in a few places | generateRandomEmail(), formatDate() |
For a complete project structure template, see our Playwright framework setup guide.
10. Master Playwright's debugging tools
When a test fails, the speed of your debugging determines how fast you ship. This Playwright best practice is about using each tool for its intended purpose:
-
Local: The Playwright VS Code extension lets you set breakpoints, step through tests, and inspect locators live.
-
Interactive: npx playwright test --debug opens the Playwright Inspector for stepping through actions 1 at a time.
-
Visual: npx playwright test --ui gives you a time-travel debugging experience with DOM snapshots, network logs, and console output.
-
CI: The trace viewer captures a complete trace (screenshots, DOM snapshots, network requests) as a single file you can replay. Set trace: 'on-first-retry' in your config.

For a complete walkthrough of every debugging approach, see our Playwright debugging guide.
11. Parallelize and shard across CI
Playwright runs tests in parallel by default across files. For large suites (500+ tests), shard across multiple machines:

jobs:
test:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install chromium --with-deps
- run: npx playwright test --shard=${{ matrix.shard }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ strategy.job-index }}
path: playwright-report/
Tip: Only install the browsers you need on CI. Replace npx playwright install --with-deps (all browsers) with npx playwright install chromium --with-deps to save download time. For more CI patterns, see our CI/CD integrations guide.
12. Eliminate flaky tests
Flaky tests erode trust. When tests randomly fail, the team starts ignoring failures, and real bugs slip through. Here are the top 5 causes and their fixes:
1. Missing await on async actions
// Bad: Missing await
page.getByRole('button', { name: 'Subscribe' }).click();
await expect(page.getByText('Success')).toBeVisible();
// Good: Always await actions
await page.getByRole('button', { name: 'Subscribe' }).click();
await expect(page.getByText('Success')).toBeVisible();
Fix: Run eslint with @typescript-eslint/no-floating-promises to catch these automatically.
2. Manual waits instead of web-first assertions
// Bad: Fixed timeout
await page.waitForTimeout(2000);
await expect(page.getByText('Loaded')).toBeVisible();
// Good: Web-first assertion
await expect(page.getByText('Loaded')).toBeVisible();
3. Non-deterministic test data
// Bad: Same email every run — 2nd run fails
await page.getByPlaceholder('Your Email').fill('[email protected]');
// Good: Unique data per run
const email = `test-${Date.now()}@example.com`;
await page.getByPlaceholder('Your Email').fill(email);
4. Interdependent tests
// Bad: Test 2 depends on Test 1
test('create item', async ({ page }) => { /* store ID globally */ });
test('update item', async ({ page }) => { /* uses ID from test 1 */ });
// Good: Each test is independent
test.beforeEach(async ({ request }) => {
testItemId = await request.post('/api/items');
});
5. Clicking hidden or disabled elements
Use role-based locators. Playwright automatically waits for visibility and enabled state.
# Detect flaky tests by running the same test 10 times
npx playwright test login.spec.ts --repeat-each 10
Tip: When you fix a flaky test, add it to a low-flakiness test suite that you run more often. This signals to the team that the test is stable and can be trusted. For systematic approaches, see our flaky test detection tools guide.
13. Test across browsers and devices
Playwright supports Chromium, Firefox, and WebKit out of the box. Use analytics to guide your browser testing matrix. If 80% of users are on Chrome and 15% on Safari, prioritize those.
export default defineConfig({
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
],
});

For mobile-specific patterns, see our Playwright mobile testing guide.
14. Use Playwright AI agents to generate and heal tests
Playwright v1.56 introduced 3 built-in AI agents: Planner (explores your app, writes a test plan), Generator (converts plans to test files), and Healer (fixes failing tests automatically). This is the newest Playwright best practice and the 1 most teams haven't adopted yet.
# Initialize agent definitions for your preferred AI tool
npx playwright init-agents --loop=vscode # VS Code + Copilot
npx playwright init-agents --loop=claude # Claude Code
Playwright CLI is a command-line interface for snapshot-based browser automation. Microsoft built it for AI coding agents because it's ~4x more token-efficient than MCP.
playwright-cli open https://your-app.com
playwright-cli snapshot
playwright-cli click e15
playwright-cli fill e5 "[email protected]"
playwright-cli screenshot
Playwright MCP is an MCP server providing real-time accessibility snapshots for AI agents that need continuous page state. See the full CLI vs MCP comparison to decide which fits your workflow.

| Scenario | Use this | Why |
|---|---|---|
| AI agent needs to browse a website | Playwright CLI | Most token-efficient, snapshot-based |
| AI agent needs real-time page state | Playwright MCP | Continuous accessibility tree access |
| Generate a full test suite | Test Agents | Planner > Generator > Healer pipeline |
| Debug and fix a failing test | Test Agents (Healer) | Auto-inspects UI and patches tests |
15. Give your AI agent Playwright Skills
AI agents write decent Playwright tests out of the box. But they fall apart on real-world sites. Wrong selectors, broken auth flows, flaky CI runs. Agents don't have context about battle-tested patterns.
The Playwright Skill by TestDino contains 70+ guides organized into 5 skill packs: core, playwright-cli, pom, ci, and migration.
# Install all 70+ guides
npx skills add testdino-hq/playwright-skill
# Or install individual packs
npx skills add testdino-hq/playwright-skill/core
npx skills add testdino-hq/playwright-skill/ci

Without the Skill, an AI agent generates tutorial-quality code with CSS selectors and no assertions. With the Skill loaded, the same agent uses role-based locators, proper assertions, and patterns from authentication.md.
The Skill works with Claude Code, GitHub Copilot, Cursor, and Windsurf. It's MIT licensed. Fork it, customize it for your team, and share it internally.
16. Centralize reporting for CI
Playwright's built-in HTML reporter is excellent for local development. But on CI, the report disappears after the pipeline finishes. Your team can't see it or compare against last week's run.
export default defineConfig({
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'results.json' }],
['list'],
],
retries: process.env.CI ? 2 : 0,
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
});
For teams at scale, tools like TestDino provide AI-powered failure categorization that labels each failure as Bug, Flaky, or UI Change. Your team spends time fixing real issues instead of triaging noise. For a full walkthrough, see our Playwright reporting guide and our CI reporting runbook.

17. Lint, type-check, and stay updated
These 3 Playwright best practices are boring but they prevent an entire category of bugs.
Lint your tests to catch missing await
npm install -D @typescript-eslint/eslint-plugin
{
"rules": {
"@typescript-eslint/no-floating-promises": "error"
}
}
Type-check your tests on CI
- name: Type check
run: npx tsc --noEmit
Keep Playwright updated
npm install -D @playwright/test@latest
npx playwright install
npx playwright --version
Check the release notes with every update. New versions often add features that simplify your tests. For the full list of common Playwright mistakes and how to avoid them, see our guide.
Quick reference cheat sheet
| Practice | Command / Pattern |
|---|---|
| Generate locators | npx playwright codegen https://your-app.com |
| Debug a specific test | npx playwright test login.spec.ts:15 --debug |
| Run in UI Mode | npx playwright test --ui |
| Capture traces | npx playwright test --trace on |
| View HTML report | npx playwright show-report |
| Shard tests | npx playwright test --shard=1/4 |
| Install only Chromium on CI | npx playwright install chromium --with-deps |
| Initialize test agents | npx playwright init-agents --loop=vscode |
| Seed test data via API | await request.post('/api/test/seed', { data }) |
| Install Playwright Skills | npx skills add testdino-hq/playwright-skill |
| Update Playwright | npm install -D @playwright/test@latest |
FAQs
Table of content
Flaky tests killing your velocity?
TestDino auto-detects flakiness, categorizes root causes, tracks patterns over time.