A test suite nobody trusts is worse than no test suite at all. It gives the illusion of coverage while the team ignores red pipelines and ships anyway. The difference between teams that trust their E2E suite and teams that don't almost always comes down to the same 15 practices.
These aren't theoretical suggestions — they're patterns extracted from production suites with 500–2,000+ tests. Each practice includes a concrete "do this, not that" example so you can apply it immediately.
Locator Strategy
Locate elements by their ARIA role and accessible name. These locators survive UI redesigns because they target what the element is, not where it sits in the HTML. Use getByRole as your default, falling back to getByLabel, getByText, and getByTestId in that order.
page.getByRole('button', { name: 'Submit' })page.getByLabel('Email address')page.getByRole('heading', { level: 1 })
page.locator('.btn-primary')page.locator('#email-input')page.locator('div > h1:first-child')
Bonus: Role-based locators double as an accessibility audit. If you can't find an element with getByRole, that element likely has accessibility problems that affect real users with screen readers.
getByTestId for Elements Without Semantic RolesUse data-testid only for elements that have no meaningful ARIA role: custom canvas components, generic wrapper <div>s used as drop zones, or dynamically generated containers. If you're adding data-testid to a <button>, <input>, or <link>, that's a code smell — those elements already have roles.
Chaining like page.locator('.sidebar').locator('.menu').locator('.item') creates a tight coupling to your HTML hierarchy. When a developer wraps elements in a new <div>, the chain breaks. Target the end element directly: page.getByRole('menuitem', { name: 'Settings' }).
Assertions & Waiting
Playwright's await expect() assertions automatically retry until the condition is met or the timeout expires. This eliminates the single largest category of flaky tests.
await expect(page.getByText('Success')).toBeVisible()await expect(page).toHaveURL(/dashboard/)
const text = await page.textContent('.msg');expect(text).toBe('Success');
The "don't" example fails intermittently because textContent() evaluates once at the exact moment it's called. If the page hasn't rendered yet, you get null. The "do" example retries until "Success" appears or the timeout expires.
page.waitForTimeout(3000) is the #1 cause of flaky tests across every framework. It's either too short (test fails) or too long (suite is slow). Replace every hardcoded wait with a specific condition.
await page.waitForResponse('**/api/data')await expect(spinner).not.toBeVisible()
await page.waitForTimeout(3000)await page.waitForTimeout(5000)
One exception: waitForTimeout is acceptable in rare debugging scenarios (await page.pause() is better). It should never appear in committed test code.
Test Isolation
Each test should create its own data, perform its own actions, and assert its own outcomes. No test should depend on another test running first. No test should leave state that affects other tests. Playwright creates a fresh browser context per test by default — use this isolation instead of fighting against it.
test('user can update their display name', async ({ page, request }) => { // Arrange: create test user via API (fast, isolated) const res = await request.post('/api/test-users', { data: { name: 'Test User', email: `user-${Date.now()}@test.com` } }); const user = await res.json(); // Act: update name through UI await page.goto('/profile'); await page.getByLabel('Display name').fill('New Name'); await page.getByRole('button', { name: 'Save' }).click(); // Assert await expect(page.getByText('New Name')).toBeVisible(); });
Using the UI to create test data is 10–20x slower than an API call and introduces dependencies on unrelated UI components. If the registration flow breaks, every test that creates users through the UI also breaks — even though those tests have nothing to do with registration.
Use the request fixture to seed data in beforeEach or in custom fixtures. Clean up in afterEach with DELETE calls.
storageState for AuthenticationDon't log in through the UI for every test. Authenticate once in a globalSetup script, save the session to a JSON file with storageState, and load it in your config. This saves 2–5 seconds per test and eliminates a dependency on the login page.
export default defineConfig({ globalSetup: './global-setup.ts', use: { storageState: 'auth.json', }, });
Test Architecture
Encapsulate page interactions into classes. When a selector changes, you update it in one place instead of across 30 test files. Keep POM classes focused: locators as readonly properties, user actions as async methods. Don't put assertions in POM classes — assertions belong in tests.
export class LoginPage { readonly email = this.page.getByLabel('Email'); readonly password = this.page.getByLabel('Password'); readonly submitBtn = this.page.getByRole('button', { name: 'Sign In' }); constructor(private page: Page) {} async login(email: string, password: string) { await this.email.fill(email); await this.password.fill(password); await this.submitBtn.click(); } }
Extract repeated setup logic into Playwright fixtures instead of duplicating beforeEach blocks. Fixtures are composable, lazy (only instantiated when requested), and automatically cleaned up. Combine fixtures with POM: create a fixture that provides an authenticated DashboardPage instance.
export const test = base.extend<{ dashboardPage: DashboardPage }>({ dashboardPage: async ({ page }, use) => { // Automatic setup await page.goto('/dashboard'); const dashboard = new DashboardPage(page); await use(dashboard); // Automatic teardown (if needed) }, }); // Tests are clean and readable test('shows recent activity', async ({ dashboardPage }) => { await expect(dashboardPage.activityFeed).toBeVisible(); });
Use page.route() to intercept calls to third-party services (payment gateways, email APIs, analytics) that are slow, expensive, or unreliable. Don't mock your own API in E2E tests — the whole point of E2E testing is to verify the real integration between frontend and backend.
page.route('**/stripe.com/**', ...)page.route('**/analytics.google.com/**', ...)
page.route('**/your-api/products', ...)page.route('**/your-api/users', ...)
Exception: Mock your own API when testing specific error states that are hard to trigger (500 errors, timeouts, rate limits). In those cases, mock the specific endpoint for that specific test, not globally.
CI/CD & Performance
Local development and CI have fundamentally different needs. Use environment-aware config:
const isCI = !!process.env.CI; export default defineConfig({ retries: isCI ? 2 : 0, // Retry only in CI workers: isCI ? 4 : undefined, // Limit workers in CI use: { trace: isCI ? 'on-first-retry' : 'off', // Traces only in CI video: isCI ? 'on-first-retry' : 'off', // Video only on failure headless: isCI, // Headed locally for debugging }, reporter: isCI ? [['html'], ['junit', { outputFile: 'results.xml' }]] : [['list']], });
When your suite exceeds 10 minutes in CI, shard it across multiple machines. Playwright's --shard flag splits tests evenly. In GitHub Actions, use a matrix strategy:
jobs: test: strategy: matrix: shard: [1, 2, 3, 4] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20' } - run: npm ci - run: npx playwright install --with-deps - run: npx playwright test --shard=${{ matrix.shard }}/4 - uses: actions/upload-artifact@v4 if: always() with: name: report-shard-${{ matrix.shard }} path: playwright-report/
This turns a 20-minute suite into a 5-minute run. Each shard gets a proportional subset of tests with no configuration needed beyond the flag.
Configure CI to upload Playwright's HTML report and trace files as build artifacts. When a test fails in CI, you need the trace to diagnose the issue without reproducing it locally. Set if: always() on the upload step so artifacts are saved even when tests fail.
AI-Assisted Testing
In 2026, Claude AI with the Playwright MCP Server can generate complete test suites from natural language. This is a genuine 3–5x speed multiplier — but it requires the same discipline as hand-written code:
- Always review generated tests — verify assertions match your business logic, not just that the code compiles
- Run tests locally before committing — first-run pass rates are high but not 100%
- Use MCP Server for live DOM context — prompt-only generation produces less accurate locators
- Apply the same architecture rules — request POM structure, proper test isolation, and meaningful test names from Claude
- Use AI for self-healing — when a selector breaks, paste the failure into Claude with MCP connected to fix it in under 2 minutes instead of 30
The best workflow: Use Claude to generate the initial test files with POM structure, then review and commit. When tests break due to UI changes, use Claude + MCP to self-heal. This combines AI speed with human judgement for a test suite that's both fast to build and reliable to maintain.
Quick-Reference Checklist
Before You Commit a Test
- Locators use getByRole or getByLabel
- No hardcoded waits (waitForTimeout)
- All assertions use await expect()
- Test creates its own data
- Test doesn't depend on other tests
- Test runs in parallel without flaking
- POM class used for reusable pages
- External services are mocked
Before You Merge to Main
- CI config has retries: 2
- Trace artifacts uploaded on failure
- storageState used for authentication
- Sharding enabled if suite > 10 min
- All tests pass locally and in CI
- No test.skip without a linked issue
- New tests reviewed by a teammate
- HTML report uploaded as artifact
Frequently Asked Questions
What are the most important Playwright best practices?
The five most impactful: (1) Use getByRole/getByLabel instead of CSS selectors, (2) Use web-first await expect() assertions, (3) Isolate every test with its own data, (4) Never use hardcoded waits, (5) Seed data via API instead of UI. These five eliminate the majority of flaky tests.
How do I fix flaky Playwright tests?
Most flakiness comes from: hardcoded waits (replace with assertions), brittle CSS selectors (switch to role-based locators), shared test data (give each test its own), animation interference (use reducedMotion), and race conditions in parallel (ensure no shared state). Use Trace Viewer to diagnose exactly where tests fail.
How do I speed up Playwright tests in CI?
Enable fullyParallel, use sharding across CI machines, run headless, seed data via API, mock external services, use storageState for auth, and block unnecessary resources (images, fonts) with route.abort().
Should I use Page Object Model or fixtures?
Use both. POM encapsulates page interactions (locators, actions). Fixtures handle setup/teardown (auth, data seeding). Combine them: fixtures that provide pre-configured POM instances to tests. This gives you clean, readable test files.
How many tests should run in parallel?
Start with Playwright's default (CPU cores / 2). For CI, 4–8 workers per machine is typical. If parallel runs introduce flakiness, check for shared state before reducing workers. For 500+ tests, shard across multiple CI machines.
Asim Noaman
Senior QA Automation Engineer & AI Testing Specialist
With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.