Writing the first few Playwright tests is rarely the hard part. The challenge begins when the application grows and the test suite grows with it.
A locator that worked for months stops matching after a UI update. Test execution starts taking longer than expected. New contributors introduce different patterns because there is no clear structure to follow. Over time, the suite becomes harder to understand, troubleshoot, and extend.
Most of these issues can be avoided with these Playwright best practices.
1. Define Clear Test Scope and Coverage Goals
The quality of a Playwright test suite depends as much on what you choose not to automate as what you do. UI tests take longer to execute and maintain than unit or API tests, so adding them without a clear purpose quickly increases the size and cost of the suite.
Instead, define the scope early to help teams invest automation effort where it delivers the most value.
- Establish what to test and why: Test suites grow when scope expands without clear priorities. Identify the user journeys that have the greatest business impact, then decide where UI automation is needed and where API or unit tests can provide the same confidence.
- Set boundaries for what UI tests should not handle: Not every validation belongs in Playwright. Pure business logic, formatting checks, and backend validations are often better suited to lower testing layers. Keeping those responsibilities separate prevents unnecessary UI tests and reduces maintenance effort.
- Prioritize based on frequency and risk: Features that change often or affect core user flows should receive higher testing priority. Critical paths such as login, checkout, and dashboard interactions usually need deeper coverage, while low-impact features may require lighter automation.
2. Choose Stable Locators and Avoid Fragile Selectors
After deciding what belongs in your Playwright test suite, it’s time to start writing those tests. One of the earliest decisions you’ll make is how they identify elements on the page. That choice has a bigger impact on long-term maintenance than most teams expect.
- Prefer semantic and accessibility-based locators: Playwright provides built-in locators such as getByRole(), getByLabel(), getByText(), and getByPlaceholder(). These target elements based on how users interact with the interface instead of relying on the underlying DOM structure. They also encourage better accessibility practices because roles and labels need to be defined correctly.
- Use data-testid for dynamic interfaces: Some components generate changing IDs, repeated text, or deeply nested markup. In those cases, dedicated attributes such as data-testid provide a stable way to identify elements without depending on CSS classes or layout.
- Avoid long CSS selectors and XPath expressions: Selectors that depend on parent-child relationships, element positions, or generated classes are more likely to fail after a UI update. Keep locators short, scoped, and tied to meaningful attributes whenever possible.
Here is an example:
import { test, expect } from '@playwright/test';
test('Locate elements using Playwright locators', async ({ page }) => {
await page.goto('https://example.com/login');
// Preferred: User-facing locators
await page.getByLabel('Email').fill('user@example.com');
await page.getByRole('button', { name: 'Sign in' }).click();
// Useful for dynamic components
await page.getByTestId('save-button').click();
// Avoid: Selectors tied to DOM structure
await page.locator('div.container > div:nth-child(2) > button').click();
});All four examples locate the same element. The difference is what they depend on. User-facing locators usually survive UI refactoring because they rely on roles, labels, or dedicated test attributes. DOM-based selectors often need updates after layout changes, even when the user experience hasn’t changed.
3. Adopt the Page Object Model and Reusable Components
A mistake many teams make is treating every locator that passes today as a good locator. Six months later, someone redesigns the page, CSS classes change, and dozens of tests fail even though the user experience hasn’t changed. The locator wasn’t stable. It was simply lucky.
Take a login flow as an example. The first test includes the login steps because it’s the quickest way to get started. The second test copies them. A few weeks later, dozens of tests contain the same selectors and actions. Changing a single field or button now means editing every one of those files.
Page Objects solve that duplication, but only if you keep them small. I’ve seen page objects grow into thousand-line classes where every screen interaction lives in one place. Finding the right method eventually becomes harder than understanding the original test.
- Move repeated interactions into page classes: Store selectors and common actions inside dedicated page objects instead of repeating them across multiple tests. When the UI changes, you’ll usually update one page object instead of dozens of test files.
- Create reusable components for shared UI elements: Elements such as navigation menus, tables, modals, search bars, and date pickers often appear throughout the application. Treat them as reusable components instead of recreating the same interaction logic on every page.
- Keep page objects focused on interactions, not assertions: There are exceptions. Some teams prefer assertion helpers for reusable validations. The important part is staying consistent instead of mixing both styles throughout the same project.
Here’s a simple example. The page object owns the interaction. The test owns the assertion.
// pages/LoginPage.ts
import { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async login(email: string, password: string) {
await this.page.getByLabel('Email').fill(email);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign in' }).click();
}
}// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('User signs in successfully', async ({ page }) => {
const loginPage = new LoginPage(page);
await page.goto('/login');
await loginPage.login('user@example.com', 'password');
await expect(page).toHaveURL(/dashboard/);
});4. Use Fixtures for Setup, Authentication, and Shared Utilities
If you’ve written a handful of Playwright tests, you’ve probably copied the same setup more than once. Every test logs in, creates a user, opens the same page, or loads the same data. It doesn’t feel like a problem at first because copying a few lines is faster than thinking about a better approach.
That changes once your suite starts growing. One anti-pattern I’ve seen repeatedly is teams moving almost everything into fixtures because they want cleaner tests. A few months later, debugging starts with figuring out where users are created, who logs them in, and why certain data already exists. The setup became cleaner on the surface but harder to understand.
Fixtures exist to remove that repetition. They prepare everything a test needs before execution, so each test can focus on the scenario it’s validating instead of the steps required to reach it.
- Move repeated setup into fixtures: If every test signs in, creates test data, or opens the same page, move that setup into a fixture instead of repeating it across the suite.
- Expose shared objects through custom fixtures: Your page objects, API clients, and helper utilities can be injected directly into tests, which keeps the test code shorter and easier to follow.
- Keep fixtures focused on preparation: Use fixtures to prepare the environment. Leave assertions inside the test so anyone reading it can immediately understand what’s being verified.
Here’s a simple example. Instead of creating a LoginPage object inside every test, let the fixture provide it automatically.
// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
export const test = base.extend({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});// login.spec.ts
import { test, expect } from './fixtures';
test('User signs in successfully', async ({ loginPage, page }) => {
await page.goto('/login');
await loginPage.login('user@example.com', 'password');
await expect(page).toHaveURL(/dashboard/);
});The test now starts with the part you actually care about. If your authentication flow changes next month, you update the fixture once instead of hunting through every test that logs a user in.
5. Use Smart Waits and Web-First Assertions
By now, your tests are easier to read and the setup is no longer repeated everywhere. Yet you’ll still run into failures that seem random. The button was visible yesterday but isn’t clickable today. The page loads locally but times out in CI.
Someone adds a waitForTimeout(5000) to make the test pass, and a few weeks later another fixed delay appears in a different file.
The frustrating part is that the test usually passes afterward, which makes the fixed delay look like the solution. In practice, those waits tend to multiply. One turns into five, then ten, until nobody is sure which ones are still necessary.
Playwright already waits for elements to become actionable before interacting with them. You can build on that behaviour with web-first assertions that wait until the expected state is reached instead of pausing execution for a fixed amount of time.
- Use web-first assertions whenever possible: Assertions such as toBeVisible(), toHaveText(), and toHaveURL() wait until the expected condition is met before failing. They make tests respond to the application’s actual state instead of an arbitrary timeout.
- Avoid fixed delays: Methods such as waitForTimeout() pause execution whether the application is ready or not. They slow down successful test runs and still fail when the application takes longer than expected.
- Use explicit waits only for application-specific events: Some workflows require waiting for a file download, a network response, or a custom event. Wait for those specific conditions instead of adding generic delays.
The difference is easier to see in code.
// Preferred
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page).toHaveURL(/dashboard/);
// Avoid
await page.waitForTimeout(5000);
await page.getByRole('heading', { name: 'Dashboard' }).click();In the first example, Playwright waits only as long as necessary. In the second, every test waits five seconds even if the page is ready immediately. If the application needs six seconds that day, the test still fails.
6. Mock External Dependencies Carefully
Even with better waits, some tests will still fail for reasons that have nothing to do with your application. A payment gateway is slow. A third-party API returns a temporary error. An analytics service doesn’t respond. Your checkout flow fails even though the feature works exactly as expected.
When you’re testing your application’s behaviour, external services shouldn’t decide whether the test passes or fails. Mocking lets you replace those dependencies with predictable responses, so you can verify your application’s logic without waiting for systems you don’t control.
- Mock services outside your control: Payment gateways, mapping services, analytics platforms, and third-party APIs are common candidates. If those services become unavailable or slow, your tests should still produce consistent results.
- Mock only what the scenario requires: Replacing every network request with mock data hides integration issues. Mock external dependencies when they aren’t the focus of the test, but continue validating real API integrations where they matter.
- Keep mock responses realistic: Use representative payloads, status codes, and error conditions instead of oversimplified responses. This gives you confidence that the application handles real-world scenarios correctly.
The example below intercepts a request to an external API and returns a predefined response.
await page.route('**/api/recommendations', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
products: ['Laptop', 'Mouse', 'Keyboard']
}),
});
});
await page.goto('https://example.com');Now the test verifies how your application displays recommendations instead of depending on whether the external recommendation service is available. That makes failures easier to investigate because you already know where the problem is likely to be.
7. Keep Tests Independent and Isolated
Once your suite starts running in parallel, you’ll quickly find out whether your tests are truly independent. A test passes when you run it alone but fails in the full suite. Another works only because a previous test already created the required data. These failures are frustrating because rerunning the test often makes them disappear.
Shared state is one of those problems that doesn’t look expensive until parallel execution exposes it. Then every failure starts looking random even though the root cause is usually deterministic.
Independent tests don’t care about execution order. Whether Playwright runs them first, last, or alongside twenty other tests, the outcome should stay the same.
- Start every test with a clean state: Let each test create or load only the data it needs. Avoid relying on users, sessions, or records created by previous tests.
- Don’t chain tests together: If one test needs another to run first, you’ve created a hidden dependency. Every test should be able to run on its own.
- Reset shared data when necessary: If a scenario updates inventory, user profiles, or orders, restore the original state before the next test starts. API-based cleanup is usually faster than doing the same work through the UI.
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('User updates profile', async ({ page }) => {
// Independent test logic
});
test('User changes password', async ({ page }) => {
// Independent test logic
});One of the quickest health checks is to run a single test repeatedly in isolation. If the result changes depending on what ran before it, you’ve found a dependency that eventually needs to disappear.
8. Configure Browser and Device Projects for Cross-Browser Testing
A Playwright test passing in Chromium doesn’t automatically mean it will behave the same way in Firefox or WebKit. Browser engines render pages differently, handle fonts differently, and occasionally expose browser-specific issues that never appear during local development.
Instead of maintaining separate test suites, define browser and device projects in your Playwright configuration. The same tests can then run across multiple environments without changing the test code.
- Run the same suite across multiple browsers: Chromium, Firefox, and WebKit each expose different rendering and JavaScript behaviours. Testing all three helps catch browser-specific regressions earlier.
- Use device profiles where appropriate: Playwright’s built-in device descriptors simulate viewport size, touch support, and user agent. They’re useful for responsive testing, although critical user journeys should still be validated on real devices before release.
- Keep environment-specific settings in projects: Locale, timezone, permissions, geolocation, and viewport settings belong in project configuration rather than individual test files.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'Chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'Firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'WebKit', use: { ...devices['Desktop Safari'] } },
],
});Adding another browser should feel like a configuration change, not a reason to duplicate your tests.
9. Optimize Test Execution with Parallelism and Sharding
The first hundred tests usually finish quickly. The next few hundred are where execution time starts slowing down your release pipeline. Waiting forty minutes for regression results after every pull request eventually becomes a bottleneck.
Playwright includes parallel execution and sharding to distribute tests across CPU cores or multiple CI machines. Both reduce execution time without requiring changes to the tests themselves.
One mistake teams often make is treating parallel execution as a performance switch. They enable more workers, but the suite was written with shared users, shared test data, or hidden dependencies. Parallel execution doesn’t create those problems. It simply exposes them.
- Run tests in parallel where possible: Independent tests can execute simultaneously, reducing overall runtime. If parallel execution introduces flaky failures, check for shared state before changing Playwright settings.
- Use sharding for large regression suites: As the suite grows, splitting tests across multiple CI agents is usually more effective than continually increasing workers on a single machine.
- Adjust workers based on available resources: Running too many workers on an underpowered machine can increase CPU and memory contention, making execution slower rather than faster.
import { defineConfig } from '@playwright/test';
export default defineConfig({
workers: 4,
fullyParallel: true,
});Four workers might be ideal for one project and too many for another. The right number depends on the machine running the tests and how resource-intensive the suite is.
10. Manage Test Data and Environments Carefully
By now, your tests run across multiple browsers and execute in parallel. The next source of failures usually isn’t the test code. It’s the data underneath it.
You’ve probably seen it happen. One test deletes a user that another test expects to find. Shared accounts get modified during parallel execution. Someone manually updates a staging environment and a handful of tests start failing without any code changes. The test itself isn’t broken. The environment is no longer in the state the test expected.
Keeping test data predictable is just as important as writing good tests.
- Create only the data each test needs: A test should not depend on records created by another test or left behind from a previous execution.
- Prefer API-based setup over UI setup: Creating users, orders, or products through APIs is usually faster than navigating through the application every time. It also reduces the chances of setup failures masking the scenario you’re actually trying to validate.
- Keep environment configuration outside the tests: Base URLs, credentials, feature flags, and environment-specific values belong in configuration files or environment variables, not inside your test logic.
Here’s an example of loading environment-specific values instead of hardcoding them.
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.BASE_URL,
},
});When every test starts with predictable data and configuration, failures become much easier to investigate because you can rule out environment inconsistencies early.
11. Integrate Playwright Tests into Your CI/CD Pipeline
A test suite only provides value if it runs consistently. If someone has to remember to execute tests before every release, failures eventually slip through.
Most teams don’t run every Playwright test on every commit either. A pull request with a small UI change doesn’t always need the same validation as a production release. Organizing your pipeline around different levels of testing gives developers faster feedback without skipping important checks.
- Run smoke tests on every pull request: Validate the most critical user journeys before code is merged. This catches high-impact regressions early without slowing every build.
- Schedule broader regression suites when appropriate: Full regression runs are better suited for nightly builds, release candidates, or major feature branches where wider validation is needed.
- Publish test artifacts for failed runs: Screenshots, traces, videos, and logs make debugging much easier because developers can inspect the failure without rerunning the test immediately.
A typical CI workflow triggers Playwright automatically whenever new code is pushed.
name: Playwright Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci - run: npx playwright test
When tests become part of the delivery pipeline, every change is validated the same way. That consistency is often more valuable than simply running more tests.
12. Debug Failures with Traces, Screenshots, and Logs
Even well-written Playwright tests fail occasionally. The difficult part isn’t spotting the failure. It’s figuring out why it happened without rerunning the same test multiple times.
Many teams make debugging harder than it needs to be by collecting traces or screenshots only after flaky tests become a recurring problem. By then, the information needed to investigate earlier failures is already gone.
- Enable tracing for failed tests: Traces record every action, network request, DOM snapshot, and assertion so you can replay the test and see exactly what happened before the failure.
- Capture screenshots and videos when appropriate: A screenshot often reveals UI issues immediately, while videos help investigate problems that occur only during execution, such as timing or animation-related failures.
- Review browser console and network activity: JavaScript errors, failed API requests, missing resources, and CORS issues rarely appear in assertions but are often the actual cause of the failure.
Playwright can collect these artifacts automatically whenever a test fails.
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
});A failed assertion tells you where the test stopped. The debugging artifacts explain everything that happened before it.
13. Write Tests That Are Easy to Read and Review
A Playwright test is usually written once but read many times. Code reviews, debugging sessions, and future enhancements all depend on someone understanding the test quickly. If the intent isn’t obvious, even simple changes take longer than they should.
One mistake teams make is treating test code differently from application code. Naming conventions become inconsistent, scenarios grow longer over time, and a suite that was easy to follow gradually becomes difficult to maintain.
- Name tests after user behaviour: A title such as User can reset password communicates the scenario more clearly than Test password reset.
- Keep one scenario per test: Long end-to-end flows make failures harder to interpret because a single assertion can hide several unrelated problems. If a workflow naturally contains multiple independent validations, consider splitting it into separate tests.
- Use test.step() for complex workflows: Named steps make reports and traces easier to navigate without breaking the scenario into multiple tests.
test('User completes checkout', async ({ page }) => {
await test.step('Add product to cart', async () => {
// ...
});
await test.step('Complete payment', async () => {
// ...
});
});When reviewers spend more time understanding the test than reviewing the change itself, readability has already become part of the maintenance cost.
14. Maintain Test Health as Your Application Evolves
Adding new tests is usually straightforward. Deciding which ones no longer deserve a place in the suite is harder. Over time, features are redesigned, workflows change, and old scenarios stop providing meaningful coverage. Keeping every historical test eventually increases execution time without adding confidence.
Regular maintenance keeps the suite relevant as the application evolves.
- Remove obsolete tests: If a feature has been removed or the same behaviour is already covered elsewhere, deleting the test is usually a better choice than keeping it “just in case.”
- Investigate flaky tests before increasing retries: Retries reduce noise from temporary failures, but they shouldn’t replace debugging. Frequent retries often indicate timing issues, shared state, or unstable test environments that need attention.
- Upgrade Playwright regularly: New releases include browser updates, bug fixes, and additional capabilities. Review the release notes before upgrading and run the suite against the new version before adopting it in CI.
A growing test count doesn’t necessarily mean a healthier suite. Well-maintained suites remove outdated tests as readily as they add new ones.
15. Validate on Real Browsers and Devices Before Release
A Playwright suite can pass every test locally and still miss issues that appear only in production environments. Browser version differences, operating system behaviour, touch interactions, hardware limitations, and device-specific rendering are difficult to reproduce through emulation alone.
One assumption that catches teams off guard is believing successful emulation guarantees the same behaviour on physical devices. Emulation is excellent for development and debugging, but it can’t reproduce every browser, operating system, or hardware-specific condition.
- Run critical user journeys on real browsers: Browser behaviour still varies across rendering, scrolling, permissions, media handling, and browser updates. Validating high-impact workflows on the browsers your customers actually use reduces the risk of release-day surprises.
- Validate important mobile scenarios on real devices: Device emulation is valuable during development, but touch input, virtual keyboards, gestures, sensors, and operating system integrations still need testing on physical hardware.
- Choose production-like environments whenever possible: The closer your test environment matches your users’ browsers, devices, and operating systems, the more representative your results become.
Passing tests in an emulated environment is a useful milestone. The final confidence check comes from validating the user journeys that matter most in the environments your customers actually use.
Conclusion
Most Playwright test suites don’t fail because of a single design decision. They become harder to maintain as small compromises accumulate over time. A locator copied into three files. A login flow repeated across dozens of tests. A fixed delay that never gets removed. None of them feel significant when they’re introduced. Together, they gradually make the suite slower to change and harder to trust.
Building a maintainable Playwright framework is less about following every best practice than applying them consistently. As the application grows, revisit the suite with the same discipline you apply to production code. Remove what’s no longer useful, simplify what has become unnecessarily complex, and keep the tests focused on the user journeys that still matter.














