17 Playwright Best Practices Every Engineer Should Follow in 2026
Find best practices that separate production-grade Playwright setups from tutorial code, with working examples for every pattern.
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 practices that separate production-grade Playwright 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.
Best practices for Playwright
-
Define your test coverage goals
-
Test what users see, not how it's built
-
Use stable locators to find elements
-
Keep tests focused and isolated
-
Write assertions that wait automatically
-
Use APIs to seed test data
-
Avoid testing 3rd party integrations
-
Mock external dependencies with page.route()
-
Structure your project for scale
-
Master Playwright's debugging tools
-
Parallelize and shard across CI
-
Eliminate flaky tests systematically
-
Test across browsers and devices
-
Use Playwright AI to generate and heal tests
-
Give your AI agent Playwright Skills
-
Centralize reporting for CI
-
Lint, type-check, and stay updated
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 }) => {
// Setup
const userId = await request.post('/api/test/user/create').then(r => r.json());
// Happy path
await page.goto('https://storedemo.testdino.com/');
await page.getByText('Apple iPad Air').first().click();
await page.getByRole('button', { name: /add to cart/i }).click();
// ... checkout steps
await expect(page.getByText('Cart')).toBeVisible();
});
test('checkout shows error when payment fails', async ({ page }) => {
// Mock payment API to fail
await page.route('**/api/payments', route => route.fulfill({
status: 400,
body: JSON.stringify({ error: 'Card declined' })
}));
// Proceed to checkout and verify error handling
await expect(page.getByText('Payment failed')).toBeVisible();
});
Pro 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.
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.
3. Use stable locators to find elements
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 stable):
// 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 complex DOM structures, chain locators to narrow the scope:
// Find the "Add to cart" button inside the "Apple iPad Air" product card
const product = page.getByRole('listitem').filter({ hasText: 'Apple iPad Air' });
await product.getByRole('button', { name: /add to cart/i }).click();
// Verify the cart count updated
await expect(page.getByTestId('cart-count')).toHaveText('1');
Use npx playwright codegen https://storedemo.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.

Use beforeEach hooks for shared setup:
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('https://storedemo.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();
});
test('user can update their email', async ({ page }) => {
await page.getByRole('link', { name: 'Settings' }).click();
await page.getByLabel('Email').fill('[email protected]');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Settings saved')).toBeVisible();
});
For larger suites, logging in before every test wastes time. Use Playwright's setup project to authenticate once and share the session:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
dependencies: ['setup'],
use: {
storageState: './auth.json',
},
},
],
});
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('https://storedemo.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 zero repeated login steps.
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.
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.
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.
The request fixture gives every test a context for making authenticated HTTP calls:
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ request }) => {
// Create test user via API
const userResponse = await request.post('https://storedemo.testdino.com/api/test/users', {
data: {
email: '[email protected]',
password: 'secure-password-123',
firstName: 'Test',
lastName: 'User'
}
});
const user = await userResponse.json();
console.log('Created test user:', user.id);
});
test('user can view their profile', async ({ page }) => {
// User already exists, just log in
await page.goto('https://storedemo.testdino.com/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('secure-password-123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByRole('link', { name: 'My Account' }).click();
await expect(page.getByRole('heading', { name: 'My Account' })).toBeVisible();
});
For complex scenarios, create a factory to generate consistent test data:
// fixtures/test-data-factory.ts
import { APIRequestContext } from '@playwright/test';
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();
}
async createProduct(overrides?: any) {
const response = await this.request.post(`${this.baseUrl}/api/test/products`, {
data: {
name: 'Apple iPad Air',
price: 660.00,
inventory: 100,
...overrides
}
});
return response.json();
}
async createOrder(userId: string, items: any[]) {
const response = await this.request.post(`${this.baseUrl}/api/test/orders`, {
data: { userId, items }
});
return response.json();
}
}
// tests/my-test.spec.ts
import { test, expect } from '@playwright/test';
import { TestDataFactory } from '../fixtures/test-data-factory';
test.beforeEach(async ({ request }, testInfo) => {
const factory = new TestDataFactory(request, process.env.BASE_URL || 'https://storedemo.testdino.com');
// Create test data
testInfo.user = await factory.createUser();
testInfo.product = await factory.createProduct();
testInfo.order = await factory.createOrder(testInfo.user.id, [testInfo.product.id]);
});
test('user can view their order history', async ({ page, request }, testInfo) => {
// Data is already seeded. Just navigate and verify.
await page.goto(`https://storedemo.testdino.com/orders/${testInfo.order.id}`);
await expect(page.getByText('Apple iPad Air')).toBeVisible();
await expect(page.getByText('$660.00')).toBeVisible();
});
Always clean up test data to keep your environment clean:
test.afterEach(async ({ request }, testInfo) => {
if (testInfo.user?.id) {
await request.delete(`https://storedemo.testdino.com/api/test/users/${testInfo.user.id}`);
}
});
Pro Tip: Create a dedicated /api/test/* endpoint on your backend that only exists in non-production environments.
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:
// ✅ Good: Mock the Stripe API, test your integration point
test('user can complete checkout', async ({ page }) => {
// Intercept calls to Stripe's API
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
})
}));
// Now your test is reliable, fast, and doesn't charge the test card
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.
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.
// Mock a third-party payment API
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,
}),
}));
// Now test the checkout flow — Stripe never gets called
await page.goto('https://storedemo.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. One 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 |
One-off utility functions used in a few places |
generateRandomEmail(), formatDate() |
10. Master Playwright's debugging tools
When a test fails, the speed of your debugging determines how fast you ship. Use 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 one 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.

11. Parallelize and shard across CI
Playwright runs tests in parallel by default across files. For large suites (500+ tests), shard across multiple CI machines:

For large test suites (500+ tests), shard across multiple machines:
# .github/workflows/playwright.yml
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/
Pro Tip: Only install the browsers you need on CI. Replace npx playwright install --with-deps (installs all browsers) with npx playwright install chromium --with-deps to save download time and disk space.
12. Eliminate flaky tests systematically
Flaky tests erode trust. When tests randomly fail, the team starts ignoring failures, and real bugs slip through. The top causes and their fixes:
The top 5 causes of flakiness (and their fixes)
- Missing await on async actions
Fix: Run eslint with @typescript-eslint/no-floating-promises to catch these automatically.example.spec.ts// ✅ Good: Always await actions await page.getByRole('button', { name: 'Subscribe' }).click();// ❌ Bad: Missing await — action executes in background
page.getByRole('button', { name: 'Subscribe' }).click();
await expect(page.getByText('Success')).toBeVisible(); // May fail if click hasn't completed
await expect(page.getByText('Success')).toBeVisible(); - Manual waits instead of web-first assertions
Fix: Replace all waitForTimeout() with web-first assertions or waitForResponse().example.spec.ts// ❌ Bad: Fixed timeout — doesn't adapt to network speed
await page.waitForTimeout(2000);
await expect(page.getByText('Loaded')).toBeVisible();
// ✅ Good: Web-first assertion — waits as long as needed up to timeout
await expect(page.getByText('Loaded')).toBeVisible(); - Non-deterministic test data
Fix: Use timestamps, UUIDs, or random strings to generate unique test data.example.spec.ts// ❌ Bad: Test email is the same every time. Second run fails because user already exists
test('signup', async ({ page }) => {
await page.getByPlaceholder('Your Email').fill('[email protected]');
});
// ✅ Good: Generate unique test data
test('signup', async ({ page }) => {
const email = `test-${Date.now()}@example.com`;
await page.getByPlaceholder('Your Email').fill(email);
}); - Interdependent tests example.spec.ts
// ❌ Bad: Test 2 depends on Test 1 passing first
test('create item', async ({ page }) => {
// Create item, store ID globally
});
test('update item', async ({ page }) => {
// Uses the item ID from test 1 — fails if test 1 was skipped
});
// ✅ Good: Each test is independent
test.beforeEach(async ({ request }) => {
// Create item for this test only
testItemId = await request.post('/api/items');
});
test('update item', async ({ page }) => {
// Uses testItemId from beforeEach — always available
});
Fix: Use beforeEach hooks and API seeding to set up data per test, not globally.
Detect and isolate flaky tests
# Run the same test 10 times to see if it flakes
npx playwright test login.spec.ts --repeat-each 10
Pro 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.
13. Test across browsers and devices
Playwright supports Chromium, Firefox, and WebKit out of the box. Use analytics to guide your browser 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'] } },
],
});

14. Use Playwright AI to generate and heal tests
Playwright 1.56 introduced 3 built-in AI agents:
Planner explores your app and produces a structured test plan in markdown. Generator converts that plan into executable Playwright test files. Healer runs failing tests, inspects the current UI, and patches them automatically.
# 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.
# Open a page and snapshot it
playwright-cli open https://your-app.com
playwright-cli snapshot
# Interact using element references from the snapshot
playwright-cli click e15
playwright-cli fill e5 "[email protected]"
playwright-cli press Enter
playwright-cli screenshot
Playwright MCP is an MCP server providing real-time accessibility snapshots for AI agents that need continuous page state. Use it when:
-
Real-time AI agent interactions with live web pages
-
When the AI agent needs a continuous, up-to-date page state
-
Complex multi-step workflows where the agent needs to react to dynamic content
-
Integration with AI tools that support MCP natively (Claude Desktop, Cursor, etc.)

|
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 |
|
Record a user flow for test creation |
Playwright CLI |
Built-in test generation from recordings |
|
AI-powered exploratory testing |
Playwright MCP |
Real-time interaction and observation |
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
npx skills add testdino-hq/playwright-skill/playwright-cli

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.

17. Lint, type-check, and stay updated
These 3 practices are boring but they prevent an entire category of bugs.
Lint your tests to catch missing await
The most common Playwright bug is a missing await. Without it, assertions execute synchronously and pass without actually checking anything.
npm install -D @typescript-eslint/eslint-plugin
// .eslintrc.json
{
"rules": {
"@typescript-eslint/no-floating-promises": "error"
}
}
This catches:
// ❌ ESLint error: Missing await — this assertion doesn't actually wait
expect(page.getByText('Success')).toBeVisible();
// ✅ Fixed
await expect(page.getByText('Success')).toBeVisible();
Type-check your tests on CI
Add a type-check step to your CI pipeline:
- name: Type check
run: npx tsc --noEmit
TypeScript catches wrong argument types, missing properties, and invalid method calls before the tests even run.
Keep Playwright updated
New Playwright versions ship with updated browser binaries that match the latest browser releases. This means you catch browser-specific bugs before your users do.
# Update Playwright
npm install -D @playwright/test@latest
# Install updated browsers
npx playwright install
# Check your current version
npx playwright --version
Check the release notes with every update. New versions often add features that simplify your tests — like new locator methods, better trace viewer capabilities, or improved agent tools.
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 |
Conclusion
Playwright in 2026 is a full platform: built-in AI agents, MCP servers, a token-efficient CLI, and an ecosystem of skills that make AI-generated tests production-ready.
The core practices haven't changed: use resilient locators, isolate your tests, write web-first assertions, and run on CI. What's changed is the tooling around them. Start with the fundamentals (practices 1-9), adopt the AI-native practices (10-15), and build the infrastructure to sustain it all (16-17).
Table of content
Flaky tests killing your velocity?
TestDino auto-detects flakiness, categorizes root causes, tracks patterns over time.