Home / Blog / Playwright best practices
Playwright best practices, ranked by what ignoring them costs
Four practices carry most of the cost: keep shared state out of the gaps between tests, use assertions that retry, replace fixed sleeps with the condition you are waiting for, and pick locators a user could name. The rest can wait. Four more are genuinely contested, and a list that hides that has skipped the argument.
You have read the official best-practices page. Probably more than once. It did not change what you did on Monday, and the reason is not that it is wrong: everything on it is true, and nothing on it is ranked. Twenty-seven headings arrive at the same volume, you have one sprint, and the page cannot tell you which two of them your repository is currently paying for.
How should you rank Playwright best practices?
Two questions settle where an item goes. What happens when you ignore it, and how long before you find out.
A practice whose cost is "the suite lies to you and you cannot tell which result to believe" outranks one whose cost is "the run takes four minutes longer", because the second is a bill and the first is a broken instrument. And a practice whose cost arrives in eight months outranks one whose cost arrives in eight seconds. The fast one teaches you: you make the mistake, the test goes red, you stop making it. The slow one lets you go on making it.
That gives three tiers, and the rest of this page runs in that order. The four that decide whether the suite is worth running at all. The ones that cost time, money and patience, which are real and which can wait a sprint. And the ones you should not do yet, because doing them early is itself the maintenance. A fourth group sits outside the ranking, in a section of its own, because those arguments are still open.
Why does this test only fail in CI?
Practice 1: keep state out of the gaps between tests. It is first because it is the one that makes every other result unreadable.
The week goes like this. The spec passes on your laptop, every time. It passes in CI when you re-run it
on its own to check. It fails often enough in a full run for somebody to notice, and it is not
reliably the same spec that fails. Then somebody sets workers: 1, the problem goes
away, and that goes into the commit message as the fix. The suite is now serial and takes far
longer, and nothing about the bug has changed.
Browser state is not your problem. Playwright
gives each test its own BrowserContext, so cookies, local storage and session storage
start empty every time and cannot leak from one spec to the next — the
process and context model is built to make that true.
Everything that leaks lives outside the browser: a record one spec created and another
mutated, a file on disk, a user account with one shopping basket, or a variable at module scope
in a worker that runs many tests in sequence. Playwright's own parallelism guide names that last
one directly, in a section on keeping tests independent, and says such a test works while tests
run in order and breaks the moment they run in parallel or in a different order.
The version behind every sample here is Playwright 1.62, and a small local application was standing in for your product when each of them ran.
import { test, expect } from '@playwright/test';
test('adding an item puts it in the basket', async ({ page }) => {
await page.goto('/basket');
await page.getByRole('button', { name: 'Add Desk lamp' }).click();
await expect(page.getByRole('listitem')).toHaveText(['Desk lamp']);
});
test('checkout opens with an empty basket', async ({ page }) => {
await page.goto('/checkout');
await expect(page.getByTestId('line-items')).toHaveText('0 item(s)');
});
Playwright 1.62 · TypeScript · the second test passes alone and fails after the first
Neither test mentions the other, both would pass review, and neither declares a dependency, because there is none to declare — they share one seeded account. Run the second alone and it is green. Run the whole file:
Error: expect(locator).toHaveText(expected) failed
Locator: getByTestId('line-items')
Expected: "0 item(s)"
Received: "1 item(s)"
Timeout: 5000ms
Playwright 1.62 · what the runner printed for the second test
The failure is attributed to checkout. Checkout is fine. Turning on
fullyParallel, splitting the two specs into two files, or sharding across machines
all change which one loses, and none of them removes the collision.
The direction of the fix is short to say and long to do: each spec creates the state it needs and owns it, through an API call or a fixture that sets up and tears down around the test. Fixtures have their own page, which is where yours belongs.
Leave it, and a red run stops carrying information. Nobody can separate a real regression from an ordering artefact, so the team stops reading failures and starts re-running them, and from that morning on the suite is a tax rather than a test. Working out which specs in an existing suite are in this state is a procedure of its own, and it starts with reproducing the failure on purpose — diagnosing a flaky spec covers it.
Why did a test that passed for eight months start failing on a Tuesday?
Practice 2: assertions that retry, not booleans sampled once.
Picture the spec. It signs in and checks the dashboard greeting, and it was written like this:
expect(await page.getByText('Welcome back').isVisible()).toBe(true). It has passed
on every run since March. Then a routine dependency bump adds a couple of hundred milliseconds to
the first render, and the line starts failing perhaps one run in six. Nothing in that pull request
touched the login page, and nothing touched the test.
import { test, expect } from '@playwright/test';
test('the dashboard greets a signed-in user', async ({ page }) => {
await page.goto('/dashboard');
expect(await page.getByText('Welcome back').isVisible()).toBe(true);
});
test('the dashboard greets a signed-in user, web-first', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
});
Playwright 1.62 · TypeScript · against a greeting that renders 400ms late, the first fails and the second passes
Same locator, same intent, and the only difference is where the await sits. In
the first, it resolves the boolean before expect is ever called, so what gets
asserted is a value that stopped being about the page the instant it was read. There is nothing
left to retry. The second hands expect the locator itself, and the matcher
re-evaluates until the condition holds or the assertion times out, five seconds by default.
The failure is worth reading, because of what it does not say:
Error: expect(received).toBe(expected) // Object.is equality
Expected: true
Received: false
Playwright 1.62 · what the runner printed for the first test
No element, no locator, no mention of waiting. A developer triaging that has nothing to work from. Playwright's own best-practices page carries this exact pair as its good and bad example, so the guidance is not ours and is not new. Once the shape is right, picking the right matcher is the bigger skill.
The cost is in the delay. This failure does not arrive labelled as a bad assertion. It arrives as a bug report against the application, gets triaged by somebody who cannot reproduce it, gets marked flaky, and gets retried away. The eight months are the expensive part. A test that fails on the day you write it has taught you something. One that waits until you have four hundred like it has taught the whole team the wrong habit first.
Is page.waitForTimeout ever acceptable?
Practice 3: replace a fixed sleep with the condition you are waiting for. The question is phrased that way because the answer is not "never".
Start with the arithmetic, using your own two numbers: the length of the sleep, and how many specs import it. Three seconds across a hundred and forty specs is seven minutes per run spent doing nothing, whether the page was ready in eighty milliseconds or not.
And it still flakes, because CI is slower and busier than the laptop the three seconds was tuned on. The sleep does not make the test correct. It makes the test slow and almost correct, which is the worst combination available, because the slowness hides how often the flake is happening. The version that does real damage is the one somebody extracted into a helper.
import type { Page } from '@playwright/test';
// Added in 2023 so the order screen would stop failing. Nobody has touched it since.
export async function settle(page: Page) {
await page.waitForTimeout(3000);
}
tests/helpers/wait.ts · Playwright 1.62 · TypeScript
import { test, expect } from '@playwright/test';
import { settle } from './helpers/wait';
test('placing an order reaches the order page', async ({ page }) => {
await page.goto('/orders/new');
await page.getByRole('button', { name: 'Place order' }).click();
await settle(page);
await expect(page).toHaveURL(/\/orders\/\d+$/);
});
Playwright 1.62 · TypeScript · passes, in three seconds
The replacement waits for the thing the test is about, which is the order existing at a URL of its own:
import { test, expect } from '@playwright/test';
test('placing an order reaches the order page', async ({ page }) => {
await page.goto('/orders/new');
await page.getByRole('button', { name: 'Place order' }).click();
await page.waitForURL(/\/orders\/\d+$/);
await expect(page.getByRole('status')).toHaveText('Order placed');
});
Playwright 1.62 · TypeScript · passes, and finishes when the order does
There is a second-order failure underneath this, and it is the reason sleeps grow back after
somebody deletes them. A team that cannot get a click to land reaches for force: true, or for
dispatchEvent, and the click starts working. Playwright's auto-waiting guide
publishes the full matrix of which actionability checks run for which action, and six actions run
none of the five: blur, dispatchEvent,
focus, press, pressSequentially and
setInputFiles. The force option disables the non-essential checks on the
actions that do have them.
The suite has now switched off the waiting it is about to be blamed for not having. Sleeps go
in to cover for the removed checks, the run gets slower, and the next person to meet a stubborn
click reaches for force again. Getting out means putting the checks back and
asserting the state the action depends on. That is more work than a sleep on the day, and it is
the only version that ends.
All of which leaves the answer to the heading. A fixed sleep is a legitimate debugging tool, and a legitimate way to wait for something
genuinely time-based with no observable state — a banner that dismisses itself after five
seconds, a poll you do not control. It is not a way to wait for a page. Playwright's API
reference marks waitForTimeout as discouraged and says it should only be used for
debugging.
A reader will go looking for the source of all this, so be careful which page you land on. The
official best-practices page says nothing about it. It has no mention of waitForTimeout and no guidance against
fixed waits anywhere in its twenty-seven headings. The pages that carry the behaviour are the
auto-waiting guide and the assertions guide. Working out which of the several clocks ran out on a given failure is
a separate question.
Which locators survive a redesign?
Practice 4: locators a user could name. Lowest of the four, and it is fourth because its failure is loud. You find out inside one pull request, which makes it far cheaper than the three above.
The failure: a design-system bump renames a utility class. Every spec that leaned on that class goes red at once. The pull request that caused it contains no test files, so the first person to open the failures assumes the tests broke and starts repairing tests. Half a day goes before anybody reads the diff that did it.
A CSS class is a fact about the implementation, and it changes when the implementation changes. A role and an accessible name are facts about what the user sees, and they change when the product changes. Tests should break when the product changes. The documentation makes the same point with a chain of its own:
// 👎
page.locator('button.buttonIcon.episode-actions-later');
// 👍
page.getByRole('button', { name: 'submit' });
The documentation's own example, from the Playwright best-practices guide
One edge here is genuinely argued over, and it is narrow enough to belong in this section rather
than the one below: getByTestId. One camp holds that a test id is the only locator
under the team's own control, so it is the only one nobody can break by accident. The other holds
that a test id is a hook placed for the test rather than a fact about the product, so it can end
up on the wrong element and keep passing forever. The question that decides it for a given
element: would a user be able to find this control if the test id vanished? If yes, name
it the way they would. If no, the test id may be covering for a control nobody can reach.
Which of the built-in locators fits a particular element, and the repair for each way one can break, is a longer list than this one.
What is actually contested?
Check this before anybody quotes a guide at you. The official best-practices page takes no position on any of the four arguments below: the words "page object", "fixture" and "mocking" do not appear in its body at all. BrowserStack's guide, read the same day, numbers fifteen practices and puts "Adopt the Page Object Model and Reusable Components" third, with "Use Fixtures for Setup, Authentication, and Shared Utilities" fourth — two co-equal items on one list, where a large part of the community treats them as alternatives.
Each argument below gets both positions and the question that settles it for one team. None gets settled here.
The page object model, against fixtures and locators
The largest split in the community, and the one most often written down as settled advice. One side holds that fixtures and locators already provide what page objects were invented for, and that a class layer adds a file to edit without adding coverage. The other holds that a flow used by thirty specs should live in one place so that changing it is one edit. Both positions are held by people who ship suites that work. That is all this page will say about it: the argument in full is elsewhere, and it takes a side.
Where test data comes from
Through the user interface, through the API, or seeded straight into the database. The interface position: setup through the screens is itself a test, and it walks the path a customer walks, so a broken sign-up form ought to be loud. The API position: setup through the screens makes every spec depend on a screen it is not testing, so a broken sign-up form fails four hundred specs and the report blames four hundred features.
The question that decides it: when this setup step breaks, do you want to find out from this test? Playwright has already settled it for one case — sign in once in a setup project, reuse the saved storage state, and leave the login form to the test whose subject it is. For a suite starting from nothing, the tutorial argues a default, and test data as a subject has its own page.
How much of the network to replace
The documentation covers the mechanism thoroughly and stops there. Four sections on how:
intercepting a request with page.route() and answering it with
route.fulfill(), patching a real response through route.fetch(),
recording and replaying HAR files, and intercepting WebSockets. Not one sentence on how much.
That silence is where the argument lives, and it is a reasonable silence: the answer is a
property of your product, and the framework has no view on your product.
One position: mock everything you do not own and the suite becomes fast, deterministic and cheap to run on every commit. The other: mock everything you do not own and the suite stops noticing the day the integration breaks, which was one of the things it was bought for. The commercial guides are the ones that hedge here: BrowserStack's item on it is titled "Mock External Dependencies Carefully" and tells you to keep validating the real integrations where they matter. Ask which of those two failures you would rather learn about from this suite. The mechanics are a subject on their own.
Whether to assert on network calls
For: an assertion that the checkout request was actually sent, with the right body, catches a class of regression the interface hides, because the button still looks pressed and the confirmation still renders. Against: the best-practices page tells you to test user-visible behaviour and to avoid implementation details, and a request payload is arguably one.
Read that page closely before you settle it, though, because the three examples it gives of an implementation detail are the name of a function, whether something is an array, and the CSS class of an element. A network request is not on that list, and whether it belongs there is exactly the argument. What settles it for a given request: is it part of the promise you made to the user, or part of how that promise happens to be kept this quarter?
Which of these can you skip while the suite is small?
Reference documentation cannot tell one reader to ignore a section, which is why no documentation page will ever contain this list. Each item below is worth having eventually and worth nothing yet, and each carries the condition that changes the answer.
- Sharding across machines. Skip until one CI job is no longer enough. The question to ask: does a full run block a merge for longer than a code review takes?
- A custom reporter, or a test-management integration. Skip until somebody outside the team needs to read the results without asking you for them.
- Visual regression baselines. Skip until the design system is stable enough that a baseline survives a sprint. Before that the baselines are the maintenance, and updating them becomes a ritual nobody reads.
- A full page object layer. Skip until the same flow appears in enough specs that changing it is a chore. Whether to build one at all is the argument above.
- A data factory. Skip until test data setup is itself the thing that keeps breaking.
Running the same specs across Chromium, Firefox and WebKit stays off that list. It is one config entry, it catches engine-level breakage early, so turn it on. Write down what it covers while you do, because somebody will read the coverage matrix later: a WebKit project runs Playwright's own WebKit build on the machine running the tests, and that is not Safari on anybody's iPhone. A mobile project emulates a mobile browser: a viewport, a user agent, touch input, and never a native iOS or Android app.
When this bites you
Three habits, and underneath they are one. Each starts as a real instrument, turned on for a good reason, and ends up doing somebody's thinking for them.
Retries used as a fix rather than as an instrument
Failing tests are not retried by default, and turning retries on is right. Do the arithmetic on
what it hides, though. retries: 2 means two retries after the first attempt, so a
spec gets three goes at every run. A spec that fails one run in three has to miss all three, and
if those failures are independent that is one build in twenty-seven. The same defect that used
to go red on every third run now goes red on one in twenty-seven, which is rare enough to read
as noise and be re-run.
Playwright already hands you the instrument. Results come back as passed, flaky and failed, and the flaky count is the number to watch. A flaky count climbing while the pass rate stays green is a suite going bad quietly.
Timeouts raised until it stopped failing
A spec kept timing out, so somebody doubled its timeout, and it stopped. It works, and it is the cheapest thing in the file to do. The cost is that the new number has quietly become the specification: nobody agreed that this screen may take two minutes, and now the suite says it may. Green build, forty-minute run, no decision anywhere. Before changing any of the numbers, find out which of them ran out, because raising the wrong one changes nothing except the bill.
Debugging by re-running
A failure nobody can reproduce, on a machine nobody can log into, and the team's protocol becomes
the re-run. trace: 'on-first-retry' is the documented recommendation for CI and
npx playwright show-trace opens what it produces. A team that has not turned it on
has decided to investigate failures without evidence.
Reading the trace is a skill worth an afternoon.
One config carries all three, and 1.62 adds a dial:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
workers: process.env.CI ? 2 : undefined,
retries: process.env.CI ? 2 : 0,
retryStrategy: 'isolated', // Playwright 1.62: retries run at the end, one at a time
use: {
trace: 'on-first-retry',
},
});
playwright.config.ts · Playwright 1.62 · TypeScript
retryStrategy is new in 1.62 and it changes when a retry runs, not whether one
does. The default interleaves each retry back into the suite still running around it;
'isolated' holds them all until everything else has finished, then walks them
through a single worker. You get a re-run with the machine to itself and you pay on the clock,
which is a trade worth making while you suspect the environment. Look it up in the release notes
or the TestConfig reference — the retries guide has not picked it up yet. Two lines
up, workers left alone is half the machine's logical CPU cores, which is why the
same suite behaves differently on your laptop and on a runner.
A list tells you what to look for and cannot tell you how many you have. Twelve of your three hundred specs might depend on the order they run in, or eighty might, and no article can read your repository. Where that count is the thing a decision is waiting on, a playwright test suite audit is an engineer of ours reading the suite and the pipeline and writing down what is in them.
Questions
Are the official Playwright best practices enough?
They are correct and they are the right place to start. What they are built for is reference, so the twenty-seven headings on that page all carry the same weight, and a team with one sprint needs an order rather than a set. Read it, then decide which two of its items your repository is paying for. The ordering is the part nobody can write for every reader at once, which is why this page argues one.
Is page.waitForTimeout really that bad?
It is fine while you are debugging, and fine for something genuinely time-based with no observable state to wait on. It is wrong as a way to wait for a page, because the number was a guess about one machine on one day: the suite gets slower on every run and still fails on the runs where the guess was short. Playwright's API reference marks waitForTimeout as discouraged and says it should only be used for debugging. Cite the right page for it: the official best-practices guide does not mention waitForTimeout at all and gives no guidance against fixed waits. The pages that do are the auto-waiting guide and the assertions guide.
Is the page object model a best practice or not?
It is the item on every list that should not be stated flatly, because the community is genuinely split on it. One camp holds that locators and fixtures already do the job page objects were invented for, so a class layer adds a file to edit without adding coverage. The other holds that a flow thirty specs repeat should have one name and one home. Both positions are held by people shipping suites that work. We do have a position on it. The page that owns the question is where it is argued out, at the length it needs.
Which one should I fix first?
Test isolation. It is the only item whose cost is that you cannot trust any of the other results: once specs depend on the order they ran in, a red run stops telling you whether the product broke, and every other improvement you make is measured on an instrument you have stopped believing.
How many specs, and what does the pipeline run them on?
A spec count and where the pipeline runs them are enough for a first conversation about a suite you have stopped believing, and they are the first things we ask. Send them, and say which of the items above you already recognised in your own repository. If you would rather skip the reading and start on the work, that is available too: the audit is optional and no engagement here waits on one.