Playwright best practices: locators waits flaky tests
A Confession Before We Start
This is a long, opinionated walk through Playwright best practices — specifically the three that cause the most pain when they’re gotten wrong: locators, waits, and flaky test prevention. Three years ago I inherited a Playwright suite with 340 tests. On a good day, 12 of them failed for no reason anyone could explain. On a bad day, it was closer to 40. The team’s solution, by the time I showed up, was a Slack bot that auto-retried the entire pipeline up to three times before anyone even looked at the report. Nobody trusted red builds anymore. Nobody trusted green ones either, honestly — they just trusted “did it eventually turn green.”
It took me about six weeks to get that suite down to near-zero unexplained failures, and almost none of what fixed it was clever. It was locators. It was waits. It was a handful of habits that, once you actually understand why they work, stop feeling like rules and start feeling obvious.
That’s what this post is. Not a reference manual — Playwright’s own docs are good, go read them if you want the API surface. This is the version of the conversation I’d have with you over coffee if you asked me “why do my tests keep breaking,” and I had two hours and no reason to hold back.
Part 1 — Locators Are Not What You Think They Are
Locator strategy is where most Playwright best practices conversations should start, and it’s usually where they get skipped. Here’s the thing that changes everything once it clicks: a Playwright locator isn’t a pointer to an element. It’s a promise to go find one, made fresh every time you ask it to act. I know that sounds like a small distinction. It isn’t.
Selenium’s findElement hands you a frozen reference — a snapshot of one specific DOM node at one specific moment. React re-renders that node (which it does constantly, for reasons that have nothing to do with your test), and now you’re holding a reference to a ghost. StaleElementReferenceException. Half of every Selenium suite I’ve ever worked on had some kind of retry wrapper built specifically to survive this one exception.
Playwright just… doesn’t do that. When you write page.locator('button[type="submit"]'), nothing gets searched yet. You’ve written a description, not a query result. The actual DOM lookup happens the instant you call .click() on it — and if you call another action on that same locator ten seconds later, it searches again, against whatever the DOM looks like right now.
const submitButton = page.locator('button[type="submit"]');
// nothing has happened yet — this line does zero DOM work
await submitButton.click();
// THIS is when Playwright goes looking, right before it clicks
Once that lands, a lot of other things stop feeling like magic and start feeling like consequences. Chaining locators works because each link in the chain is just another lazy description. Auto-waiting works because there’s a natural moment — right before the action — to check whether the element is actually ready. It’s all downstream of this one design choice.
How I Actually Pick a Locator
I’ll give you my honest priority order, the one I use on real apps with messy component libraries and inconsistent frontend conventions — not the idealized version you’d get from a docs page.
getByRole first, almost always. This is the one people skip, and it’s the one that pays off the most. It reads the accessibility tree — the same structure a screen reader uses — instead of raw markup. When your frontend team swaps a hand-rolled div-button for a proper MUI Button component (and they will, eventually, probably during a redesign nobody warns QA about), the class names and DOM structure change completely. The accessible role stays “button.” The accessible name stays whatever the visible label says. Your test doesn’t even notice.
await page.getByRole('textbox', { name: 'Email address' }).fill('ajit@example.com');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
Read that test out loud. It sounds like a person describing what they’re doing, not like code negotiating with a DOM tree. That’s not a stylistic accident — that’s the entire point of role-based locators.
getByLabel for forms, before you reach for anything else. If a field has a proper <label> tied to it, this is your best option, full stop. And here’s a side benefit nobody mentions enough: when getByLabel fails to find a field, half the time it’s not your test’s fault — it’s because someone shipped an input with a broken or missing label association. I’ve turned two of these “test failures” into actual accessibility bug tickets on real projects. The test wasn’t wrong. It caught something real.
getByTestId when the markup is a mess or the widget is custom. There’s a strange purism floating around some corners of the testing world that treats data-testid as cheating. I don’t buy it. If you’re testing a drag-and-drop kanban board, a canvas chart, or some heavily animated custom dropdown that nobody bothered to make accessible, fighting for a clean role locator is a waste of an afternoon. Ask the dev team for a data-testid. It’s a contract: “this identifier will not move, no matter what CSS chaos happens around it.” I push every team I work with to adopt this for anything genuinely dynamic.
// playwright.config.ts — if your team prefers a different attribute name
export default defineConfig({
use: {
testIdAttribute: 'data-qa',
},
});
getByText, with a warning label attached. It’s intuitive and it works well for static content — but two things bite people constantly. First, it does substring matching by default, so if your page has both “Save” and “Save as Draft,” getByText('Save') matches both, and Playwright’s strict mode will (correctly) throw an error rather than silently guessing which one you meant.
await page.getByText('Save').click(); // ❌ ambiguous, strict mode throws
await page.getByRole('button', { name: 'Save', exact: true }).click(); // ✅
Second, and this one’s sneakier: text locators quietly rot the moment your app adds localization, or the moment a button starts showing a live count like “Add to Cart (3).” I’ve seen a perfectly good test start failing three sprints after it was written, for no reason anyone remembered, because a “(2)” got tacked onto a button label. Nobody touched the test. The app just changed underneath it.
CSS selectors, in moderation. I’m not a purist about this — a tight, structural CSS selector is sometimes genuinely the cleanest tool for the job.
// fine — scoped, structural, not fighting the framework
page.locator('.product-card').filter({ hasText: 'Wireless Mouse' });
// not fine — hashed CSS-in-JS classes that regenerate on every build
page.locator('.css-a8f3k2j');
// also not fine — positional, breaks the moment markup nesting shifts by one level
page.locator('div > div:nth-child(3) > span.MuiTypography-root');
I inherited a suite once where nearly half the locators were exactly this second kind — copy-pasted straight out of Chrome DevTools’ “Copy selector” button. Every single frontend deploy broke a dozen tests, for reasons that had absolutely nothing to do with actual regressions. It’s a special kind of demoralizing, watching your CI go red on a release day because someone’s styled-components hash rotated.
XPath, only when I’ve genuinely run out of options. I used to lean on it heavily in my Selenium years, and I still respect what it can do — navigating sideways to siblings, selecting a parent by a child’s content, things CSS just can’t express. But in Playwright, it costs you readability (nobody wants to mentally parse //div[@class="actions"]//button[contains(text(),"Delete")] in a code review), and it’s usually more fragile against markup changes than the alternative. Most of the time, .filter({ has: ... }) gets you the same result without the XPath baggage:
const overdueRow = page.locator('tr').filter({ hasText: 'Overdue' });
await overdueRow.getByRole('button', { name: 'Send Reminder' }).click();
Chaining and Filtering — This Is the Part People Sleep On
Say you’re on a product listing page with fifty nearly identical “Add to Cart” buttons, and you need the one attached to “Mechanical Keyboard” specifically. You could try to build one horrifying CSS selector to nail that down in one shot. Or you could just narrow the scope step by step, the way you’d actually describe it to a colleague.
const productCard = page.locator('.product-card').filter({
has: page.getByRole('heading', { name: 'Mechanical Keyboard' }),
});
await productCard.getByRole('button', { name: 'Add to Cart' }).click();
If the card markup changes later, only the outer piece needs revisiting. Each layer is small enough to reason about on its own, which matters more than it sounds like it should when you’re the third person to touch this file eight months from now and have zero memory of writing it.
page.locator('.notification').filter({ hasText: 'Payment failed' });
page.locator('.task-row').filter({ hasNotText: 'Archived' });
page.locator('.data-table tbody tr').first();
page.locator('.data-table tbody tr').last();
page.locator('.data-table tbody tr').nth(1);
Quick word of caution on .nth() — position in a dynamically sorted or filtered list is one of the most common sources of flakiness I run into. A table that re-sorts after async data lands means “row 2” during your test run might not be “row 2” a heartbeat later. Reach for content-based filtering instead whenever your data lets you.
Loops — Where Almost Everyone New to Playwright Trips
The instinct coming from other frameworks is to loop with an index and repeatedly call .nth(i). Fine for read-only checks. For anything that interacts with elements and might trigger a re-render, be a bit more careful:
// read-only — fine to grab a frozen array
const rows = await page.locator('.data-table tbody tr').all();
for (const row of rows) {
console.log(await row.textContent());
}
// interacting — re-query against the LIVE locator each time,
// don't trust a snapshot taken before the loop started
const checkboxCount = await page.locator('.task-checkbox').count();
for (let i = 0; i < checkboxCount; i++) {
await page.locator('.task-checkbox').nth(i).check();
}
If checking one box causes completed items to jump to the bottom of the list, a frozen array captured before the loop began might now be pointing at positions that don’t mean what they used to. Re-querying each time keeps you honest.
Iframes and Shadow DOM, Without the Ceremony
Coming from Selenium, both of these used to involve a lot of stateful switching that was easy to forget and easy to mess up. Playwright handles both more gracefully.
const paymentFrame = page.frameLocator('#payment-iframe');
await paymentFrame.getByLabel('Card Number').fill('4242424242424242');
// no "switch back" required — page-level locators still just work
await page.getByRole('button', { name: 'Pay Now' }).click();
There’s no global “current frame” you can forget to reset, which used to be responsible for a whole genre of Selenium bugs where a later step failed because an earlier step never switched context back.
Shadow DOM is quietly handled too — Playwright’s locators pierce open shadow roots without any special syntax at all.
<my-date-picker>
#shadow-root (open)
<button>Select date</button>
</my-date-picker>
// just works, no shadow-piercing syntax needed
await page.getByRole('button', { name: 'Select date' }).click();
Closed shadow roots are a different animal — intentionally sealed off, and no tool can reliably reach in without cooperation from the app itself. If you’re dealing with Web Components (Lit, Stencil), it’s worth asking the dev team early whether roots are open or closed. It’ll save you a confusing afternoon of staring at a locator that should work and doesn’t.
Part 2 — Waiting: Where Flakiness Is Actually Born
If you only remember one sentence from this entire article, make it this: Playwright auto-waits for actionability before almost every action, and that removes the need for explicit waits in the overwhelming majority of everyday interactions. Not “reduces.” Removes. Of every Playwright best practice covered in this piece, understanding auto-waiting properly is probably the one with the highest immediate payoff.
Selenium made you think about waiting constantly — implicit waits, explicit WebDriverWait chains, ExpectedConditions you had to memorize. Skip it, and Selenium would happily try to click something the instant it existed in the DOM, whether or not it was visible, enabled, or done moving. Playwright flips that entirely. Before a .click(), a .fill(), a .check(), it runs through a checklist automatically: is the element attached, visible, stable (not mid-animation), not covered by something else, enabled, and (for fills) editable. It waits, up to your configured timeout, for all of that to be true.
Read Playwright’s actionability documentation once, properly, even if you’ve been writing Playwright tests for a year. Knowing exactly what auto-waiting checks tells you exactly what it doesn’t — and that gap is where the real flaky-test knowledge lives.
What Auto-Waiting Won’t Save You From
It handles whether a specific element is ready for a specific action, right now. It has no idea whether your API call finished. It doesn’t know your navigation has fully settled. It has zero concept of your business logic — a spinner can vanish while the data underneath it is still stale, and auto-waiting will happily let you click on stale data because, structurally, nothing was stopping it.
Web-First Assertions Are the Other Half of This Story
If auto-waiting on actions is one pillar, retrying assertions are the other, and honestly I think they matter even more, because they cover the “did the expected thing actually happen” side, not just “can I click this.”
// retries automatically until true or timeout — this is the one you want
await expect(page.getByText('Order confirmed')).toBeVisible();
// checks ONCE, right now, no retry — avoid this for anything async
const text = await page.getByText('Order confirmed').textContent();
expect(text).toBe('Order confirmed');
This distinction — retrying expect(locator).toXxx() versus a plain expect() on a manually-pulled value — has personally been the source of more “works on my machine, fails in CI” tickets than anything else I’ve investigated. On a fast local machine, the condition might already be true by the time your line runs. On a loaded CI runner, it isn’t yet, and a non-retrying assertion has no way to wait for it. There’s no cleverness required to fix this — just the discipline of always reaching for the retrying form when the thing you’re checking is async.
await expect(locator).toBeVisible();
await expect(locator).toBeEnabled();
await expect(locator).toHaveText('Exact text');
await expect(locator).toContainText('Partial text');
await expect(locator).toHaveValue('input value');
await expect(locator).toHaveCount(5);
await expect(page).toHaveURL(/\/dashboard/);
waitForLoadState — And Why I Almost Never Use Its Most Popular Option
Three states, and the names sound more magical than they are. load fires once the page and its resources — images, stylesheets, subframes — are done. domcontentloaded fires earlier, once the initial HTML is parsed, without waiting on those extras. networkidle waits for at least 500ms of network silence.
That last one gets recommended everywhere as a general “wait for the page to settle” hammer, and I’d actively steer you away from it. Modern single-page apps rarely go fully quiet — analytics pings, websocket heartbeats, notification polling, all humming in the background indefinitely. networkidle is waiting for a silence that may simply never arrive within your timeout, or that arrives at a wildly inconsistent point depending on unrelated chatter that has nothing to do with what you’re actually testing. Playwright’s own team has said as much publicly.
What I actually do these days: I basically never call waitForLoadState() explicitly. I let page.goto() handle its own default wait, and for everything after that, I wait on something specific and meaningful — an element appearing, a particular response resolving — instead of a generic “is the network quiet” guess.
waitForResponse — Probably the Single Most Underused Tool in the Whole API
This is the one I’d tell you to learn first if you only had time for one. Instead of guessing how long an API call takes and padding with buffer, you wait for the actual call to resolve.
test('search results load after query', async ({ page }) => {
await page.goto('https://example.com/products');
const [response] = await Promise.all([
page.waitForResponse((res) => res.url().includes('/api/search') && res.status() === 200),
(async () => {
await page.getByPlaceholder('Search products').fill('mechanical keyboard');
await page.getByRole('button', { name: 'Search' }).click();
})(),
]);
const data = await response.json();
expect(data.results.length).toBeGreaterThan(0);
await expect(page.getByTestId('search-results')).toContainText('Mechanical Keyboard');
});
Notice the wait is registered before the click, wrapped together with Promise.all. This isn’t stylistic — it closes a real race condition. If you set up waitForResponse after the click, there’s a genuine, if small, window where the response already resolved before your wait even started listening, and you’ll hang until timeout waiting for something that already happened. This exact race is one of the sneakier “sometimes flaky, sometimes not” bugs, because it depends on timing that’s inherently variable and gets worse under CI resource contention, not better.
expect.poll — For Everything That Isn’t a DOM Element or a Network Call
Sometimes what you’re actually waiting on is a database record, a file landing on disk, a background job’s status. Neither locators nor network waits apply. expect.poll is the general-purpose “keep checking until true” tool for exactly this.
await expect
.poll(
async () => {
const record = await getExportJobStatusFromDb(jobId);
return record.status;
},
{ timeout: 30000, intervals: [1000, 2000, 5000] }
)
.toBe('completed');
The staggered intervals are a nice detail — poll fast at first for quick jobs, back off for longer-running ones, instead of hammering a database at a fixed interval for 30 straight seconds.
Let’s Talk About page.waitForTimeout()
I want to be direct here because I still see this constantly in suites I inherit, and I get exactly why — it’s the fastest possible fix when something’s failing intermittently and you’re on a deadline. But it’s a trade. You’re swapping a visible, honest flaky test for an invisible, dishonest slow one.
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForTimeout(3000); // "should be enough" — narrator: it wasn't, eventually
await expect(page.getByText('Success')).toBeVisible();
3000ms is a guess. On your machine, the real operation might take 400ms, so you’re burning 2.6 seconds every single run — and this pattern rarely stays in one place, it gets copy-pasted, and those seconds add up across a full suite into real, wasted CI minutes every day. On a busy CI runner during release week, the same thing might take 3200ms, and now your “fix” fails again anyway, because a fixed number was never actually addressing the variable thing underneath it.
The one legitimate use I’ll grant it: temporary, local debugging, while you figure out what you should actually be waiting for. It shouldn’t survive to a commit. Playwright’s own docs are candid that tests relying on it will be flaky, and every time I flag this in review, my question back is the same: what are you actually waiting for? There’s almost always a real, nameable answer.
Spinners — A Pattern So Universal It Deserves Its Own Section
await page.goto('https://example.com/dashboard');
await expect(page.getByTestId('loading-spinner')).toBeHidden({ timeout: 15000 });
await expect(page.getByTestId('user-stats-panel')).toBeVisible();
await expect(page.getByText('Total Revenue')).toBeVisible();
The part that catches people: sometimes a spinner appears and disappears fast enough — a cached response, say — that your test never actually observes the “visible” state at all. That’s fine. You don’t need proof the spinner existed. You need proof it’s gone before you trust whatever’s underneath it, because that’s the real precondition for your next assertion meaning anything.
Timeouts, at the Right Layer
export default defineConfig({
timeout: 30000,
expect: { timeout: 5000 },
use: {
actionTimeout: 10000,
navigationTimeout: 15000,
},
});
Override per-call when you have an actual, understood reason — a report generation button that genuinely takes 20–40 seconds on staging, say — not as a blanket fix for “the suite feels flaky.”
await page.getByRole('button', { name: 'Generate Report' }).click({ timeout: 5000 });
await expect(page.getByText('Report ready')).toBeVisible({ timeout: 60000 });
Blanket-raising every timeout across the whole suite doesn’t fix root causes — it just delays the moment a real bug shows up as a failure, and slows your pipeline down for no benefit.
Part 3 — Flaky Tests: What’s Actually Going On
Flaky test prevention is arguably the whole reason most teams go searching for Playwright best practices in the first place — nobody starts this research because their tests are too reliable. Let me be precise about a word people use loosely. A flaky test fails and passes across repeated runs against the exact same code, with no real change in application behavior — the inconsistency lives in the test or its environment, not the app. That’s different from an intermittent bug, where the app itself genuinely behaves inconsistently and your test is correctly, faithfully reporting it.
Conflating these two leads teams to the wrong fix every time. If you slap a retry on a test that’s honestly reporting a real race condition in your app’s own code, you haven’t fixed anything — you’ve hidden a production bug behind test tooling. Before applying any fix below, ask yourself first: is this the test’s fault, or the app’s? I’ll come back to how the trace viewer actually answers that question.
Race Conditions Between UI State and Your Test
By far the most common thing I run into. The test acts a beat before the app has actually finished whatever async work makes that action meaningful.
await page.getByLabel('Email').fill('ajit@example.com');
await page.getByRole('button', { name: 'Save' }).click(); // might land while debounced validation is mid-flight
await page.getByLabel('Email').fill('ajit@example.com');
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
await page.getByRole('button', { name: 'Save' }).click();
Technically .click()‘s own actionability check already waits for “enabled,” so this can look redundant. I still write it out explicitly, because the failure message changes from a generic click-timeout to “timed out waiting for button to be enabled” — and that’s the difference between a five-second diagnosis and a twenty-minute one.
Animations
Playwright’s “stability” check already handles a lot of this — it won’t click something mid-animation. The gaps show up with slower CSS transitions or JS-driven animation libraries (Framer Motion, GSAP) that its native check doesn’t fully track.
const modal = page.getByRole('dialog', { name: 'Confirm Deletion' });
await expect(modal).toBeVisible();
await expect(modal).toHaveClass(/modal--entered/); // if the animation lib exposes a completion class
await modal.getByRole('button', { name: 'Confirm' }).click();
If you have any pull with the frontend team, ask for a testable “animation complete” signal — a class, a data-state attribute. I frame this conversation as “this also helps you debug your own animation callbacks,” and it tends to land a lot better than “make my tests easier.”
Shared State Between Tests
This is architectural, and it produces the nastiest kind of flakiness — a test that’s rock solid in isolation and unpredictable the moment it runs alongside everything else.
// assumes "first invoice in the list" is always this test's invoice —
// breaks the second tests run in parallel against shared data
await page.locator('.invoice-row').first().getByRole('button', { name: 'Delete' }).click();
test('user can delete an invoice', async ({ page, request }) => {
const invoiceRef = `TEST-INV-${Date.now()}`;
await request.post('/api/invoices', { data: { reference: invoiceRef, amount: 500 } });
await page.goto('/invoices');
const invoiceRow = page.locator('.invoice-row').filter({ hasText: invoiceRef });
await invoiceRow.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Invoice deleted')).toBeVisible();
await expect(invoiceRow).toBeHidden();
});
Two changes, both load-bearing on their own. The test creates uniquely identifiable data instead of trusting position, and it sets that data up via a direct API call rather than clicking through the UI first — faster, and it removes an entire category of flakiness from setup alone. In code review, “does this test create and clean up its own state” is close to a hard requirement for me now. It’s the decision that determines whether a suite scales to hundreds of parallel tests, or turns into a house of cards where test order secretly matters and nobody remembers why.
Fixed Data in Shared Environments
Related, but worth its own callout: a test that assumes “the user with this email already has 3 orders” in a shared staging environment is gambling on data that drifts — someone runs a manual test, a nightly job resets things, another team’s automation touches the same rows. Where I can, every test provisions and tears down its own data. Where a full seeded dataset is genuinely unavoidable, at minimum use unique identifiers so parallel runs stop colliding on the same records.
Third-Party Dependencies
If your app calls out to a real payment gateway or external API during test runs, you’ve inherited that service’s reliability as your own. That’s rarely worth it outside a handful of deliberate end-to-end smoke tests. Mock it.
await page.route('**/api/payments/charge', async (route) => {
await route.fulfill({
status: 402,
contentType: 'application/json',
body: JSON.stringify({ error: 'card_declined', message: 'Your card was declined.' }),
});
});
Now the test is fully deterministic. No more mornings where 15 tests are red because a sandbox payment environment somewhere is having a bad day — I’ve lived that particular morning more than once, and it’s never fun explaining to a release manager that the failures are “probably the vendor.”
Parallel Execution Collisions
Playwright runs in parallel by default, which is great for speed and merciless about exposing any hidden assumption of exclusivity. Watch for multiple workers logging into the same account, tests writing to the same file path, or tests touching a shared global resource that another test’s assertions depend on.
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 4 : undefined,
});
// escape hatch for genuinely coupled tests — not a default
test.describe.configure({ mode: 'serial' });
I treat every use of serial mode as a flag, not a solution — a note-to-self that there’s a real isolation problem worth fixing properly at some point, even if today isn’t that day.
CI Resource Constraints, and How I Actually Handle Retries
export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
use: {
trace: process.env.CI ? 'on-first-retry' : 'off',
video: process.env.CI ? 'retain-on-failure' : 'off',
},
});
I want to be careful with this one, because “just add retries in CI” can quietly become the same anti-pattern as waitForTimeout() if you use it to mask real problems instead of absorbing genuine noise. My actual policy is 2 retries in CI, purely as a buffer for a resource-starved runner having a bad moment, and zero locally so developers see real failures immediately during development. And retries get tracked, not ignored. On teams I’ve led, we keep a visible log of which tests actually consume retries over time. Needing one once a month is fine — that’s real noise. Needing one every third run means there’s a real problem, and it goes into the backlog to get fixed, not left to quietly eat CI minutes forever.
expect.soft — Seeing All the Damage at Once
Less about preventing flakiness directly, more about not misdiagnosing a real, consistent bug AS flakiness because the failure signal was too murky to read clearly.
await expect.soft(page.getByText('Order #12345')).toBeVisible();
await expect.soft(page.getByText('Total: $149.99')).toBeVisible();
await expect.soft(page.getByText('Estimated delivery: 3-5 days')).toBeVisible();
await expect.soft(page.getByRole('button', { name: 'Track Order' })).toBeVisible();
Without soft assertions, the first failure stops the test cold — you fix it, re-run, discover the second one also fails, fix that, re-run again. A slow drip across multiple CI runs. With soft assertions, you see every real problem in one pass, which matters a lot when a CI run isn’t instant and you’re trying to clean up a batch of related regressions efficiently.
The Trace Viewer — My Actual Process, Step by Step
This is the part I think most “best practices” posts skip in favor of vague advice, so let me walk through what I genuinely do when a test fails intermittently.
export default defineConfig({
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
});
First, I open the trace (npx playwright show-trace trace.zip). It gives me a full scrubbable timeline — every action, every network request, DOM snapshots before and after each step, console logs, all of it. Second, I look right at the moment of failure. Was the element genuinely absent? Was it there but covered by a toast or a sticky header? Was a request still pending that the UI was silently waiting on? Third, if I have a passing run’s trace to compare, that’s where the real diagnosis lives — if the sequence of network calls differs (say, an extra retry on the app’s own side adding 800ms), that’s a genuine timing dependency to handle explicitly, not random noise. Fourth, I sort the failure into one of three buckets: a real, intermittent application bug (file it, don’t touch the test), a test design flaw (fix it properly, using whatever’s covered above), or genuine environmental noise (leave it, note it, watch if it becomes frequent).
The trace viewer is, without exaggeration, the biggest quality-of-life jump I’ve had in debugging compared to my Selenium years, where chasing an intermittent failure usually meant sprinkling print statements and hoping to catch it happening again. If you haven’t gone deep on the trace viewer docs, it’s worth the hour.
Files: Uploads and Downloads
const fileInput = page.getByLabel('Upload profile picture');
await fileInput.setInputFiles('./fixtures/test-avatar.png');
await expect(page.getByText('Upload successful')).toBeVisible({ timeout: 10000 });
// downloads — register the listener BEFORE the triggering click, same
// race-condition logic as waitForResponse
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Download PDF' }).click(),
]);
expect(download.suggestedFilename()).toBe('invoice-12345.pdf');
Dates and Timezones — The Bug That Waits Months to Show Up
This category is sneaky specifically because it stays invisible for a long time — until a date boundary, a daylight saving shift, or a CI runner sitting in a different timezone than your laptop finally exposes it.
const today = new Date().toLocaleDateString(); // means something different everywhere, every time
await expect(page.getByText(`Report for ${today}`)).toBeVisible();
await page.clock.setFixedTime(new Date('2026-08-09T09:00:00'));
await page.goto('/dashboard');
await expect(page.getByText('Report for 08/09/2026')).toBeVisible();
Playwright’s clock API is genuinely underused for exactly this. It lets you pin the browser’s notion of “now,” so a date-dependent test becomes fully reproducible no matter when or where it actually runs. I’d also set your CI runner’s timezone explicitly rather than trusting whatever default the provider happens to use — that default changes without warning, and you won’t find out until something breaks silently.
A Real Before/After
This is adapted from an actual checkout test I inherited that failed roughly 1 in 6 runs in CI. Seeing the full transformation matters more than isolated snippets, so here’s the whole thing.
// BEFORE — ~15% failure rate
test('user can complete checkout', async ({ page }) => {
await page.goto('/products');
await page.click('.product-card:nth-child(1) .add-to-cart-btn');
await page.waitForTimeout(1000);
await page.click('#cart-icon');
await page.waitForTimeout(500);
await page.click('.checkout-btn');
await page.waitForTimeout(2000);
await page.fill('#card-number', '4242424242424242');
await page.fill('#expiry', '12/28');
await page.fill('#cvv', '123');
await page.click('.pay-now-btn');
await page.waitForTimeout(3000);
const successText = await page.textContent('.confirmation-message');
expect(successText).toContain('Order confirmed');
});
// AFTER — 0 failures across 200+ CI runs since the rewrite
test('user can complete checkout', async ({ page, request }) => {
const product = await createTestProduct(request, { name: 'Test Widget', price: 29.99 });
await page.goto('/products');
const productCard = page.locator('.product-card').filter({ hasText: product.name });
await productCard.getByRole('button', { name: 'Add to Cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page.getByRole('heading', { name: 'Your Cart' })).toBeVisible();
await page.getByRole('button', { name: 'Proceed to Checkout' }).click();
await expect(page.getByRole('heading', { name: 'Payment Details' })).toBeVisible();
const paymentFrame = page.frameLocator('#payment-iframe');
await paymentFrame.getByLabel('Card Number').fill('4242424242424242');
await paymentFrame.getByLabel('Expiry Date').fill('12/28');
await paymentFrame.getByLabel('CVV').fill('123');
const [chargeResponse] = await Promise.all([
page.waitForResponse((res) => res.url().includes('/api/payments/charge') && res.ok()),
page.getByRole('button', { name: 'Pay Now' }).click(),
]);
expect(chargeResponse.status()).toBe(200);
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible({ timeout: 15000 });
await expect(page.getByText(product.name)).toBeVisible();
await deleteTestProduct(request, product.id);
});
Every change here maps to something covered above — role-based locators over brittle CSS, API-driven setup over shared catalog state, retrying assertions over guessed waits, explicit network correlation for the payment call, and cleanup so the test doesn’t leave junk behind for the next run to trip over. None of these is exotic on its own. The value is applying all of it, consistently, as a habit — not as a fix you reach for only after something breaks.
Part 4 — What I Actually Check in Code Review
This isn’t a formal gate, just what I scan for mentally on every PR that touches test code — the same three buckets of Playwright best practices this whole article keeps circling back to: locators, waits, and isolation.
On locators: role, label, or test-id before CSS. Any XPath that could become a .filter(). Any hashed CSS-in-JS class names. Any .nth() against a list whose order could plausibly shift. Would this survive a component library migration?
On waiting: any waitForTimeout() in the diff, and if so, what’s the real condition hiding underneath it. Are assertions using the retrying expect(locator).toXxx() form. Is a network wait registered before its triggering action. Is networkidle being used somewhere it doesn’t need to be.
On isolation: does this test create and clean up its own data. Would it survive five copies of itself running in parallel against the same environment. Are third-party calls mocked. If serial mode was needed, is there a tracked follow-up to fix the real isolation problem. Is there a trace or video captured on failure so a future investigation actually has something to work with?
Part 5 — The Debugging Tools I Actually Reach For
I mentioned the trace viewer earlier, and I stand by everything I said about it, but it’s not the only tool in the box, and if you only ever open a trace after a CI failure, you’re missing the tools that stop a bad locator or a race condition from ever reaching CI in the first place. Let me walk through what’s actually on my desktop when I’m writing a new test, not just debugging a broken one.
Playwright Inspector — Slower Than You’d Like, More Honest Than Anything Else
When I’m writing a test against a page I don’t know well, I don’t start by guessing selectors and running the whole suite to see what breaks. I run the test with the inspector attached and step through it one action at a time.
npx playwright test checkout.spec.ts --debug
This opens a browser window alongside a small control panel — step, resume, and a live view of the actionability checks Playwright is running before each action. That last part is the genuinely useful bit. If a click is hanging, the inspector shows you exactly which actionability condition it’s stuck waiting on — element found, but not visible; or visible, but not stable; or stable, but covered by something else. That’s information you’d otherwise have to reconstruct from a timeout error message and a screenshot, and reconstructing it takes ten times longer than just watching it happen live.
I also use page.pause() constantly during initial test-writing, dropped directly into the test body at the point I’m unsure about:
test('user can apply a discount code', async ({ page }) => {
await page.goto('/cart');
await page.getByRole('button', { name: 'Apply Discount' }).click();
await page.pause(); // execution freezes here, inspector opens, I explore the live DOM
await page.getByLabel('Discount code').fill('SAVE20');
});
When execution hits page.pause(), everything stops — the browser stays open, exactly as it was, and I can use the inspector’s “pick locator” tool to click on any element and have it generate the actual locator Playwright would use, ranked by preference (it defaults to suggesting role and text locators first, which lines up nicely with everything in Part 1). This has saved me more guessing-and-checking cycles than any other single habit in my workflow. I write the skeleton of a test, drop a pause where I’m not sure what the right locator is, run it, click around in the real browser, and copy exactly what the inspector suggests. Then I remove the pause before committing.
UI Mode — What I Actually Run Day to Day
If the inspector is my tool for writing a single new test carefully, UI mode is what I have open in a second monitor basically all day while doing broader suite work.
npx playwright test --ui
It gives you a live, filterable list of every test, watch-mode re-runs on file save, a timeline scrubber for each run that’s essentially a lighter-weight trace viewer built directly into the workflow, and — this is the part I actually rely on most — the ability to pick any single test or any single describe block and run just that, instantly, without touching the CLI at all. When I’m iterating on a flaky test fix, I’m not running the whole suite fifteen times to confirm the fix holds. I’m running that one test fifteen times in UI mode, watching the timeline each time, and comparing runs directly against each other without leaving the window.
There’s also a “locator” tab inside UI mode that lets you type a selector and see, live, what it matches on the current page state — genuinely useful when you’re trying to figure out why a locator that looks correct on paper is matching zero elements, or three when you expected one.
VS Code Extension — Locators Without Leaving Your Editor
If your team uses VS Code (most of the teams I’ve worked with do, at least for the automation repo specifically, even if the app itself is built with a different stack), the official Playwright extension adds a “Pick locator” button directly in the editor gutter. You click it, your browser opens to whatever URL the test navigates to, you click the element you care about, and the generated locator gets inserted at your cursor. It’s a small thing, but it removes the context-switching cost of jumping between DevTools, a terminal, and your editor just to write one line of test code. For a team writing dozens of new tests a sprint, that adds up to real time saved, even if it doesn’t feel dramatic in any single instance.
Console and Network Tabs Inside a Trace — Don’t Skip Past Them
When I open a trace to investigate a failure, I don’t only look at the action timeline and DOM snapshots. The trace viewer also captures browser console output and the full network waterfall for the run, and I’ve solved more flaky-test mysteries by reading a console warning than by staring at DOM diffs. A JavaScript error thrown mid-render, a React key warning suggesting a list re-render happened at an inconvenient moment, a 429 rate-limit response on an API call that the UI silently swallowed and retried — all of this shows up in those tabs, and none of it shows up if you only glance at the screenshot and the pass/fail status.
My actual habit now: any time I’m investigating something genuinely confusing — not the obvious “locator didn’t match” cases, but the ones where everything looks like it should have worked — I open the console tab in the trace before I do anything else. More often than I expected going in, the real story is sitting right there in a warning nobody was looking at.
Part 6 — Cross-Browser Flakiness: The Category Everyone Forgets Until It Bites Them
Most teams write and validate their Playwright tests against Chromium, because that’s the default in a fresh playwright.config.ts and because Chromium behaves the most “normally” of the three engines Playwright supports. Then someone enables the Firefox and WebKit projects for broader coverage — usually right before a release, usually because a stakeholder asked “do we actually test Safari” — and a chunk of the suite turns red for reasons that have nothing to do with the application being broken in those browsers.
Where WebKit Actually Differs
WebKit (Playwright’s Safari-equivalent engine) has genuinely different timing characteristics around a few things that matter for automation specifically, not just for general page rendering.
File input behavior. I’ve hit cases where setInputFiles() resolves at a slightly different point in WebKit’s internal file-handling pipeline compared to Chromium, meaning an assertion that checks for an uploaded file’s preview appearing immediately after the call needs a touch more patience on WebKit specifically. The fix isn’t a blanket wait — it’s usually a web-first assertion with a slightly longer timeout scoped to that one interaction, applied conditionally based on the browser project.
test('uploaded file preview renders', async ({ page, browserName }) => {
await page.getByLabel('Upload document').setInputFiles('./fixtures/sample.pdf');
const previewTimeout = browserName === 'webkit' ? 8000 : 4000;
await expect(page.getByTestId('file-preview')).toBeVisible({ timeout: previewTimeout });
});
I don’t love conditional logic branching on browserName scattered through a suite — it’s a maintenance smell if it spreads too far — but for a small number of genuinely engine-specific timing quirks, it’s more honest than padding every browser’s timeout to cover the slowest one’s worst case.
Focus and blur event timing. Form validation that triggers on blur can fire at a subtly different point in the event sequence on WebKit versus Chromium, particularly around programmatic focus changes triggered by JavaScript rather than a real click. If you’ve got a test that fills a field, expects a validation message to disappear, and it flakes specifically on the WebKit project in CI while Chromium is rock solid, this is very often the actual cause. The fix, again, isn’t a longer generic wait — it’s usually replacing an implicit assumption about immediate blur-validation with an explicit web-first assertion on the validation message’s visibility state, which absorbs the timing difference naturally because it retries regardless of engine.
Where Firefox Differs
Firefox’s rendering engine handles certain CSS animation and layout-shift timing differently enough that Playwright’s built-in “stability” actionability check (the one that waits for an element’s bounding box to stop moving across frames) occasionally needs a longer window to register stability on Firefox for animation-heavy UI, particularly custom dropdowns and modals using non-native CSS transitions rather than the platform’s built-in ones.
The practical guidance mirrors what I said in Part 3 about animations generally: if you can get the frontend team to expose a clean “transition complete” signal — a class toggle, a data attribute — you sidestep engine-specific stability timing entirely, because you’re no longer relying on Playwright’s generic geometric stability heuristic at all. You’re waiting on an explicit signal the app itself commits to, and that signal behaves identically regardless of which engine is rendering it.
My Actual Cross-Browser Strategy (Not Everything Runs Everywhere)
I want to push back gently on a default a lot of teams fall into: running the entire suite against all three engines, every single CI run, because the config template made it easy to add projects for chromium, firefox, and webkit and nobody questioned it after that.
// playwright.config.ts — a tiering approach I actually use
export default defineConfig({
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
What I actually recommend, and what I’ve implemented on more than one team: full suite runs on Chromium on every PR, because it’s fast and it catches the overwhelming majority of real regressions. Firefox and WebKit run against a curated subset — the critical user journeys, not every edge-case test — either nightly or on a merge to main, not on every single PR. This isn’t about cutting corners on cross-browser coverage; it’s about being honest that most flakiness differences between engines live in a small number of genuinely engine-sensitive interactions (file uploads, custom animations, certain form validation timing), and running your entire 400-test suite three times over on every PR to catch issues concentrated in maybe fifteen of those tests is a poor trade against CI time and, ironically, against team trust in cross-browser failures specifically, because a bloated cross-browser run makes every WebKit-specific flake feel like “WebKit is just flaky” noise that gets ignored, instead of the smaller set of genuine signal it actually is.
Part 7 — Locators Inside a Page Object Model, Done Without Fighting Playwright’s Grain
If you’ve read my Page Object Model post on this blog, you already know I’m a fan of POM as an organizational pattern — not because it’s fashionable, but because it gives a growing suite a place to put locator definitions once instead of scattered across forty test files. What I want to add here, specifically in the context of this post, is how locator strategy and POM interact, because I’ve seen POM implementations that accidentally reintroduce exactly the brittleness this whole article is trying to talk you out of.
The Mistake: Storing Resolved Locators Instead of Locator Factories
Here’s a version of a page object I see constantly from teams new to both Playwright and POM together, and it looks reasonable at first glance:
// ❌ Locators resolved once in the constructor
export class CheckoutPage {
readonly page: Page;
readonly payButton: Locator;
readonly cardNumberField: Locator;
constructor(page: Page) {
this.page = page;
this.payButton = page.getByRole('button', { name: 'Pay Now' });
this.cardNumberField = page.frameLocator('#payment-iframe').getByLabel('Card Number');
}
}
This actually works fine in Playwright specifically — remember, locators are lazy descriptions, not resolved element references, so storing them as class properties doesn’t reintroduce the Selenium staleness problem the way it would if this were a WebElement. I want to be precise about that, because I don’t want to overstate the danger here. But it does quietly reintroduce a different problem: every locator on the page object gets constructed at instantiation time, whether or not that part of the page even exists yet for the current test scenario, and it makes conditional or dynamic locator construction — filtering by a variable, chaining based on test-specific data — awkward to express cleanly within the class.
What I Actually Do Instead: Locator-Returning Methods
// ✅ Methods that build locators on demand, composable and testable in isolation
export class CheckoutPage {
constructor(private readonly page: Page) {}
payButton() {
return this.page.getByRole('button', { name: 'Pay Now' });
}
paymentFrame() {
return this.page.frameLocator('#payment-iframe');
}
cardNumberField() {
return this.paymentFrame().getByLabel('Card Number');
}
// parameterized — this is where the method approach really earns its keep
invoiceRow(invoiceRef: string) {
return this.page.locator('.invoice-row').filter({ hasText: invoiceRef });
}
productCard(productName: string) {
return this.page.locator('.product-card').filter({
has: this.page.getByRole('heading', { name: productName }),
});
}
}
The difference matters most in that last example. A property-based locator can’t take a parameter — you’d end up writing a separate property for every product name you ever need to test against, which is obviously unworkable, so teams doing property-based POM usually fall back to raw page.locator() calls scattered directly in test files whenever they need anything parameterized, which defeats the entire purpose of centralizing locator logic in the first place. A method-based approach doesn’t have this problem at all — checkoutPage.invoiceRow('TEST-INV-12345') reads naturally in a test, stays centralized in the page object, and composes with everything else in Part 1 of this article without any friction.
Where Web-First Assertions Live in a POM Structure
One more question I get asked constantly by teams adopting both patterns together: should assertions live inside page object methods, or in the test file? My answer, consistently: locators and pure actions belong in the page object; assertions belong in the test file, or in narrowly-scoped helper methods that make their expectation obvious from the name.
export class CheckoutPage {
constructor(private readonly page: Page) {}
async payWithCard(cardNumber: string, expiry: string, cvv: string) {
const frame = this.paymentFrame();
await frame.getByLabel('Card Number').fill(cardNumber);
await frame.getByLabel('Expiry Date').fill(expiry);
await frame.getByLabel('CVV').fill(cvv);
await this.payButton().click();
}
// a named expectation helper is fine — it reads clearly, unlike a
// generic assertion buried three layers deep in a page object
async expectOrderConfirmed() {
await expect(this.page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible({ timeout: 15000 });
}
}
// in the test file
test('checkout completes successfully', async ({ page }) => {
const checkout = new CheckoutPage(page);
await checkout.payWithCard('4242424242424242', '12/28', '123');
await checkout.expectOrderConfirmed();
});
I’ve seen the opposite extreme too — teams that bury dozens of raw, unnamed assertions inside page object action methods, so a test file reads as three innocent-looking method calls, but a failure could be coming from any of fifteen different expect() statements hidden two files away. When that happens, debugging a failure means opening the page object source just to figure out what actually got asserted, which defeats a lot of the readability that role-based locators and clean test structure are supposed to buy you in the first place. Named expectation helpers, like expectOrderConfirmed() above, give you the best of both — the assertion logic stays centralized and reusable, but the test file still tells you exactly what’s being checked without a detour.
Part 8 — Visual Regression Testing and the Special Flakiness It Invites
If you’re running Playwright’s screenshot comparison (toHaveScreenshot()) alongside your functional suite — and if you’ve read my dedicated post on Playwright visual regression testing, you know I think it’s worth doing — it’s worth calling out that visual assertions introduce an entirely different category of flakiness that everything covered so far doesn’t fully address, because the thing being compared isn’t “did this text appear” but “does every single pixel in this region match, within tolerance.”
The Usual Suspects for Visual Flakiness
Dynamic content inside the captured region. A timestamp, a relative “2 minutes ago” label, a randomly-rotating promotional banner, a user’s own avatar pulled from a live CDN — any of these sitting inside your screenshot’s bounding box will cause a pixel diff on every single run, even when the actual layout and design are completely unchanged. The fix is masking, not disabling the check:
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [page.getByTestId('last-updated-timestamp'), page.getByTestId('user-avatar')],
maxDiffPixelRatio: 0.02,
});
Masking replaces the specified region with a solid block before comparison, so genuinely dynamic content stops contributing noise to the diff, while everything else on the page still gets compared with full precision. maxDiffPixelRatio is your other lever — a small tolerance absorbs genuine sub-pixel antialiasing differences between runs without absorbing so much tolerance that a real visual regression slips through unnoticed.
Font rendering differences between local machines and CI. This one causes more confusion than almost anything else in visual regression testing, because it looks exactly like a real bug on first glance — text sits a pixel or two differently, line-wrapping shifts slightly — but it’s actually just font rendering varying between your local OS’s font rasterizer and whatever’s installed on the CI image. The fix isn’t a bigger tolerance (that just masks real text-related regressions too). The fix is generating your baseline screenshots on the same environment that will run comparisons — meaning inside the CI container itself, or inside a matching Docker image locally, not on your laptop directly.
# generate baselines using the official Playwright Docker image, # matching whatever CI actually runs against docker run --rm -v $(pwd):/work -w /work mcr.microsoft.com/playwright:v1.48.0-jammy \ npx playwright test --update-snapshots
I learned this one the hard way on a real project — a designer flagged what looked like a legitimate 2px misalignment bug from a visual regression failure, we spent half a day investigating a “real” bug that turned out to be nothing more than Ubuntu’s font rasterizer rendering slightly differently than macOS’s. Generating baselines inside the same container CI uses eliminated the entire category of false positive going forward.
Animation and transition state at capture time. The same actionability “stability” concept from Part 3 applies here, but visual regression is even less forgiving — a screenshot taken one frame into a fade-in transition looks completely different from one taken after it completes, and unlike a click (which just needs the element roughly stable), a pixel comparison needs it to be in the exact same state every single time.
// disable CSS animations globally for visual regression runs specifically —
// I keep this in a separate project config, not the main functional suite
export default defineConfig({
projects: [
{
name: 'visual-regression',
testMatch: /.*\.visual\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
},
},
],
});
// in a global setup or fixture for the visual project
await page.addStyleTag({
content: `*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }`,
});
Killing animation duration globally for visual-regression-specific test runs removes the timing variable entirely rather than trying to catch the “right” frame consistently, which in my experience is nearly impossible to do reliably across different machine speeds.
Part 9 — Mobile Emulation and the Locator Quirks Nobody Warns You About
A good chunk of QATribe’s traffic comes from readers doing web-app testing specifically, but if your app has any responsive or mobile-web surface, Playwright’s device emulation is genuinely good — good enough that teams sometimes skip real device testing entirely for early-stage validation, which is a separate conversation, but it does mean the locator and waiting concerns from earlier in this post pick up a few mobile-specific wrinkles worth naming directly.
Touch Targets and Visibility Thresholds
Playwright’s visibility actionability check considers an element visible if it has a non-empty bounding box and isn’t hidden by CSS — but on a mobile viewport, an element can technically satisfy that check while being positioned entirely outside the visible viewport, below the fold, requiring a scroll before a real user (or a real tap) could reach it. Desktop viewports are wide and forgiving enough that this rarely surfaces as an issue; narrow mobile viewports expose it constantly, especially on pages with long forms or bottom-sheet style modals.
test('mobile checkout flow', async ({ page }) => {
// Playwright's click already auto-scrolls the element into view before
// acting, which handles most of this automatically — but for assertions
// specifically, be deliberate about it
const payButton = page.getByRole('button', { name: 'Pay Now' });
await payButton.scrollIntoViewIfNeeded();
await expect(payButton).toBeVisible();
await payButton.click();
});
Click and fill actions handle scrolling into view automatically as part of their own actionability sequence, so this is less of a concern for the actions themselves. It becomes a real issue specifically when you’re asserting visibility as a standalone check — say, confirming a sticky bottom bar appears — where an element can be technically “visible” by CSS definition while sitting well outside the current scroll position, and your assertion passes even though a real user on a real device would never have seen it without scrolling.
Viewport-Dependent Component Swaps
A pattern I run into constantly on responsive apps: the same logical UI element renders as a completely different DOM structure depending on viewport — a desktop navigation bar becomes a hamburger-triggered mobile drawer, a desktop data table becomes stacked mobile cards. If your locator strategy leans on role and accessible name (which, per Part 1, it should), this transition is usually painless, because the accessible role and name of “the navigation menu” or “the search button” tend to stay consistent even when the underlying markup changes dramatically between breakpoints.
Where it gets genuinely messy is when a team’s mobile and desktop views were built by different people, at different times, with inconsistent accessible naming between them — a “Menu” button on desktop and a “Navigation” button on mobile, functionally identical, but named differently enough that a shared test written against one label breaks silently on the other viewport. This isn’t really a Playwright problem to solve technically; it’s a signal to raise with the frontend team, because inconsistent accessible naming across breakpoints is an accessibility gap independent of your test suite, and fixing it benefits actual users navigating with assistive technology on mobile, not just your automation.
// playwright.config.ts — separate mobile project with its own viewport
export default defineConfig({
projects: [
{ name: 'desktop-chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
],
});
Part 10 — Interview-Style Questions on Locators, Waits, and Flaky Tests
QA and SDET interviews lean heavily on exactly this trio of topics, because it’s where the gap between “knows the syntax” and “has actually maintained a suite under real pressure” shows up fastest. These are the questions I ask when I’m interviewing candidates for automation-heavy roles, and the ones I’d expect to be asked myself.
Locator Questions
Why does Playwright recommend getByRole over CSS selectors, and are there situations where CSS is actually the better choice? getByRole ties tests to accessibility semantics, which tend to survive markup and styling refactors because the underlying role and accessible name rarely change even when the DOM structure around them does. CSS is still the right call for structural relationships that don’t map cleanly to ARIA roles — targeting a specific container by a stable semantic class, for instance — as long as the selector avoids auto-generated or positional fragility.
What’s the difference between page.locator() and page.$() (or the older ElementHandle API), and why does it matter for flakiness? page.locator() is lazily evaluated and re-resolves the DOM on every action, which is inherently resistant to stale references after re-renders. ElementHandle-based APIs return a resolved reference to a specific DOM node at the moment of the call, which can go stale exactly the way Selenium’s WebElement does if the page re-renders afterward. Modern Playwright code should essentially never reach for ElementHandle-based patterns for this reason.
How would you locate the third row in a table, and why might that be a risky approach? .nth(2) against the row locator gets you there mechanically, but it’s risky whenever the table’s row order can change — sorting, filtering, or asynchronous insertion could all shift which row is actually “third” between the moment your test starts and the moment the assertion runs. Content-based filtering — locating a row by a value it contains — is more resilient whenever the underlying data allows it.
Explain what “strict mode” means in Playwright and why it exists. By default, most locator actions throw an error if the locator matches more than one element, rather than silently acting on the first match. It exists specifically to surface ambiguous locators immediately, as a loud failure at the moment you write the test, instead of a silent wrong-element interaction that might not get noticed until it causes a confusing downstream failure much later.
How does Playwright handle Shadow DOM, and what’s the difference between open and closed shadow roots for automation purposes? Playwright’s locators automatically pierce open shadow roots without any special syntax. Closed shadow roots are intentionally inaccessible from outside JavaScript by design, and no automation tool — Playwright included — can reliably reach into one without cooperation from the application, usually via explicitly exposed test hooks.
Waiting and Timing Questions
What exactly does Playwright’s auto-waiting check before a click, and what does it not check? It verifies the element is attached, visible, stable across frames, not obscured by another element, and enabled. It does not know anything about your application’s business logic — an in-flight API call, business-rule validity of currently displayed data, or anything not expressible as a DOM-level actionability condition.
Why is expect(locator).toBeVisible() considered more reliable than fetching textContent and comparing it with a plain assertion? The locator-based assertion retries automatically until the condition is true or a timeout is reached. A plain assertion on a manually-extracted value checks exactly once, at the instant it runs, with no retry mechanism — meaning it can fail simply because the check ran a moment too early relative to an async update, even though the condition would have become true a beat later.
Why is networkidle discouraged as a general-purpose readiness signal on modern web apps? Single-page applications frequently maintain background network activity — analytics beacons, websocket connections, polling — that never fully goes quiet. Waiting for network silence on such an app risks waiting for a condition that may not occur within a reasonable timeout, or that resolves inconsistently depending on unrelated background traffic that has nothing to do with the actual state under test.
Describe the race condition risk in calling waitForResponse() after triggering the action that causes the request, and how to avoid it. If the wait is registered after the triggering click, there’s a window in which the response could already have resolved before the listener started watching for it, leading the wait to hang until timeout for an event that already happened. Wrapping the wait and the trigger together with Promise.all registers the listener and fires the action concurrently, closing that window.
When would you reach for expect.poll() instead of a web-first locator assertion? When the condition you’re waiting on isn’t expressible as a DOM locator state at all — a database record’s status, a value returned from a custom function, a file’s presence on disk. expect.poll() provides the same retry-until-true mechanism as a web-first assertion, but for arbitrary conditions outside the page.
Flaky Test Questions
How do you distinguish a genuinely flaky test from a test correctly reporting a real intermittent bug in the application? By comparing traces (or logs) from a passing and a failing run of the same test. If the application’s own behavior genuinely differs between runs — different network timing, different data returned, a visible race condition in the app’s own async logic — that’s a real, intermittent application bug worth filing, not a test defect. If the app’s behavior is consistent and only the test’s assumptions about timing or shared state vary, the defect is in the test.
What risk does blindly increasing retry counts introduce into a CI pipeline? Retries configured without tracking can quietly mask genuine, worsening test or application reliability issues behind an apparent green build, since a test that needs several retries to eventually pass looks identical in a basic pass/fail report to one that passed cleanly on the first attempt. Retry counts need to be tracked over time so chronically-retrying tests get flagged and investigated rather than permanently absorbed.
Why is shared test data across parallel test runs a common source of flakiness, and how do you address it structurally? When multiple tests read or mutate the same underlying records concurrently, one test’s side effects can invalidate another’s assumptions about current state, producing failures that depend on execution order or worker scheduling rather than any real defect. The structural fix is having each test provision its own uniquely-identified data (commonly via direct API calls rather than UI-driven setup) and clean it up afterward, so no test depends on state another test happens to have left behind.
Why might mocking third-party API calls improve both test reliability and test speed? A test that depends on a real third-party service’s uptime and response time inherits that service’s reliability characteristics as its own, introducing failure modes entirely outside the team’s control and outside the actual application logic under test. Mocking the call with page.route() removes that dependency, making the test deterministic with respect to the scenario being tested and typically much faster, since no real network round-trip to an external system is required.
What’s the danger of using page.waitForTimeout() as a fix for a flaky test, even if it appears to resolve the immediate failure? A fixed wait duration is a guess about how long an async operation takes, and that guess is rarely accurate across different environments and load conditions — it wastes time when the real operation is faster than the guess, and still fails intermittently when the real operation is occasionally slower than the guess. It also obscures the actual condition the test needed to wait for, making the code harder to reason about and harder to fix properly later.
Part 11 — Two More Case Studies From Different Corners of a Real Stack
The checkout rewrite earlier in this piece is the flashy one — payment flows tend to be. But most of the flaky tests I actually fix in a given month aren’t payment flows. They’re data tables, form validation, and permission-gated UI. Here are two more, from domains closer to what a lot of you reading this actually work on day to day.
Case Study: A BFSI-Style Transaction Data Table
This one’s adapted from a wealth management dashboard — a paginated, sortable, filterable table of account transactions, the kind of UI that shows up constantly in BFSI products and that I’ve personally debugged variations of at more than one employer.
// BEFORE — flaky roughly 1 in 8 runs, and worse under parallel execution
test('user can filter transactions by type', async ({ page }) => {
await page.goto('/accounts/12345/transactions');
await page.click('#filter-dropdown');
await page.click('text=Withdrawals');
await page.waitForTimeout(1500);
const rows = await page.$$('.transaction-row');
expect(rows.length).toBeGreaterThan(0);
const firstRowText = await rows[0].textContent();
expect(firstRowText).toContain('Withdrawal');
});
Several compounding problems here, and it’s worth naming all of them because in real inherited code they rarely show up alone. The fixed fifteen-hundred-millisecond wait assumes the filter’s backing API call always resolves within that window — fine on a fast staging environment, not fine once real transaction volume made that same query noticeably slower in a load-tested pre-production environment, which is exactly where this test started failing more often. The ElementHandle-based $$ query is a resolved snapshot, not a lazy locator, so if the table re-renders between the query and reading rows[0]‘s content — which a live-filtering table absolutely can do — the reference can point at stale or shifted content. And asserting against “the first row” assumes a specific sort order that isn’t actually guaranteed to be true after every filter operation on every environment.
// AFTER — stable across 150+ runs on the load-tested environment
test('user can filter transactions by type', async ({ page }) => {
await page.goto('/accounts/12345/transactions');
const [filterResponse] = await Promise.all([
page.waitForResponse((res) => res.url().includes('/api/transactions') && res.ok()),
(async () => {
await page.getByRole('button', { name: 'Filter' }).click();
await page.getByRole('option', { name: 'Withdrawals' }).click();
})(),
]);
const data = await filterResponse.json();
expect(data.transactions.length).toBeGreaterThan(0);
const rows = page.locator('.transaction-row');
await expect(rows.first()).toBeVisible();
// Assert every visible row matches the filter, not just an assumed "first" one —
// this is a stronger, more meaningful check than the original test ever made
const count = await rows.count();
for (let i = 0; i < count; i++) {
await expect(rows.nth(i)).toContainText('Withdrawal');
}
});
Worth noting the improvement here isn’t purely a flakiness fix — the rewritten version is also a genuinely stronger test than the original. The original only checked that the first row matched the filter; a bug that let a non-withdrawal transaction slip into row two would have passed silently. The rewrite checks every visible row. Fixing flakiness and improving actual test coverage aren’t always the same task, but in my experience they show up together more often than not, because a rushed original test and a flaky original test tend to come from the same root cause: not enough thought given to what’s actually being guaranteed at each step.
Case Study: A Healthcare-Style Multi-Step Form With Conditional Fields
This one’s adapted from a patient intake form — the kind with conditional fields that appear or disappear based on earlier answers, common in healthcare products and genuinely one of the trickier UI patterns to automate reliably, because the DOM is actively changing shape as the user progresses.
// BEFORE — fails intermittently, especially in CI, roughly 1 in 10 runs
test('conditional allergy field appears and can be filled', async ({ page }) => {
await page.goto('/intake/new');
await page.click('#has-allergies-yes');
await page.waitForTimeout(500);
await page.fill('#allergy-details', 'Penicillin');
await page.click('#continue-btn');
await page.waitForTimeout(1000);
await page.click('#submit-btn');
const confirmation = await page.textContent('.confirmation-banner');
expect(confirmation).toContain('submitted');
});
The fixed waits here are standing in for two genuinely different things happening under the hood — a conditional field mounting into the DOM after a radio button selection, and a multi-step form transitioning between steps, likely with some client-side validation running in between. Treating both with the same blunt five-hundred-and-thousand-millisecond guesses means the test’s reliability is entirely dependent on those two very different operations both happening to finish inside their respective guessed windows, on every environment, forever.
// AFTER — stable, and each step's wait is tied to what's actually happening
test('conditional allergy field appears and can be filled', async ({ page }) => {
await page.goto('/intake/new');
await page.getByRole('radio', { name: 'Yes, I have allergies' }).check();
// wait for the actual conditional field to mount and be ready, not a guessed duration
const allergyField = page.getByLabel('Please describe your allergies');
await expect(allergyField).toBeVisible();
await allergyField.fill('Penicillin');
await page.getByRole('button', { name: 'Continue' }).click();
// wait for the step transition by asserting the NEXT step's heading appeared —
// this is a real signal, not a guess about transition duration
await expect(page.getByRole('heading', { name: 'Review Your Information' })).toBeVisible();
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByRole('alert')).toContainText('submitted', { timeout: 10000 });
});
Two things worth calling out beyond the general wait-replacement pattern you’ve already seen a few times in this article by now. First, switching from a raw CSS ID selector to getByRole('radio', ...) and getByLabel(...) means this test reads as an actual description of what a patient filling out the form would experience, which matters more than usual for healthcare-adjacent UI where accessibility compliance tends to be a genuine legal and ethical requirement, not just a nice-to-have — a test suite built on accessible locators is, as a side effect, quietly asserting that the accessible structure exists at all, which is worth something on its own. Second, the confirmation assertion switched from a generic .confirmation-banner class to getByRole('alert'), which assumes the confirmation message is marked up with an appropriate ARIA role — if it isn’t, that’s worth raising with the frontend team as its own finding, separate from the test fix itself, because a submission confirmation that isn’t announced to screen readers is a real gap for any user relying on assistive technology.
Part 12 — A Short Word on Team Culture, Because Tooling Alone Doesn’t Fix Flaky Suites
I want to end this expanded version with something that isn’t code, because I’ve watched teams implement every single technical pattern in this article correctly and still end up with a suite people don’t trust, for reasons that have nothing to do with locators or waits.
The technical fixes in this post get you a suite that’s capable of being reliable. Whether it actually stays reliable comes down to what a team does the moment a test goes red. If the habit is “re-run the pipeline and see if it goes green,” that habit will eventually erase every bit of the discipline covered above, one small compromise at a time, because it removes the pressure that makes anyone actually go investigate a real timing issue before it compounds into three more. If the habit is “a red build gets looked at, categorized as a real bug, a test defect, or genuine noise, and handled accordingly before merging,” the suite stays healthy indefinitely, because nothing rotten gets to hide.
I’ve said some version of this to every team I’ve led, and I’ll say it here too: a flaky test isn’t an annoyance to route around. It’s a signal, and it’s telling you something true about either your application or your test — sometimes both. The entire point of everything in this article is to give you the tools to actually hear what it’s saying, instead of turning the volume down with a retry and moving on.
Part 13 — CI/CD Configuration That Actually Supports What We’ve Been Talking About
Everything in this article so far assumes a certain amount of CI maturity underneath it — sharding, artifact retention, sensible parallelism — and I want to stop assuming and actually show it, because I’ve watched teams get every single locator and waiting pattern right and still bleed trust in their pipeline because the CI configuration around those tests was fighting them the whole time.
GitHub Actions — The Setup I Actually Run
This is close to verbatim what I’ve shipped on more than one real project, trimmed slightly for clarity.
# .github/workflows/playwright.yml
name: Playwright Tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('package-lock.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Run tests (shard ${{ matrix.shard }}/4)
run: npx playwright test --shard=${{ matrix.shard }}/4
env:
CI: true
- name: Upload trace on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces-shard-${{ matrix.shard }}
path: test-results/
retention-days: 7
merge-reports:
if: always()
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- uses: actions/download-artifact@v4
with:
pattern: playwright-traces-shard-*
path: all-results
merge-multiple: true
- run: npx playwright merge-reports --reporter html ./all-results
- uses: actions/upload-artifact@v4
with:
name: merged-html-report
path: playwright-report/
retention-days: 14
A few decisions in here that I’d defend specifically. Sharding across four parallel jobs, rather than one job running the whole suite serially, is the single biggest lever for pipeline speed on any suite past a few hundred tests — and it’s also, not coincidentally, the thing that most aggressively surfaces hidden test-isolation problems, because a test that quietly depended on running right after another specific test will now land on a completely different shard and fail in a way that has nothing to do with the change in your PR. I consider that a feature, not a bug in the CI config — it’s forcing the exact kind of isolation discipline covered in Part 3.
Browser caching matters more than people expect on a suite that runs dozens of times a day — reinstalling Chromium’s binary on every single CI run adds real, compounding minutes across a busy week that add up to genuine cost, both in literal CI billing and in how long a contributor waits for feedback on their PR.
Uploading traces only on failure, with a bounded retention window, keeps artifact storage from ballooning while still giving you the actual diagnostic data — the trace, the video, the screenshot — for every failure that lands in CI, which is the raw material the whole trace-viewer workflow from Part 3 depends on. If you don’t capture this, your ability to distinguish a real bug from test flakiness collapses back to “well, it passed when I re-ran it,” which is exactly the trust-eroding pattern this whole article is trying to get you away from.
Jenkins — For Teams on a More Traditional Stack
A fair number of BFSI and healthcare organizations I’ve worked with are still on Jenkins, often for reasons that have nothing to do with technical preference and everything to do with existing infrastructure, compliance requirements around self-hosted CI, or a long-standing enterprise agreement nobody wants to unwind. Here’s a Jenkinsfile shape that mirrors the same principles.
// Jenkinsfile
pipeline {
agent { docker { image 'mcr.microsoft.com/playwright:v1.48.0-jammy' } }
environment {
CI = 'true'
}
stages {
stage('Install') {
steps {
sh 'npm ci'
}
}
stage('Test') {
parallel {
stage('Shard 1') {
steps { sh 'npx playwright test --shard=1/4' }
}
stage('Shard 2') {
steps { sh 'npx playwright test --shard=2/4' }
}
stage('Shard 3') {
steps { sh 'npx playwright test --shard=3/4' }
}
stage('Shard 4') {
steps { sh 'npx playwright test --shard=4/4' }
}
}
}
}
post {
failure {
archiveArtifacts artifacts: 'test-results/**', allowEmptyArchive: true
}
always {
publishHTML(target: [
reportDir: 'playwright-report',
reportFiles: 'index.html',
reportName: 'Playwright Report'
])
}
}
}
Running directly inside the official Playwright Docker image is worth calling out specifically, because it sidesteps an entire category of “works in GitHub Actions, fails on our Jenkins runner” problems caused by system-level dependency mismatches — missing font packages, mismatched glibc versions, whatever it happens to be that week. I’ve spent entire afternoons chasing exactly this class of environment drift on self-hosted Jenkins agents that were never quite kept in sync with what Playwright’s browsers actually expect at the OS level, and pinning to the official image removes the problem at the root rather than patching around it forever.
Sharding Strategy — Getting the Split Actually Even
Playwright’s built-in --shard flag splits tests by count, not by historical duration, which means four shards with a wildly uneven mix of fast unit-adjacent tests and slow, heavy end-to-end flows can leave one shard finishing in ninety seconds while another takes twelve minutes — and your pipeline’s total wall-clock time is bottlenecked by the slowest shard, not the average.
// playwright.config.ts — Playwright can use a previous run's timing
// data to balance shards more evenly than a naive count-based split
export default defineConfig({
reporter: [
['blob'], // blob reports carry timing data forward for shard balancing
['html'],
],
});
For suites where shard imbalance is a real, measured problem — not just a hunch — I’ve had good results tagging the genuinely slow, heavy tests explicitly and deliberately spreading them across shards by hand, rather than trusting an automatic count-based split to happen to land them evenly. It’s a small amount of manual curation that pays for itself the first time a release deadline is riding on CI finishing inside a specific window.
Part 14 — Accessibility Testing: A Natural Extension of Everything in Part 1
If you’ve been following the locator philosophy in this article — role first, label second, test-id for genuinely custom widgets — you’ve already been building tests that quietly depend on your application having decent accessibility semantics. It’s a short, natural step from there to actually asserting on accessibility directly, and I think teams that have already internalized role-based locators are unusually well positioned to add this without much additional learning curve.
Wiring Up axe-core
npm install --save-dev @axe-core/playwright
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('checkout page has no critical accessibility violations', async ({ page }) => {
await page.goto('/checkout');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
The first time you run this against a real production page, expect a long list. That’s normal, and it’s not a reason to abandon the check — it’s a reason to triage. I don’t recommend gating a build on zero violations from day one; that turns a genuinely useful signal into a wall nobody can get past, and teams route around walls by disabling the check entirely rather than fixing the underlying issues. What I actually do instead is start by asserting against a specific, prioritized subset — critical and serious severity only, on the handful of pages that matter most (checkout, account creation, anything with a legal or compliance dimension) — and expand the scope over time as violations get triaged and fixed.
test('checkout page has no critical or serious a11y violations', async ({ page }) => {
await page.goto('/checkout');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
const criticalViolations = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious'
);
expect(criticalViolations).toEqual([]);
});
Where This Intersects With Everything Already in This Article
Here’s the connection I actually want to draw, because it’s not just “here’s a bonus tool.” Every time a getByRole or getByLabel locator fails to find an element because the underlying markup is missing a proper role or label association, that’s an accessibility finding hiding inside what looks like a flaky or broken test. I mentioned this in Part 1 with the two real bug tickets that came out of failed getByLabel calls — this section is really just formalizing that same instinct into an explicit, scheduled check rather than something you stumble into occasionally while debugging an unrelated locator failure.
For an app in the BFSI or healthcare space specifically — and I know a good chunk of you reading this work in exactly those domains — accessibility compliance often has genuine legal weight behind it (WCAG conformance requirements tied to regulatory obligations, not just good practice), which changes the conversation with stakeholders from “nice to have” to “risk we need visibility into,” and a scheduled axe-core scan across your critical flows gives leadership exactly that visibility in a form that’s easy to report on.
Scanning Specific Components, Not Just Full Pages
Full-page scans are useful for a baseline, but for genuinely useful, actionable findings, scoping the scan to a specific component after a state change catches things a full-page scan buried in noise from unrelated parts of the page might obscure.
test('newly opened modal has no accessibility violations', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Delete Account' }).click();
const modal = page.getByRole('dialog', { name: 'Confirm Deletion' });
await expect(modal).toBeVisible();
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.analyze();
expect(results.violations).toEqual([]);
});
Modals in particular are a common source of real accessibility gaps — missing focus trapping, missing labelled-by associations between the dialog and its heading, missing keyboard-dismissal support — and they tend to be built and modified more frequently than the rest of an app’s chrome, so scanning them specifically, at the moment they open, catches regressions close to where they’re introduced rather than buried in a full-page scan someone might not read carefully.
Part 15 — API and UI Testing as One Coherent Strategy, Not Two Separate Worlds
I used request.post() earlier in this article for test data setup, and I want to expand on that, because I think a lot of teams draw an artificial line between “API tests” and “UI tests” as if they’re written by different people, for different purposes, with no overlap — when in practice, thinking of them as one coherent testing strategy, sharing the same Playwright test runner and the same fixtures, produces both faster and more reliable suites.
The API Request Context, Beyond Just Setup
Playwright’s built-in request fixture isn’t just for seeding data before a UI test runs — it’s a full-featured HTTP client capable of carrying its own test suite entirely separate from the browser.
import { test, expect } from '@playwright/test';
test.describe('Invoice API', () => {
test('creating an invoice returns a 201 with the correct shape', async ({ request }) => {
const response = await request.post('/api/invoices', {
data: { customerId: 'cust_123', amount: 500, currency: 'INR' },
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body).toMatchObject({
customerId: 'cust_123',
amount: 500,
currency: 'INR',
status: 'pending',
});
expect(body.id).toBeTruthy();
});
test('creating an invoice with a negative amount is rejected', async ({ request }) => {
const response = await request.post('/api/invoices', {
data: { customerId: 'cust_123', amount: -50, currency: 'INR' },
});
expect(response.status()).toBe(400);
});
});
These run without ever launching a browser, which makes them dramatically faster than the equivalent UI-driven flow — often an order of magnitude faster per test — and they’re a genuinely better place to validate business-rule edge cases (negative amounts, boundary values, malformed payloads) than trying to force those same edge cases through a UI form that was never designed to let you submit invalid data in the first place, since a well-built frontend will often just disable the submit button before you ever get the chance.
My Actual Layering Strategy
Here’s how I think about where a given scenario belongs, and I’ll be direct that this is opinion shaped by experience, not a rule handed down from anywhere official.
Pure business logic and validation rules — negative amounts, boundary conditions, permission checks, malformed payloads — belong in API-level tests. They’re faster, they’re more precise about exactly what’s being validated, and they don’t depend on the UI’s rendering behavior at all, so a frontend refactor can’t accidentally break a test that was never really about the frontend in the first place.
Whether the UI correctly reflects and reacts to backend state — does the cart count update after adding an item, does a validation error actually render and clear correctly, does a loading spinner appear and disappear at the right moments — belongs in UI-level tests, because that’s specifically what’s being verified: the frontend’s behavior, not the backend’s.
Full end-to-end critical user journeys — the actual checkout flow, the actual account creation flow — deserve genuine UI-driven tests all the way through, because these are exactly the flows where you want confidence that every layer, working together, produces the outcome a real user experiences. But even here, use the API to set up preconditions (an existing account, a pre-populated cart) rather than clicking through five earlier steps just to get to the one step you actually want to test.
Combining Both in a Single Test
test('admin can see a newly created support ticket in the queue', async ({ page, request }) => {
// API: create the precondition fast, without touching the UI
const ticketResponse = await request.post('/api/support/tickets', {
data: { subject: 'Cannot access billing page', priority: 'high' },
});
const ticket = await ticketResponse.json();
// UI: verify what actually matters for this test — that the admin
// dashboard correctly surfaces it
await page.goto('/admin/support-queue');
const ticketRow = page.locator('.ticket-row').filter({ hasText: ticket.id });
await expect(ticketRow).toBeVisible();
await expect(ticketRow).toContainText('Cannot access billing page');
await expect(ticketRow.getByText('High Priority')).toBeVisible();
// API: verify the backend state changed correctly after a UI action
await ticketRow.getByRole('button', { name: 'Mark In Progress' }).click();
await expect(ticketRow.getByText('In Progress')).toBeVisible();
const statusCheck = await request.get(`/api/support/tickets/${ticket.id}`);
const updatedTicket = await statusCheck.json();
expect(updatedTicket.status).toBe('in_progress');
});
This single test is doing something genuinely valuable that a pure UI test or a pure API test alone couldn’t fully cover on its own — it confirms the UI action actually persisted correctly on the backend, not just that the UI’s own local state updated to look right. I’ve seen real bugs where a UI action optimistically updated the displayed status immediately, making a purely UI-based assertion pass, while the actual backend request silently failed — and only a test that checks backend state after the UI action would have caught that gap.
Part 16 — An Anti-Patterns Gallery: More Broken Code, More Fixes
The before/after examples earlier in this article covered checkout, a BFSI transaction table, and a healthcare intake form. Here’s a wider gallery of smaller, more specific anti-patterns — the kind of thing I flag constantly in code review, condensed to their essence rather than full test files, so you can scan through and recognize your own code in at least a few of these.
Drag and Drop Without Waiting for the Drop Target’s State to Actually Update
// ❌ dragTo resolves once the drag gesture completes, not once the
// application has finished processing the resulting state change
await sourceCard.dragTo(targetColumn);
await expect(targetColumn.getByText('Task moved')).toBeVisible(); // often flakes
// ✅ correlate with the actual backend call the drop triggers, if there is one
const [response] = await Promise.all([
page.waitForResponse((res) => res.url().includes('/api/tasks/move')),
sourceCard.dragTo(targetColumn),
]);
expect(response.ok()).toBeTruthy();
await expect(targetColumn.getByText('Task moved')).toBeVisible();
Drag and drop is deceptive because the gesture itself completes quickly and reliably, but whatever business logic runs as a result — reordering a list, persisting a new state to the backend, triggering a toast — is a separate asynchronous step that dragTo() has no visibility into and doesn’t wait for.
Infinite Scroll Assumed to Load Everything in One Pass
// ❌ scrolls once, assumes the full list is now loaded
await page.mouse.wheel(0, 5000);
await expect(page.getByText('Item #50')).toBeVisible(); // flaky — depends on how much loaded per scroll
// ✅ scroll repeatedly until the target is actually present, with a sane upper bound
async function scrollUntilVisible(page, locator, maxAttempts = 20) {
for (let i = 0; i < maxAttempts; i++) {
if (await locator.isVisible()) return;
await page.mouse.wheel(0, 2000);
await page.waitForTimeout(200); // brief, bounded — acceptable here specifically,
// because we're polling toward a condition with an explicit exit, not guessing a fixed total wait
}
throw new Error('Target item never became visible after scrolling');
}
await scrollUntilVisible(page, page.getByText('Item #50'));
I want to flag something here explicitly: this is one of the very few places in this entire article where a small waitForTimeout() inside a polling loop is defensible, and it’s worth understanding exactly why it’s different from the pattern I criticized so heavily in Part 2. The wait here isn’t standing in for an unknown async condition and hoping it resolves in time — it’s a small pacing delay between repeated, bounded attempts to check a real condition (isVisible()), with an explicit loop exit the moment that condition becomes true and an explicit failure if it never does within a reasonable number of attempts. That’s a fundamentally different shape from a single fixed wait gambling on an unknown duration.
WebSocket-Driven UI Updates
// ❌ assumes the websocket push arrives within an arbitrary fixed window
await triggerNotificationFromBackend(userId);
await page.waitForTimeout(2000);
await expect(page.getByTestId('notification-badge')).toHaveText('1');
// ✅ web-first assertion still applies here — it retries regardless of
// what mechanism (polling, websocket, SSE) eventually updates the DOM
await triggerNotificationFromBackend(userId);
await expect(page.getByTestId('notification-badge')).toHaveText('1', { timeout: 10000 });
This one’s worth calling out specifically because I’ve seen engineers assume websocket-driven UI needs some kind of special handling entirely distinct from everything else in this article. It doesn’t. A web-first assertion doesn’t care what mechanism eventually causes the DOM to update — it just keeps checking until the condition is true or the timeout expires. The underlying transport is irrelevant to the test.
Toast Notifications That Disappear Before You Can Assert On Them
// ❌ auto-dismissing toast, 3-second timer — this test is racing the toast's own animation
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForTimeout(500);
await expect(page.getByText('Saved successfully')).toBeVisible(); // sometimes it's already gone
// ✅ don't add a wait before the assertion at all — let the assertion's own
// retry window do the waiting, since it starts checking immediately
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Saved successfully')).toBeVisible(); // catches it as soon as it renders
The fix here is almost the opposite instinct of what people usually reach for — removing a wait, not adding one. Any manual wait inserted before a web-first assertion just eats into the toast’s limited visible lifetime before your assertion even starts looking for it. Let the assertion begin polling immediately after the triggering action.
Keyboard Navigation Tested With Clicks Instead of Actual Keyboard Events
// ❌ this "tests keyboard navigation" using a mouse click — it isn't testing what it claims to
await page.getByRole('option', { name: 'United States' }).click();
// ✅ if the point is genuinely verifying keyboard accessibility, use real key events
await page.getByRole('combobox', { name: 'Country' }).focus();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await expect(page.getByRole('combobox', { name: 'Country' })).toHaveValue('United States');
This isn’t a flakiness issue in the traditional sense — it’s a validity issue that I include here because it’s an extremely common mistake, and it quietly gives a team false confidence that keyboard navigation works when what’s actually been tested is a mouse click that happened to land on the same visible element a keyboard-only user would eventually reach differently. If a bug ticket ever comes in reporting that keyboard users can’t operate a dropdown, and your test suite is “green,” this is very often why.
Part 17 — Onboarding a New Team Member Onto an Existing Suite
I’ve written this section as close to verbatim as I can to a document I’ve actually handed to new hires and transfers joining an automation team I was leading. It’s less about new technical content and more about sequencing — what to actually read and do, in what order, in the first couple of weeks, so all the patterns covered in this article become habits rather than a wall of theory.
Week One: Read Before You Write
Before touching the suite, actually run it locally, end to end, and watch it happen — don’t just trust that it’s green in CI. Open UI mode (npx playwright test --ui) and click through a handful of representative tests, watching each one’s timeline. This does more for building an intuitive feel for how the suite behaves than reading any documentation would, including this article.
Read through the existing page objects (if the suite uses POM, per Part 7) before writing any new locators. The existing locator conventions — which attribute the team standardized on for data-testid, how deeply role-based locators have actually been adopted versus how much legacy CSS-selector debt remains — tell you more about the team’s real, practiced conventions than any style guide document would, because a style guide describes intent and the actual code describes what’s actually been done.
Open the trace viewer on a handful of recent CI runs — both passing and any recent failures — before writing a single new test. Get comfortable navigating a trace before you’re under pressure to debug one you actually care about.
Week Two: Write Something Small, Get It Reviewed Hard
The first new test a new team member writes should be small and low-stakes on purpose — not a critical checkout flow, something more like a settings toggle or a simple form field. The goal isn’t test coverage yet. The goal is getting real, detailed feedback on locator choices, wait strategy, and isolation before the stakes are high enough that a bad habit gets baked in and copied forward into a dozen more tests before anyone catches it.
I ask new team members to specifically annotate their first PR with brief comments explaining *why* they chose each locator — not because I need the explanation to review it, but because writing the justification out loud tends to surface, to the person writing it, any locator choice they weren’t actually confident about and just went with anyway.
A Starter Checklist I Actually Hand Out
This is deliberately shorter and blunter than the full code-review checklist in Part 4 — it’s meant to be memorized in a week, not referenced forever.
- Role, label, or test-id before CSS. Always ask “would this survive a redesign?”
- No
waitForTimeout(), ever, without asking a senior team member first — not because it’s always wrong, but because the exceptions are rare enough to be worth a second pair of eyes. - Every assertion about async state uses
expect(locator).toXxx(), never a manually-extracted value. - Every test creates its own data and cleans up after itself. If it doesn’t, ask why before assuming it’s fine.
- If something feels flaky, open the trace before touching the test code. Don’t guess.
Every experienced automation engineer I respect has some version of this list memorized well enough that they don’t consciously think about it anymore — it’s just how they write a test. That’s genuinely the goal for anyone new joining a team: not memorizing this article, but reaching the point where these patterns stop feeling like rules imposed from outside and start feeling like the obviously correct way to write a test that’ll still be trustworthy a year from now.
Part 18 — Component Testing: A Different Layer, With Its Own Locator and Waiting Rules
Everything so far has assumed end-to-end testing against a running application. Playwright also supports component testing — mounting a single React, Vue, or Svelte component in isolation and interacting with it directly, without a full app, a backend, or a real browser navigation involved at all. I don’t think it replaces end-to-end testing, and I want to be upfront about that before going further, but it fills a specific gap that end-to-end tests are genuinely bad at covering efficiently.
Setting It Up
npm init playwright@latest -- --ct
// DiscountBanner.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { DiscountBanner } from './DiscountBanner';
test('shows the correct discount percentage', async ({ mount }) => {
const component = await mount();
await expect(component.getByText('20% off')).toBeVisible();
});
test('calls onDismiss when the close button is clicked', async ({ mount }) => {
let dismissed = false;
const component = await mount(
{ dismissed = true; }} />
);
await component.getByRole('button', { name: 'Dismiss' }).click();
expect(dismissed).toBe(true);
});
Notice the locator patterns are identical to everything covered in Part 1 — getByRole, getByText, the same actionability and auto-waiting behavior. That consistency is one of the more underrated benefits of component testing living inside the same Playwright ecosystem rather than a separate tool like a standalone component-testing framework bolted on independently — the mental model your team already built for end-to-end tests transfers directly, with nothing new to learn about locators or waiting specifically.
Where the Waiting Story Changes
The one genuine difference worth flagging: without a real backend, any “async” behavior in a component test is almost always driven by mocked promises or component-internal state, not real network timing or real backend processing delay. This means waitForResponse() patterns from Part 2 rarely apply directly — instead, you’re typically mocking the function or prop that would trigger an async operation, and asserting on the resulting UI state with the same web-first assertions as always.
test('shows a loading spinner while the save operation is in flight', async ({ mount }) => {
let resolveSave: () => void;
const savePromise = new Promise((resolve) => { resolveSave = resolve; });
const component = await mount(
savePromise} />
);
await component.getByRole('button', { name: 'Save' }).click();
await expect(component.getByTestId('spinner')).toBeVisible();
resolveSave!();
await expect(component.getByTestId('spinner')).toBeHidden();
await expect(component.getByText('Saved')).toBeVisible();
});
Controlling the promise resolution manually, from inside the test itself, is something you genuinely can’t do cleanly against a real backend in an end-to-end test — you’re at the mercy of actual network and server timing there. In a component test, you get to directly control exactly when the “async” operation resolves, which makes edge cases like “what does this look like while it’s loading” or “what happens if this takes unusually long” trivially reproducible on demand, every single run, with zero flakiness risk from real timing variance at all.
When I Actually Reach for This
Component tests earn their keep specifically for components with complex internal state or many prop-driven visual variations — a date picker with dozens of edge cases around month boundaries and disabled dates, a form field with a long list of validation states, a data visualization component with several distinct rendering modes. Trying to cover all of those variations through full end-to-end tests would mean navigating a real app to a real page for every single variation, which is slow and puts unnecessary pressure on the rest of the app just to exercise one component’s edge cases. A component test gets you there directly, in milliseconds, without any of that overhead.
What I don’t use it for: verifying that components correctly integrate with real application state, real routing, or real backend data — that’s exactly what end-to-end tests are for, and no amount of component-level coverage substitutes for confirming the whole system actually works together for a real user journey.
Part 19 — Test Data Management at Scale, Beyond a Single Test’s Setup
The API-driven setup pattern covered earlier in this article works cleanly for a single test’s needs, but once a suite grows into the hundreds of tests, a more deliberate approach to test data pays off, and it’s worth walking through what that actually looks like in practice rather than leaving it as an exercise for the reader.
Fixture Factories Instead of Repeated Inline Setup
// ❌ the same setup logic copy-pasted across dozens of test files,
// slightly differently each time, drifting apart over months
test('user can view their order history', async ({ page, request }) => {
const userResponse = await request.post('/api/users', {
data: { email: `test-${Date.now()}@example.com`, name: 'Test User' },
});
const user = await userResponse.json();
// ...30 more lines of order creation, product creation, etc.
});
// ✅ a shared factory module, one place to update when the API contract changes
// test-utils/factories.ts
export async function createTestUser(request: APIRequestContext, overrides = {}) {
const response = await request.post('/api/users', {
data: {
email: `test-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`,
name: 'Test User',
...overrides,
},
});
return response.json();
}
export async function createTestOrder(request: APIRequestContext, userId: string, overrides = {}) {
const response = await request.post('/api/orders', {
data: { userId, items: [{ productId: 'prod_default', quantity: 1 }], ...overrides },
});
return response.json();
}
// in the test file
import { createTestUser, createTestOrder } from '../test-utils/factories';
test('user can view their order history', async ({ page, request }) => {
const user = await createTestUser(request);
await createTestOrder(request, user.id);
await page.goto(`/users/${user.id}/orders`);
await expect(page.getByTestId('order-list')).not.toBeEmpty();
});
The value here isn’t just reduced duplication, though that matters. It’s that when the API contract for creating a user changes — a new required field gets added, a field gets renamed — there’s exactly one place to update, instead of hunting down and fixing the same broken inline setup logic scattered across forty different test files, several of which someone will inevitably miss, leading to a confusing wave of unrelated-looking failures that all trace back to the same root cause.
Cleanup Strategies — Three Approaches, and When I Use Each
Explicit teardown in the test itself, which I showed earlier in the invoice deletion example — reliable and easy to reason about, but adds boilerplate to every test and, worse, a test that fails partway through can skip its own cleanup step entirely if the failure happens before the teardown line executes.
// explicit teardown — vulnerable to being skipped on mid-test failure
test('...', async ({ request }) => {
const invoice = await createTestInvoice(request);
// ... test logic that might throw ...
await deleteInvoice(request, invoice.id); // never reached if something above throws
});
Fixture-based teardown, using Playwright’s own fixture lifecycle, which runs cleanup even if the test body throws — this is what I actually recommend as the default for anything beyond the simplest cases.
// test-utils/fixtures.ts
import { test as base } from '@playwright/test';
import { createTestInvoice, deleteInvoice } from './factories';
export const test = base.extend<{ testInvoice: any }>({
testInvoice: async ({ request }, use) => {
const invoice = await createTestInvoice(request);
await use(invoice); // test runs here
await deleteInvoice(request, invoice.id); // runs regardless of pass/fail/throw
},
});
// in the test file
import { test, expect } from '../test-utils/fixtures';
test('user can view invoice details', async ({ page, testInvoice }) => {
await page.goto(`/invoices/${testInvoice.id}`);
await expect(page.getByText(testInvoice.reference)).toBeVisible();
});
Scheduled bulk cleanup, running as a separate nightly job that deletes any test-created records older than a threshold, identified by a naming convention (everything prefixed TEST- or test-, per the patterns used throughout this article). I use this as a safety net underneath the other two, not a replacement for either — it catches the residue from failed teardowns, aborted CI runs, and the occasional test someone wrote without following the fixture pattern properly.
Environment Isolation — Not Sharing State Across Test Suites Entirely
For teams running against a genuinely shared environment (a single staging deployment multiple squads all point their suites at), I’ve found real value in namespacing test data per suite or per team, not just per test — a prefix like QA-CHECKOUT- versus QA-BILLING- makes it trivial to identify, in a shared database, which team’s automation created which records, which matters enormously the first time someone needs to investigate why staging’s data looks strange and there are four different automation suites that could plausibly be responsible.
Part 20 — Speed: Making a Large Suite Fast Without Sacrificing the Reliability We’ve Spent This Whole Article Building
Everything covered so far is about reliability. Speed is a separate axis, and I want to address it directly because a suite that’s reliable but painfully slow eventually gets the same trust problem as a flaky one — people stop waiting for it, they merge without it finishing, and its value erodes regardless of how technically correct every individual test is.
Parallelism, Tuned to Your Actual Hardware
Playwright’s default worker count is based on available CPU cores, which is a reasonable default but rarely the actual optimal number for a specific CI runner’s real resource envelope.
// playwright.config.ts
export default defineConfig({
workers: process.env.CI ? 4 : '50%', // locally, half of available cores —
// leaves headroom for the rest of your dev environment to stay responsive
});
I’ve genuinely seen teams get worse total pipeline time by cranking worker count too high on a memory-constrained CI runner — each Playwright worker launches its own browser instance, and beyond a certain point, more workers means more memory pressure, more swapping, and net-negative throughput rather than the speedup everyone expected. The right number is empirical, not a formula — run your suite at 2, 4, and 8 workers on your actual CI runner and measure real wall-clock time; don’t assume more is always faster.
Avoiding Unnecessary Navigation and Setup
A pattern I see constantly in suites that have grown organically over time: dozens of tests all independently navigating through the same multi-step login flow, or the same multi-click path to reach a specific page, when a shared authenticated state or a direct navigation would get there in a fraction of the time.
// global-setup.ts — authenticate once, save the storage state, reuse everywhere
import { chromium } from '@playwright/test';
export default async function globalSetup() {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill('test-user@example.com');
await page.getByLabel('Password').fill('TestPassword123!');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'auth-state.json' });
await browser.close();
}
// playwright.config.ts
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
storageState: 'auth-state.json', // every test starts already logged in
},
});
This one change — authenticating once in a global setup step rather than in every individual test — is often the single biggest speed win available in a suite that hasn’t already adopted it, because login flows tend to involve multiple round trips, form validation, and redirect handling that add up to real seconds multiplied across every single test that needs an authenticated session. I’ve cut suite runtime by more than a third on more than one project with this change alone, without touching a single assertion or locator.
Direct Navigation Instead of UI-Driven Traversal
Similarly, if a test’s actual focus is the third step of a multi-step wizard, there’s rarely a good reason to click through steps one and two first if the app supports direct URL navigation to that step (many do, especially ones built with proper client-side routing) or if the intermediate state can be set up via the API patterns covered in Part 15.
// slow — three full page interactions just to reach the step being tested
await page.goto('/onboarding/step-1');
await page.getByLabel('Company name').fill('Acme Corp');
await page.getByRole('button', { name: 'Next' }).click();
await page.getByLabel('Industry').selectOption('Technology');
await page.getByRole('button', { name: 'Next' }).click();
// finally at step 3, which is what this test actually cares about
// fast — set up the precondition via API, navigate directly to the step under test
await request.post('/api/onboarding/progress', {
data: { userId: testUser.id, completedSteps: ['company-info', 'industry'] },
});
await page.goto(`/onboarding/step-3?userId=${testUser.id}`);
// straight to what this test actually verifies
I want to flag a real trade-off here rather than presenting this as free speed with no cost: skipping steps one and two via API setup means this particular test is no longer verifying that the full wizard flow, end to end, actually works — it’s now specifically testing step three in isolation. That’s fine, genuinely, as long as some other test in the suite still covers the full unbroken wizard journey at least once. The mistake would be applying this shortcut everywhere and losing all end-to-end coverage of the complete flow, not using it at all.
Trimming Genuinely Redundant Assertions
A smaller but real speed factor: tests that assert the same foundational state repeatedly across many test cases within a single file — re-verifying the page loaded correctly, re-verifying the user is logged in — when a single beforeEach or a shared setup could establish that baseline once, and individual tests could focus purely on what’s actually different about each scenario.
test.describe('Invoice list page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/invoices');
await expect(page.getByRole('heading', { name: 'Invoices' })).toBeVisible();
});
test('can filter by status', async ({ page }) => {
// starts already on the page, already verified loaded — focus purely on the filter behavior
await page.getByRole('button', { name: 'Filter' }).click();
// ...
});
test('can sort by date', async ({ page }) => {
await page.getByRole('columnheader', { name: 'Date' }).click();
// ...
});
});
Part 21 — Reporting and Metrics That Actually Change Team Behavior
A trace viewer helps you debug one failure. What helps a team improve its suite’s health over time is visibility into patterns across many runs — which tests are chronically flaky, which are chronically slow, whether overall reliability is trending up or down after a given change. I touched on tracking retries in Part 3; here’s the fuller picture of what I actually build and look at.
A Simple Flakiness Dashboard, Built From JSON Reporter Output
// scripts/analyze-flakiness.ts — run after each CI run, append to a running history file
import fs from 'fs';
interface TestResult {
title: string;
file: string;
outcome: 'expected' | 'flaky' | 'unexpected';
retries: number;
}
function extractResults(reportPath: string): TestResult[] {
const report = JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
const results: TestResult[] = [];
function walk(suite: any) {
for (const spec of suite.specs ?? []) {
for (const t of spec.tests ?? []) {
results.push({
title: spec.title,
file: suite.file,
outcome: t.results.length > 1 ? 'flaky' : t.status,
retries: t.results.length - 1,
});
}
}
for (const child of suite.suites ?? []) walk(child);
}
walk(report);
return results;
}
const results = extractResults('test-results/results.json');
const flaky = results.filter((r) => r.outcome === 'flaky');
const historyPath = 'flakiness-history.json';
const history = fs.existsSync(historyPath) ? JSON.parse(fs.readFileSync(historyPath, 'utf-8')) : [];
history.push({ date: new Date().toISOString(), flakyCount: flaky.length, flakyTests: flaky.map((f) => f.title) });
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
if (flaky.length > 0) {
console.warn(`⚠️ ${flaky.length} flaky test(s) this run:`);
flaky.forEach((f) => console.warn(` - ${f.title} (${f.retries} retries)`));
}
Appending to a running history file, rather than just printing a warning for the current run and discarding it, is the part that actually matters for changing team behavior over time. A single run’s flaky test list is a curiosity. A trend line showing the same three tests flaking on 40% of runs over the past month is a prioritization argument nobody on the team can reasonably ignore in a sprint planning conversation.
What I Actually Put In Front of a Team, and How Often
Weekly, not daily — daily flakiness numbers are noisy enough on a suite of any real size that they don’t tell you much, and checking too often trains people to shrug off individual bad days as noise, which then generalizes into shrugging off real trends too. Weekly is enough to see genuine trends without drowning in day-to-day variance.
I keep it to three numbers, deliberately simple: total flaky test count for the week, the top three most-frequently-flaking individual tests by name, and total suite runtime trend. Three numbers a team can actually internalize and act on beats a comprehensive dashboard nobody opens after the first week of enthusiasm wears off.
Tying Flakiness Metrics to Actual Backlog Items
The dashboard is worthless if flaky tests identified by it don’t turn into actual, prioritized work. On teams I’ve led, any test that shows up in the “top three flaky” list two weeks running automatically gets a ticket created — not optional, not “we’ll get to it,” an actual ticket in the same backlog as feature work, competing for the same prioritization attention. This is the mechanism that actually closes the loop between “we have visibility into flakiness” and “flakiness actually goes down over time,” and without it, a beautifully built dashboard just becomes something the team gets used to ignoring.
Part 22 — A Deeper Look at Migrating From Selenium, Beyond What’s Already Scattered Through This Article
I’ve referenced Selenium comparisons throughout this piece, but for a lot of you reading this specifically because you’re planning or mid-way through an actual migration, I want to pull those threads together into something closer to a practical migration guide, because “it’s better” isn’t quite the same as “here’s how to actually move a real suite.”
What Doesn’t Translate Directly, and Needs Genuine Rethinking
Explicit wait chains become almost entirely unnecessary — but resist the urge to mechanically translate every WebDriverWait into an equivalent Playwright wait call. Most of them should simply be deleted, because the actionability checks covered in Part 2 already cover what they were doing. A one-to-one syntax translation of a Selenium suite into Playwright syntax, keeping the same defensive wait patterns everywhere, captures almost none of Playwright’s actual reliability advantage and just produces a differently-shaped version of the same brittleness.
Page Factory patterns need restructuring, not just re-typing. Selenium’s Page Factory pattern with @FindBy annotations resolves WebElement references at object instantiation, which — as covered in Part 7 — reintroduces exactly the property-based locator problem Playwright’s lazy locators are designed to avoid. A direct syntactic port of a Page Factory class into TypeScript, keeping the same “resolve everything upfront” structure, throws away one of the more meaningful reliability improvements available in the migration.
Implicit waits have no real Playwright equivalent, and that’s intentional. Selenium’s global implicit wait setting applies uniformly to every element lookup across the entire driver session, which sounds convenient but in practice produces exactly the kind of blanket-timeout anti-pattern discussed in Part 2 — masking genuine timing problems in some spots while adding unnecessary delay in others. There’s no direct Playwright setting that replicates this, and I’d actively discourage trying to build one; the actionability-based auto-waiting model is a genuinely different and better approach, not a missing feature.
A Practical Migration Sequence I’ve Actually Used
Rather than a big-bang rewrite — which I’d generally discourage for any suite past a trivial size, given how much real risk sits in trying to cut over hundreds of tests at once — I’ve had better results with a staged approach.
First, migrate the suite’s utility and helper layer — login flows, common navigation helpers, API-based data setup — since almost everything else depends on these, and getting them right first, with genuine Playwright idioms rather than translated Selenium patterns, sets the tone and the actual reusable building blocks for everything that follows.
Second, migrate a handful of representative tests across a few different feature areas, deliberately choosing a mix of simple and complex flows, and use those as calibration — a chance for the team to actually internalize the patterns in this article (role-based locators, web-first assertions, proper isolation) on a small, reviewable scale before scaling up the pace.
Third, run the old Selenium suite and the new Playwright suite in parallel in CI for a defined window — I’ve typically used somewhere around four to six weeks — so the team builds real confidence that the new suite’s failures mean the same thing the old suite’s failures meant, before fully retiring the old one. This overlap period also surfaces, very usefully, any tests where the “same” scenario behaves subtly differently once properly expressed in Playwright’s idioms — which is sometimes a sign the original Selenium test wasn’t actually testing what everyone assumed it was.
Fourth, retire the Selenium suite only once the team has genuinely stopped needing it as a safety net — not on a calendar deadline picked in advance, but when the actual evidence (parallel run comparison, team confidence, flakiness metrics from Part 21) supports it.
The Skill That Transfers Completely, and the One That Doesn’t
Everything about test design — what makes a good assertion, how to think about test independence, how to structure a suite for maintainability — transfers completely and directly from Selenium experience. None of that knowledge is wasted, and I want to be clear about that for anyone feeling like years of Selenium experience are being discarded in a migration; they’re not.
What doesn’t transfer, and genuinely needs to be relearned rather than adapted, is the instinct for exactly when and how much to wait. Years of Selenium experience train a very deep, very reasonable habit of defensive, explicit waiting before almost every interaction, because Selenium genuinely needed that discipline to be reliable. Bringing that exact instinct unmodified into Playwright doesn’t just fail to help — it actively works against the tool’s own design, producing tests that are slower and no more reliable than if the defensive waiting had simply been left out and auto-waiting trusted to do its job. This is, in my experience, the single hardest habit for experienced Selenium engineers to unlearn, harder by far than any syntax difference.
Part 23 — Network Mocking, Beyond the Single-Route Examples Earlier in This Article
I showed a basic page.route() example back in Part 3 for mocking a declined payment. Network mocking deserves a fuller treatment on its own, because once a team starts using it seriously, it becomes one of the most powerful tools available for both flakiness prevention and genuine edge-case coverage that would otherwise be difficult or impossible to trigger reliably against a real backend.
Mocking an Entire Category of Requests, Not Just One Endpoint
// intercept every analytics call and silently drop it — analytics
// noise has no bearing on functional correctness and just adds
// unnecessary network chatter to every single test run
await page.route('**/analytics/**', (route) => route.abort());
// intercept every image request and serve a 1x1 placeholder instead —
// meaningfully speeds up tests where actual image content doesn't matter
await page.route('**/*.{png,jpg,jpeg,webp}', (route) =>
route.fulfill({
status: 200,
contentType: 'image/png',
body: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
),
})
);
Stripping out analytics and real image loading isn’t just a speed optimization, though it does meaningfully speed up a suite at scale — it also removes two categories of network activity that have nothing to do with what’s actually being tested but can still occasionally cause timing noise, particularly around anything relying on networkidle (which, per Part 2, you should mostly be avoiding anyway, but legacy tests in an inherited suite often still lean on it).
Simulating Slow or Degraded Network Conditions Deliberately
Sometimes you specifically want to verify how the UI behaves under a slow connection — does a loading state render correctly and stay visible for the actual duration of a slow request, rather than flickering, does a timeout get handled gracefully rather than leaving the user staring at a spinner forever. Guessing at real-world slow network conditions by throttling your actual test environment is inconsistent across machines and CI runners; deliberately injecting delay into a mocked route is fully reproducible.
test('shows a loading state during a slow search request', async ({ page }) => {
await page.route('**/api/search*', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 3000)); // deliberate, fixed delay
await route.continue();
});
await page.goto('/products');
await page.getByPlaceholder('Search products').fill('keyboard');
await page.getByRole('button', { name: 'Search' }).click();
// this spinner needs to genuinely still be there 1.5 seconds in — the mocked
// delay guarantees this test isn't racing real, variable network timing
await page.waitForTimeout(1500);
await expect(page.getByTestId('search-spinner')).toBeVisible();
await expect(page.getByTestId('search-results')).toBeVisible({ timeout: 5000 });
});
Worth pausing on this specific example, because it’s one of the rare legitimate uses of waitForTimeout() in this entire article, and I want to be precise about why it’s different from the anti-pattern covered in Part 2. The delay here isn’t guessing at how long a real, variable operation takes — it’s checking a UI state at a fixed point during a delay the test itself deliberately controls and guarantees. Because the mocked route’s delay is fixed at exactly 3000ms by the test itself, asserting something at the 1500ms mark is checking a genuinely deterministic midpoint, not gambling on unknown real-world timing.
Testing Error States That Are Hard to Trigger Against a Real Backend
test('shows a friendly error when the server returns a 500', async ({ page }) => {
await page.route('**/api/dashboard/summary', (route) =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal server error' }) })
);
await page.goto('/dashboard');
await expect(page.getByText('Something went wrong. Please try again.')).toBeVisible();
});
test('handles a malformed API response gracefully', async ({ page }) => {
await page.route('**/api/dashboard/summary', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '{"unexpected": "shape"' }) // deliberately broken JSON
);
await page.goto('/dashboard');
await expect(page.getByText('Unable to load dashboard data')).toBeVisible();
});
test('handles a request timeout gracefully', async ({ page }) => {
await page.route('**/api/dashboard/summary', (route) => route.abort('timedout'));
await page.goto('/dashboard');
await expect(page.getByText('Request timed out. Please try again.')).toBeVisible();
});
None of these three scenarios are easy to reliably trigger against a real backend on demand — you’d need to actually take a service down, corrupt a response, or introduce real network conditions, none of which are things you want happening in a shared staging environment other teams depend on. Mocking makes every one of these edge cases as reliable and repeatable as the happy path, which matters a lot for genuinely testing error-handling code that, in a lot of codebases I’ve reviewed, gets written once and then never properly exercised by any automated test at all.
Partial Mocking — Modifying a Real Response Instead of Replacing It Entirely
Sometimes you want the real backend response, but with one field deliberately altered — testing how the UI handles a specific edge-case value without needing to actually create that exact edge case in a real database.
test('shows an overdue badge when the due date has passed', async ({ page }) => {
await page.route('**/api/invoices/*', async (route) => {
const response = await route.fetch(); // real request goes through
const json = await response.json();
json.dueDate = '2020-01-01'; // deliberately overwrite just this one field
await route.fulfill({ response, json });
});
await page.goto('/invoices/INV-1042');
await expect(page.getByText('Overdue')).toBeVisible();
});
route.fetch() lets the real request actually happen, so you’re still exercising real backend logic and real data shape for everything except the one field you’re deliberately overriding — a nice middle ground between a fully mocked response (fast, deterministic, but disconnected from real backend behavior) and no mocking at all (realistic, but hard to force into a specific edge case on demand).
Part 24 — Error Handling and Custom Fixtures for Cleaner Failure Diagnostics
A last practical area worth covering: making failures, when they do happen, as fast and clear to diagnose as possible — closing the loop back to everything in Part 3 about the trace viewer, but focused specifically on things you can build into the suite itself ahead of time.
Custom Error Messages on Assertions
// a bare assertion failure tells you WHAT failed, not necessarily WHY it mattered
await expect(page.getByTestId('cart-total')).toHaveText('$149.99');
// a custom message adds business context a future debugger won't have to reconstruct
await expect(page.getByTestId('cart-total'), 'Cart total should reflect the 10% discount applied at checkout').toHaveText('$149.99');
This feels like a small thing until you’re the third person investigating a failure in a test someone else wrote eight months ago, with zero memory of the business context that made a specific expected value meaningful. A custom message costs almost nothing to write at the time and saves real investigation time later.
A Custom Fixture That Auto-Attaches Extra Diagnostic Context on Failure
// test-utils/fixtures.ts
import { test as base } from '@playwright/test';
export const test = base.extend({
page: async ({ page }, use, testInfo) => {
await use(page);
if (testInfo.status !== testInfo.expectedStatus) {
// on failure, capture extra state that a screenshot alone wouldn't show
const localStorage = await page.evaluate(() => JSON.stringify(window.localStorage));
await testInfo.attach('localStorage-snapshot', { body: localStorage, contentType: 'application/json' });
const cookies = await page.context().cookies();
await testInfo.attach('cookies-snapshot', { body: JSON.stringify(cookies), contentType: 'application/json' });
}
},
});
Extending the built-in page fixture like this means every test in the suite automatically gets this diagnostic capture on failure, without any individual test needing to remember to do it. The specific data worth capturing depends heavily on your application — local storage and cookies are common ones, but I’ve also attached feature-flag state, the current Redux store snapshot, or a specific API response body when debugging a particular class of recurring issue. The pattern is more valuable than the specific example: build failure-time diagnostics into the fixture layer once, and every test benefits automatically going forward.
Retrying Individual Flaky Steps Without Retrying an Entire Test
Test-level retries, covered in Part 3, re-run the entire test from scratch. Occasionally there’s a narrower, more surgical case — one specific interaction within an otherwise reliable test that has a known, understood, and accepted small chance of needing a second attempt, where re-running the entire test just to retry that one step is wasteful.
async function clickWithRetry(locator: Locator, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await locator.click({ timeout: 3000 });
return;
} catch (error) {
if (attempt === maxAttempts) throw error;
}
}
}
I want to flag this one with more caution than most patterns in this article, because it’s genuinely easy to misuse as a way to paper over a real, fixable timing issue rather than actually fixing it. I reach for this only for a narrow, specific, well-understood case — usually a known third-party widget with documented flaky initial-render behavior that’s entirely outside my own application’s control — not as a general-purpose tool applied liberally across a suite. If you find yourself wrapping more than a small handful of interactions in a suite with something like this, that’s a signal to step back and ask whether there’s a real root cause, per everything in Part 3, that deserves fixing properly instead.
Part 25 — Custom Matchers: Making Your Most Common Assertions Read Like Your Domain, Not Generic Playwright
Every real project accumulates a handful of assertions that get written over and over, slightly differently each time, because the built-in matcher set doesn’t have a perfect one-to-one match for a domain-specific concept. Playwright’s expect API supports custom matchers, and I think they’re underused relative to how much clarity they can add to a suite once a team has been writing tests for a few months and the same patterns keep showing up.
A Currency-Formatting Matcher, Because Every Financial App Needs One
// ❌ this exact pattern, repeated across dozens of tests, slightly
// differently every time depending on who wrote it that day
const totalText = await page.getByTestId('order-total').textContent();
const totalValue = parseFloat(totalText!.replace(/[₹,]/g, ''));
expect(totalValue).toBeCloseTo(1499.99, 2);
// test-utils/matchers.ts
import { expect as baseExpect } from '@playwright/test';
export const expect = baseExpect.extend({
async toHaveCurrencyValue(locator: Locator, expected: number) {
const text = await locator.textContent();
const actual = parseFloat((text ?? '').replace(/[₹,]/g, ''));
const pass = Math.abs(actual - expected) < 0.01;
return {
pass,
message: () =>
pass
? `Expected locator not to have currency value ${expected}, but it did`
: `Expected locator to have currency value ${expected}, but got ${actual} (raw text: "${text}")`,
};
},
});
// in the test file
import { expect } from '../test-utils/matchers';
await expect(page.getByTestId('order-total')).toHaveCurrencyValue(1499.99);
The custom matcher isn’t just shorter to write — the failure message is genuinely more useful too, showing both the expected and actual values plus the raw text, which saves a debugging step compared to a generic assertion failure that just says two numbers didn’t match. For a BFSI-domain suite specifically, where currency comparisons show up constantly across dozens of test files, this kind of matcher pays for the small upfront investment many times over.
A Matcher for Verifying a Toast Appeared and Auto-Dismissed Within a Reasonable Window
export const expect = baseExpect.extend({
async toShowAndDismissToast(page: Page, text: string, options = { maxDismissMs: 6000 }) {
const toast = page.getByRole('alert').filter({ hasText: text });
try {
await toast.waitFor({ state: 'visible', timeout: 3000 });
} catch {
return { pass: false, message: () => `Toast with text "${text}" never appeared` };
}
try {
await toast.waitFor({ state: 'hidden', timeout: options.maxDismissMs });
} catch {
return {
pass: false,
message: () => `Toast "${text}" appeared but did not auto-dismiss within ${options.maxDismissMs}ms`,
};
}
return { pass: true, message: () => `Toast "${text}" appeared and dismissed correctly` };
},
});
// usage
await page.getByRole('button', { name: 'Save' }).click();
await expect(page).toShowAndDismissToast('Saved successfully');
This one genuinely encodes a small piece of business behavior — toasts in this hypothetical app are expected to auto-dismiss within six seconds — directly into the assertion itself. If a future change accidentally makes toasts persist indefinitely (a real bug I’ve actually seen shipped, caused by a CSS transition removal that also happened to break the JS timer tied to it), this matcher catches it specifically, in a way a plain toBeVisible() check never would, because a plain visibility check only confirms the toast appeared, not that it behaved correctly afterward.
Where I Draw the Line on Custom Matchers
I don’t recommend building custom matchers speculatively, ahead of an actual repeated pattern showing up organically in the suite. The value comes specifically from replacing something that’s already been copy-pasted with slight variations several times — that’s the signal it’s worth centralizing. Building a large speculative library of custom matchers before the suite has actually grown enough to need them just adds an abstraction layer new team members have to learn, without yet having paid for itself in reduced duplication.
Part 26 — Managing Configuration Across Multiple Environments Without Duplicating Your Whole Config File
Most real projects run the same suite against more than one environment — local, a shared staging deployment, sometimes a pre-production environment that mirrors production more closely for final validation before a release. Handling this cleanly, without a sprawling if-else mess inside playwright.config.ts, is worth a dedicated look.
Environment-Specific Config Through Environment Variables, Not Hardcoded Branches
// ❌ a config file that's grown a tangle of conditionals over time,
// increasingly hard to reason about as more environments get added
export default defineConfig({
use: {
baseURL:
process.env.ENV === 'staging'
? 'https://staging.example.com'
: process.env.ENV === 'preprod'
? 'https://preprod.example.com'
: 'http://localhost:3000',
// ...repeated for every environment-dependent setting
},
});
// environments/staging.env
BASE_URL=https://staging.example.com
API_URL=https://staging-api.example.com
DEFAULT_TIMEOUT=15000
// environments/preprod.env
BASE_URL=https://preprod.example.com
API_URL=https://preprod-api.example.com
DEFAULT_TIMEOUT=8000
// playwright.config.ts — clean, reads from whatever env file was loaded
import { defineConfig } from '@playwright/test';
import dotenv from 'dotenv';
dotenv.config({ path: `environments/${process.env.TEST_ENV ?? 'local'}.env` });
export default defineConfig({
use: {
baseURL: process.env.BASE_URL,
},
timeout: Number(process.env.DEFAULT_TIMEOUT ?? 30000),
});
# package.json scripts
{
"scripts": {
"test:local": "TEST_ENV=local playwright test",
"test:staging": "TEST_ENV=staging playwright test",
"test:preprod": "TEST_ENV=preprod playwright test"
}
}
Separate environment files, rather than conditional branches inside the config itself, keep each environment’s actual values in one clearly-scoped place, make it trivial to add a fourth or fifth environment later without touching the core config logic at all, and — a real practical benefit — make it much easier to spot at a glance exactly which values differ between environments, since they’re sitting side by side in separate small files rather than scattered across conditional expressions.
Different Timeout Expectations Are a Real, Deliberate Environment Difference — Not Noise to Paper Over
Notice the staging and preprod example files above have genuinely different default timeouts, and I want to call that out as intentional rather than an oversight. A staging environment shared across multiple teams, under variable load from other teams’ automation running concurrently, often has real, honest latency that a smaller, more isolated pre-production environment doesn’t. Setting environment-appropriate default timeouts rather than one blanket number tuned to whichever environment happens to be slowest avoids the trap covered back in Part 2 — padding every timeout globally to cover the worst case, which just slows down feedback everywhere else.
Environment-Specific Test Skipping, When a Feature Genuinely Isn’t Available Everywhere
Sometimes a feature is deliberately not yet rolled out to every environment — behind a flag in staging that hasn’t reached preprod yet, say. Rather than letting those tests fail confusingly in an environment where the feature genuinely doesn’t exist, being explicit about the skip communicates intent clearly to anyone reading the results.
test('new referral program banner appears on dashboard', async ({ page }) => {
test.skip(process.env.TEST_ENV === 'preprod', 'Referral program not yet rolled out to preprod');
await page.goto('/dashboard');
await expect(page.getByText('Refer a friend')).toBeVisible();
});
The explicit reason string matters more than it might seem — six months later, someone scanning a test report full of skipped tests needs to be able to tell at a glance which skips are expected and current versus which ones represent a forgotten flag nobody ever cleaned up after the feature actually shipped everywhere.
Part 27 — A Quick-Reference Glossary, Because This Article Has Covered a Lot of Terminology
For anyone using this as a reference to come back to rather than reading straight through, here’s a condensed glossary of the terms covered across this article, gathered in one place.
Locator — a lazily-evaluated description of how to find an element, re-resolved against the live DOM every time an action is performed on it, as opposed to a resolved reference captured once.
Actionability / auto-waiting — the set of checks (attached, visible, stable, receiving events, enabled, editable) Playwright automatically performs and waits on before most actions, without requiring explicit wait code.
Web-first assertion — an assertion using the expect(locator).toXxx() form, which retries automatically until the condition is true or a timeout expires, as opposed to a single-shot assertion on a manually-extracted value.
Strict mode — Playwright’s default behavior of throwing an error when a locator matches more than one element for most actions, rather than silently acting on the first match.
Flaky test — a test that produces inconsistent pass/fail results across repeated runs against unchanged code, where the inconsistency originates in the test or its environment rather than genuine application behavior.
Intermittent bug — genuinely inconsistent application behavior, correctly and faithfully reported as a failure by a test; distinct from a flaky test, and should be filed as a real defect rather than treated as a test problem.
Trace viewer — Playwright’s tool for reviewing a full recorded timeline of a test run, including actions, network activity, DOM snapshots, and console output, primarily used for diagnosing failures after the fact.
Sharding — splitting a test suite across multiple parallel CI jobs (shards) to reduce total wall-clock pipeline time.
Fixture — Playwright’s mechanism for providing reusable setup and teardown logic to tests, with guaranteed cleanup execution even if the test body throws.
Page Object Model (POM) — an organizational pattern that centralizes locators and page-specific actions into dedicated classes, keeping test files focused on scenario logic rather than raw selector details.
Actionable timeout hierarchy — the layered timeout configuration available in Playwright (global test timeout, expect timeout, action timeout, navigation timeout), each overridable at a more specific level when a genuine, understood exception applies.
Soft assertion — an assertion made with expect.soft() that records a failure without immediately stopping test execution, allowing multiple independent failures within one test to be reported together.
Component testing — testing a single UI component in isolation, mounted directly without a full running application, using the same Playwright locator and assertion APIs as end-to-end testing.
Part 28 — Naming and Organizing Tests So a Failure Report Is Useful Without Opening the Code
One last practical area, small in scope but genuinely underrated: how tests are named and grouped. I’ve inherited suites where every test name was some variation of “test 1,” “test 2,” or a literal copy of a Jira ticket ID with no other context, and I’ve inherited suites where every name read like a small, precise sentence. The difference shows up specifically in the moment that matters most — glancing at a CI failure notification before you’ve even opened the report.
What a Good Test Name Actually Buys You
// ❌ tells you almost nothing from a failure notification alone
test('TC-4471', async ({ page }) => { /* ... */ });
test('checkout test 3', async ({ page }) => { /* ... */ });
// ✅ a failure notification alone tells a reader almost everything they need to know
test('user cannot apply an expired discount code at checkout', async ({ page }) => { /* ... */ });
test('cart total updates correctly after removing an item', async ({ page }) => { /* ... */ });
The value compounds specifically at scale, in a Slack notification or an email digest listing a dozen failing test names with no other context attached — a well-named test tells a reader, without opening anything else, roughly what broke and how serious it likely is, which matters enormously for triage speed when a release is on the line and someone needs to make a quick call about whether a failure is release-blocking.
Grouping With describe Blocks That Reflect Actual User-Facing Behavior, Not Internal Code Structure
// ❌ organized around internal implementation details the test author
// happened to be thinking about, not anything a stakeholder would recognize
test.describe('CheckoutController tests', () => {
test('POST /checkout returns 200', async ({ request }) => { /* ... */ });
});
// ✅ organized around actual user-facing behavior — reads sensibly
// to a QA manager, a product owner, or a new team member alike
test.describe('Checkout — discount codes', () => {
test('valid discount code reduces the order total correctly', async ({ page }) => { /* ... */ });
test('expired discount code is rejected with a clear error message', async ({ page }) => { /* ... */ });
test('discount code field is case-insensitive', async ({ page }) => { /* ... */ });
});
test.describe('Checkout — payment failures', () => {
test('declined card shows a specific, actionable error', async ({ page }) => { /* ... */ });
test('network failure during payment does not double-charge on retry', async ({ page }) => { /* ... */ });
});
This isn’t purely stylistic. A well-organized describe structure, reflecting genuine user-facing behavior groupings, is also what makes an HTML test report actually navigable for someone who isn’t the person who wrote the tests — a QA manager reviewing coverage before a release, a product owner curious whether a specific edge case is covered, an auditor in a regulated environment who needs to map test coverage back to a specific requirement. I’ve sat in exactly that kind of review meeting, scrolling through a test report live, and the difference between a well-organized report and a flat list of cryptically-named tests is the difference between answering a stakeholder’s question in ten seconds versus having to go read source code live in front of them.
Tagging for Selective Execution, Beyond Just Browser Projects
Playwright supports tagging tests with annotations that can be used to selectively run subsets — critical smoke tests before a deploy, a specific feature area during focused development, everything except a known-slow category during rapid local iteration.
test('user can complete a basic checkout @smoke @critical', async ({ page }) => { /* ... */ });
test('discount code field is case-insensitive @regression', async ({ page }) => { /* ... */ });
# run only smoke tests before a production deploy — fast confidence check npx playwright test --grep @smoke # run everything except known-slow visual regression tests during local development npx playwright test --grep-invert @visual
A well-maintained smoke tag set — a small, carefully curated handful of the most critical user journeys, deliberately kept small — is genuinely valuable as a fast pre-deploy gate distinct from the full regression suite. I’ve seen teams let a “smoke” tag balloon over time until it’s nearly the entire suite, at which point it stops being useful as a fast gate at all and just becomes a slower, confusingly-named copy of the full run. Keeping it genuinely small and curated, revisited periodically, is worth the small ongoing discipline it takes.
A Final Case Study: Pulling Naming, Isolation, and Waiting Together in One Realistic Example
To close out, here’s one more example that deliberately pulls together several threads from across this article at once, rather than isolating a single lesson — closer to what a real, well-written test in a mature suite actually looks like end to end.
import { test, expect } from '../test-utils/fixtures';
import { createTestUser, createTestSubscription } from '../test-utils/factories';
test.describe('Subscription management — plan downgrades', () => {
test('downgrading from Premium to Basic mid-cycle shows a prorated credit notice', async ({
page,
request,
}) => {
const user = await createTestUser(request, { plan: 'premium' });
await createTestSubscription(request, user.id, { plan: 'premium', billingCycleDay: 15 });
await page.goto(`/account/${user.id}/subscription`);
await expect(page.getByRole('heading', { name: 'Your Subscription' })).toBeVisible();
await page.getByRole('button', { name: 'Change Plan' }).click();
await page.getByRole('radio', { name: 'Basic — $9/month' }).check();
const [downgradeResponse] = await Promise.all([
page.waitForResponse((res) => res.url().includes('/api/subscriptions/downgrade') && res.ok()),
page.getByRole('button', { name: 'Confirm Downgrade' }).click(),
]);
const downgradeData = await downgradeResponse.json();
expect(downgradeData.proratedCredit).toBeGreaterThan(0);
await expect(
page.getByText(`You'll receive a $${downgradeData.proratedCredit.toFixed(2)} credit`)
).toBeVisible();
const confirmationCheck = await request.get(`/api/subscriptions/${user.id}`);
const updatedSubscription = await confirmationCheck.json();
expect(updatedSubscription.plan).toBe('basic');
});
});
Nothing in this test is exotic on its own — every individual piece has appeared somewhere earlier in this article. What I want to point out is how naturally they combine once they’re actual habits rather than a checklist being consciously worked through: a descriptive name that tells a reader exactly what broke if this fails; data created via the factory pattern from Part 19, uniquely scoped to this test; role-based locators throughout; a network wait correlated properly with its triggering action; a web-first assertion on the resulting UI text; and a final API-level check confirming the backend state actually changed, not just that the frontend optimistically displayed something that looked right. Six months from now, whoever’s on call when this test fails will read the name, glance at the trace, and know almost immediately whether they’re looking at a real pricing-logic regression or something else — which, at the end of a very long article about a lot of small decisions, is really the entire point of all of it.
Part 29 — A Note on Test Data Realism, and Why “Fake-Looking” Data Causes Its Own Category of Bugs to Slip Through
One thing I haven’t touched on yet, and probably should have earlier given how much of this article leans on API-driven test data setup: the actual realism of the data you generate matters more than teams tend to assume, and lazy test data is a quiet source of both false confidence and, occasionally, genuine flakiness that looks like something else entirely.
The “Test User 1” Problem
A huge number of suites I’ve inherited generate test data that looks nothing like real production data — names like “Test User,” email addresses like test@test.com, amounts that are always suspiciously round numbers like exactly $100.00, addresses that are always the same fictional street. This works fine for the majority of scenarios, but it quietly hides an entire category of bugs specifically related to formatting, truncation, and internationalization — a name field that breaks with an apostrophe in it, a currency display that mishandles four-digit amounts, an address field that overflows its container with a genuinely long real-world address.
// ❌ safe, sanitized, and quietly hides real formatting bugs
const user = await createTestUser(request, { name: 'Test User', amount: 100.00 });
// ✅ deliberately varied data across a representative sample of tests,
// specifically chosen to exercise formatting edge cases
const edgeCaseUsers = [
{ name: "O'Brien-Fernandes", amount: 1234.56 },
{ name: 'Müller Aditya Krishnamurthy', amount: 99999.99 },
{ name: 'Li Wei', amount: 0.50 },
];
for (const testCase of edgeCaseUsers) {
test(`displays name and amount correctly for "${testCase.name}"`, async ({ page, request }) => {
const user = await createTestUser(request, testCase);
await page.goto(`/users/${user.id}`);
await expect(page.getByTestId('user-name')).toHaveText(testCase.name);
await expect(page.getByTestId('user-balance')).toContainText(testCase.amount.toFixed(2));
});
}
I’m not suggesting every single test in a suite needs deliberately adversarial data — that would be excessive and would slow down the majority of tests whose actual purpose has nothing to do with formatting edge cases. But a deliberate, small, curated set of tests specifically using names with apostrophes, non-Latin characters, unusually long strings, and non-round monetary amounts catches a real and recurring category of bug that an entire suite built exclusively on “Test User” and clean round numbers will simply never encounter, no matter how many tests exist.
Where This Connects Back to Flakiness Specifically
There’s a more subtle connection to the flakiness discussion running through this whole article too. I’ve debugged more than one “flaky” test that turned out to be entirely deterministic, but dependent on a randomly-generated test name occasionally happening to contain a character that broke a downstream regex validation or a URL-encoding step — the test wasn’t actually flaky at all, it was failing consistently and predictably whenever the random generator happened to produce a name with an apostrophe or an accented character, which looked exactly like intermittent flakiness across many runs because the random generator only occasionally produced that specific unlucky value. The fix, once correctly diagnosed via the trace viewer comparison approach from Part 3, wasn’t a retry — it was fixing a genuine validation bug that had been quietly triggering on a small, unlucky percentage of randomly generated names all along.
Part 30 — When Not to Automate: A Short, Honest Section on the Limits of Everything in This Article
I want to end with something that might read as a strange note for an article this deep into Playwright specifics, but I think it belongs here precisely because everything above assumes automation is the right tool for whatever’s being tested, and that assumption is worth questioning explicitly rather than leaving implicit.
Not everything benefits from automated coverage, and forcing genuinely poor candidates into an automated suite tends to produce exactly the kind of chronic flakiness this entire article has been trying to help you eliminate — because the flakiness in those cases isn’t really a locator problem or a waiting problem at all, it’s a sign the thing being tested was never well-suited to deterministic automation in the first place.
Highly subjective visual polish — genuine pixel-perfect design fidelity outside of the masked, tolerance-based visual regression approach in Part 8 is often better served by design review and manual QA than by automated pixel comparison, which tends to either produce constant false positives from legitimate design iteration or, if tolerance is loosened enough to stop that, misses real regressions entirely.
One-time data migrations — a script that runs exactly once against production data rarely benefits from a full Playwright suite built around it; a focused, targeted script test or a careful manual verification process is usually more appropriate than investing in UI-level automation for something with no ongoing regression risk.
Genuinely exploratory testing — the kind where a skilled tester is actively probing an application for unexpected behavior, following hunches, and adapting their approach based on what they find in real time, isn’t something automation replaces at all, no matter how sophisticated the locator strategy. Automated tests verify known, specified behavior repeatedly and cheaply; they don’t discover unknown behavior the way a curious human tester does. I’ve never met a genuinely excellent QA engineer who thought automation eliminated the need for skilled exploratory testing, and I’d be skeptical of anyone who claimed otherwise.
None of this is an argument against automation broadly — obviously, given the length of everything above it. It’s a reminder that the techniques in this article make automation more reliable and more valuable for the things automation is actually good at. They don’t make automation the right choice for everything, and recognizing that distinction is itself part of good QA judgment, not a concession against the discipline.
Part 31 — A Few Questions I Get Asked After Talks on This Topic, Answered Briefly
Does adopting all of this slow down how fast a team can write new tests initially? A little, at first — thinking through the right locator, the right wait condition, and proper data isolation takes more deliberate thought than reaching for whatever CSS selector Chrome DevTools hands you and a generic fixed wait. That upfront cost is real and I won’t pretend otherwise. It reverses within a few sprints, once the patterns become habitual rather than consciously applied, and it reverses dramatically once you count the time a team stops spending on flaky-test investigation, re-runs, and the slow erosion of trust in a pipeline nobody believes anymore.
Is any of this Playwright-specific, or does it generalize to other tools? The underlying principles — precise waiting over guessed durations, test isolation, distinguishing real bugs from test defects, treating a red build as a signal worth investigating rather than noise to route around — apply regardless of which automation tool sits underneath them. What’s genuinely Playwright-specific is the particular mechanism for acting on those principles: lazy locators, built-in actionability checks, web-first assertions, and the trace viewer are all real technical advantages that make the underlying discipline meaningfully easier to actually practice consistently, compared to tools that require you to hand-roll all of that waiting and retry logic yourself.
What’s the single highest-leverage change for a team starting from a genuinely flaky legacy suite? Instrument first, fix second. Get retry tracking and a flakiness dashboard in place, per Part 21, before doing any actual rewriting. Without real data on which specific tests are the worst offenders, teams tend to fix whatever’s most recently annoyed them rather than whatever’s actually costing the most CI time and trust — and a week of instrumentation before touching any test code usually redirects the fixing effort somewhere far more valuable than instinct alone would have.
One Last Note Before You Go
If you’ve read this entire thing in one sitting, I appreciate it, and I’d gently suggest you don’t need to apply all thirty-one parts of it to your suite this week. Start with the two things that compound the fastest: replacing any waitForTimeout() you can find with a real, named condition, and making sure every assertion touching async state uses the retrying expect(locator).toXxx() form. Those two habits alone will fix the majority of flaky tests in most suites I’ve ever touched. Everything else in this article is what you reach for once those two are already second nature and you’re chasing the harder, more specific ten percent that’s left — the long tail of genuine Playwright best practices rather than the two or three that matter most.
Closing FAQ, Expanded
Should component tests replace end-to-end tests for a growing suite?
No — they cover different concerns. Component tests are excellent for exercising a single component’s internal states and edge cases quickly and deterministically, but they can’t verify that components correctly integrate with real routing, real backend state, or the rest of the application working together. A healthy suite typically uses both, with end-to-end tests reserved for genuinely critical user journeys and component tests handling the long tail of individual component edge cases.
Is it worth setting up sharding for a small suite?
Generally not until a suite reaches a size where total runtime meaningfully affects how quickly a team gets feedback on a PR — often somewhere in the range of a few hundred tests, though this varies by how heavy individual tests are. For a smaller suite, the added CI configuration complexity usually isn’t worth the marginal speed gain, and it’s easy to add sharding later once the suite has actually grown into needing it.
How much test data cleanup is actually necessary if the environment gets reset periodically anyway?
Even with periodic resets, cleanup still matters for anything running between resets, since accumulated test data can still cause collisions, slow queries against bloated tables, or confusing investigation sessions for anyone examining that environment’s current state. Periodic resets are a reasonable safety net, not a substitute for tests cleaning up after themselves.
Does network mocking risk tests passing while the real integration is actually broken?
Yes, and this is the real trade-off worth being honest about. Mocked tests verify the frontend’s behavior given a specific response shape, not that the real backend actually produces that shape. This is exactly why a smaller set of genuine, unmocked end-to-end tests covering critical paths remains valuable alongside a larger body of mocked tests — the mocked tests give you fast, deterministic coverage of many scenarios, while the unmocked ones confirm the real integration between frontend and backend actually holds.
What’s a reasonable amount of time to budget for migrating a mid-sized Selenium suite to Playwright?
Highly dependent on suite size and how much of the existing suite already reflects reasonable test design versus how much needs genuine rework, not just syntax translation. For a suite in the low hundreds of tests with generally sound underlying test design, a staged migration following the sequence in Part 22 has typically taken a small team something on the order of two to three months in my experience, run alongside normal feature work rather than as a dedicated full-stop effort — though this is a rough anchor from specific projects, not a universal estimate.
Final Word
This piece has covered a lot of ground — locators, waiting, flakiness root causes, but also the layers around those core ideas that a suite actually needs to stay healthy at real scale: CI configuration, accessibility, cross-browser reality, team habits, migration strategy. If there’s a single idea that ties all of it together, it’s the same one from the very start of this article: almost nothing here is about being clever. It’s about being precise about what you’re actually waiting for, what you’re actually asserting, and what you’re actually willing to let hide behind a retry. Get precise about those three things, consistently, and the rest of it — speed, trust, a team that actually believes its own CI — tends to follow on its own. That’s the whole of Playwright best practices, really, once you strip away the thirty-one sections of examples: precision over guessing, everywhere, all the time.
🔥 Continue Your Learning Journey
Want to go beyond Playwright with Typescript setup and crack interviews faster? Check these hand-picked guides:
👉 🚀 Master TestNG Framework (Enterprise Level)
Build scalable automation frameworks with CI/CD, parallel execution, and real-world architecture
➡️ Read: TestNG Automation Framework – Complete Architect Guide
👉 🧠 Learn Cucumber (BDD from Scratch to Advanced)
Understand Gherkin, step definitions, and real-world BDD framework design
➡️ Read: Cucumber Automation Framework – Beginner to Advanced Guide
👉 🔐 API Authentication Made Simple
Master JWT, OAuth, Bearer Tokens with real API testing examples
➡️ Read: Ultimate API Authentication Guide
👉 ⚡ Crack Playwright Interviews (2026 Ready)
Top real interview questions with answers and scenarios
➡️ Read: Playwright Interview Questions Guide
Pretty! This was a really wonderful article. Thank you for
providing these details.
Thanks for comments and appreciating the blog.
I hope my other blogs will also help you.
Thanks for comments and appreciating the blog.