Last updated on August 7th, 2026 at 07:46 am
I have spent most of the last two decades chasing down flaky test failures, and for a long time I assumed that was just the cost of doing automation. A button renders a few milliseconds late, a selector breaks because a developer renamed a CSS class, a test passes on my laptop and fails on CI for no reason anyone can explain. I added sleep timers. I rewrote selectors. I told myself this was normal.
It isn’t normal. It is a sign that the tool underneath the test was never built for how browsers work today.
That is the problem Playwright was built to solve, and it is why I moved my own automation work over to it and have not looked back. In this tutorial, I will walk through the architecture that makes it fast, a production-grade setup, resilient locators, Page Objects with fixtures, the edge cases that break most frameworks, and the errors you will hit once your suite is running in CI. Every code block here is something I have used or debugged myself.
- What Is Playwright?
- How Playwright Automation Works
- Installing Playwright and Setting Up Your Project
- Writing Your First Playwright Test
- Generating Tests Automatically with Codegen
- Choosing Locators That Do Not Break Every Sprint
- Organizing Locators with the Page Object Model
- Handling Real-World UI: Shadow DOM, Multiple Tabs, and Network Mocking
- Hard vs. Soft Assertions
- Debugging a Failing Test Locally
- Debugging Failures That Only Happen in CI
- Common Playwright Errors and How to Fix Them
- Authentication and Session Management
- Building a Production-Ready Config
- Running Tests in Parallel Across CI with Sharding
- A Quick Checklist Before You Ship a Playwright Suite
- Frequently Asked Questions
- Closing Thoughts
What Is Playwright?
Playwright is an open-source browser testing and automation framework, created and maintained by Microsoft, that lets you write a single test script and run it against Chromium, Firefox, and WebKit. It was first released in January 2020, and it was designed from the ground up for how modern web applications behave: asynchronous rendering, single-page apps, background API calls, and UI that changes shape depending on network timing.
You can use Playwright for:
- End-to-end testing – simulating a real user clicking through your application from start to finish
- UI and functional testing – verifying that individual components and flows behave correctly
- API testing – sending requests and validating responses without a browser at all, using the same test runner
- Visual regression testing – catching unintended layout or styling changes
- Web scraping and browser automation – outside of testing, the same engine can drive a browser for any scripted task
It supports JavaScript, TypeScript, Python, Java, and .NET, with identical capabilities across all of them, so a team is not locked into one language just because they chose Playwright. That range is a big part of why Playwright testing has become the default choice for so many QA teams over the last few years, whatever stack the rest of the application is built on.
Playwright is completely free and open source under the Apache 2.0 license. There is no paid tier for the framework itself.

If this is your very first time touching Playwright and you’d rather move slower before getting into architecture and production patterns, our Playwright Testing Tutorial for Beginners is a gentler starting point. This tutorial assumes you’re ready to move a bit faster.
And if you already know Playwright and just want a specific topic, locators, a particular action, Java or Python instead of TypeScript, our full Playwright tutorials list is organized by topic so you can jump straight to it.
If you have used Selenium or Cypress before, here is what separates Playwright from both. Selenium talks to the browser through a driver and the WebDriver protocol, adding a translation layer to every action. Cypress runs inside the browser itself, which is fast but limits it to one tab and one origin at a time. Playwright talks to the browser engine directly, which is what gives it both the speed and the multi-tab flexibility the other two struggle with. The next section covers exactly how that works.
How Playwright Automation Works
Most of the flakiness I dealt with in Selenium traces back to one thing: the driver model. Your test code does not talk to the browser directly. It sends an HTTP request to a driver process, the driver translates that into a browser command, waits for a response, and sends it back. Every click, every scroll, every keystroke makes that round trip. Add a slow CI runner into the mix and you get exactly the kind of timing failures I used to blame on “flaky tests” when the real cause was the plumbing underneath them.
Playwright skips that translation layer. It opens a direct WebSocket connection to the browser engine, using the Chrome DevTools Protocol for Chromium, the WebDriver BiDi protocol for Firefox, and its own protocol for WebKit. Your test talks to the browser, not to a driver relaying messages on its behalf.

That direct connection is what makes three things possible:
- Browser contexts. A context is an isolated environment inside a single browser process, similar to opening an incognito window. You can spin up dozens of them in seconds without launching dozens of separate browser windows, which is why Playwright parallelizes so well.
- Auto-waiting. Before Playwright clicks or types into something, it checks that the element is attached to the DOM, visible, stable, and not covered by anything else. This is the actionability check, and it is why most Playwright tests do not need manual waits at all.
- Network interception. Because Playwright sits on top of the browser engine itself, it can read, modify, or fully mock any network request the page makes, without a proxy or a browser extension.
Playwright vs. Selenium vs. Cypress
| Feature | Playwright | Cypress | Selenium |
|---|---|---|---|
| Connection to browser | Direct (WebSockets) | Runs inside the browser | HTTP via a separate driver |
| Cross-browser support | Chromium, Firefox, WebKit | Chromium-based + limited Firefox | Chromium, Firefox, WebKit, and more |
| Parallel execution | Native, out of the box | Requires a paid plan or plugin | Needs Selenium Grid |
| Auto-waiting | Built in | Built in (retry-ability) | Manual waits in most setups |
| Multiple tabs/windows | Native support | Not supported well | Native support |
| Network mocking | Native (page.route()) | Partial support | Requires an external proxy |

None of this means Selenium or Cypress are bad tools. Selenium is still the right choice if you need to support a browser Playwright does not cover, and Cypress has a strong developer experience for teams that only test one origin. But for cross-browser end-to-end automation at scale, this is why most teams I have worked with have moved to Playwright, and why I am building the rest of this tutorial around it. For the fuller breakdown, see our dedicated Playwright vs Selenium comparison.
One honest limit worth naming here: Playwright automates web browsers, including mobile browser viewports, but it does not automate native mobile apps, and it does not run on real device hardware without pairing it with a third-party device cloud. If your team needs native iOS or Android app automation, that’s outside what Playwright does at all, and a tool like Appium is the right fit instead.
Installing Playwright and Setting Up Your Project
Before you install anything, you need Node.js 22 or higher on your machine (Playwright currently supports the 22.x, 24.x, and 26.x lines). Check what you have with:
node -v
If that returns nothing or a version below 22, install Node.js first. Everything else in this tutorial assumes it is already there.
This tutorial also uses VS Code throughout, for the integrated terminal, the file explorer screenshots, and the IntelliSense examples later on. Playwright itself doesn’t require any specific editor, any terminal and text editor works, but if you want your screen to match what’s shown here, download VS Code for free before continuing.
With that in place, create a project folder, navigate into it, and run:
npm init playwright@latest

This one command does more than install a package. It walks you through a few choices and then scaffolds a working project around them:
- TypeScript or JavaScript. I use TypeScript on every real project at this point, mainly for the autocomplete and the type-checking on locators and fixtures. If you are just getting started, plain JavaScript works fine too. For a slower, more detailed walkthrough of this exact step, see our dedicated install guides for TypeScript or JavaScript, whichever you’re using.
- Test folder name. Defaults to
tests. No reason to change this unless you are merging into an existing repo structure. - GitHub Actions workflow. Say yes if you already know you will run this in CI. You can add it later either way.
- Install browsers. Say yes. This downloads Chromium, Firefox, and WebKit so you are not stuck installing them mid-tutorial.
Once it finishes, run the sample tests to confirm the install worked:
npx playwright test
This runs the sample tests Playwright generated for you across all three browsers.

To see the results in a browser instead of the terminal:
npx playwright show-report

If both of those worked, your environment is set up correctly and everything from here on will run the same way.
What Got Created
Take a look at your project folder now. A default Playwright install gives you:
playwright.config.ts– the control center for the whole suite: which browsers run, timeouts, retries, base URL, reporting. Almost everything you configure lives here instead of inside individual test files.tests/– where your test files live. Playwright automatically discovers anything matching*.spec.tsor*.test.tsinside it.tests-examples/– a sample test file Playwright generates so you have something working to look at. Safe to delete once you understand the pattern.package.json– your usual Node project file, now with Playwright as a dependency.

You’ll also see playwright-report/ and test-results/ show up once you’ve actually run a test, these aren’t part of the initial install, Playwright generates them fresh on every run and they’re safe to add to .gitignore.
For a closer look at how a real project builds on this default layout, see our breakdown of Playwright project structure in TypeScript.
The default playwright.config.ts is enough to run tests, but it is not set up for a real pipeline yet. I will rebuild it into a production-ready version later in this tutorial, once you have seen how the pieces inside it are used.
Writing Your First Playwright Test
Before getting into locator strategy or framework architecture, it helps to see the smallest possible Playwright test end to end. Everything you build later is just a more organized version of this.
Create a new file inside your tests folder:
tests/first.spec.ts
Every Playwright test starts with the same two imports:
import { test, expect } from '@playwright/test';
testdefines a test caseexpecthandles the assertions inside it
Here is a complete, working test:
test('homepage has the right title', async ({ page }) => {
await page.goto('https://playwright.dev');
await expect(page).toHaveTitle(/Playwright/);
});
A few things worth understanding line by line:
{ page }is a fixture Playwright hands you automatically. It represents a single browser tab, already open and ready to use. You never create it yourself.page.goto()navigates to a URL and waits for the page to load before moving on.expect(page).toHaveTitle(...)checks the page title. If it does not match yet because the page is still loading, Playwright retries automatically until it does, or until it times out.
Every one of these is async, and every call to them is preceded by await. If that pattern is new to you, our explainer on what await does in Playwright is worth a quick read before going further, since nearly every line of code from here on depends on it.
Now add an interaction, so the test does more than just load a page:
test('search docs and land on a results page', async ({ page }) => {
await page.goto('https://playwright.dev');
await page.getByRole('link', { name: 'Get started' }).click();
await expect(page).toHaveURL(/.*intro/);
});
getByRole() finds the link the same way a screen reader would, by its accessible role and visible text, instead of by a CSS class that could change tomorrow. I will go deeper into why this matters in the next section.
Run this specific file:
npx playwright test tests/first.spec.ts

By default, Playwright runs headless, meaning no browser window opens. To watch it happen:
npx playwright test tests/first.spec.ts --headed

I use headed mode constantly while writing a new test, just to watch what it is doing, and switch back to headless once I trust it. CI always runs headless since there is no display to render a window on anyway.
That is the entire mental model: page gives you a tab, you act on it, you assert against it. Everything from here is about making that pattern reliable and reusable at scale, starting with how you find elements on the page.
Generating Tests Automatically with Codegen
Writing every test by hand is not the only way to start. Playwright ships a recorder called Codegen that watches you click through your actual application and writes the test for you as you go.
npx playwright codegen https://playwright.dev
This opens two windows: a real browser pointed at the URL you gave it, and the Inspector alongside it. Click, type, and navigate through the flow you want to test, and the Inspector writes working Playwright code line by line as you go, already using role- and text-based locators rather than raw CSS. When you’re done, copy the generated code straight into a spec file.

I use it constantly for two specific cases: prototyping a flow I don’t want to hand-write from scratch, and figuring out the locator for something awkward, since Codegen will tell you exactly what it would target if you clicked that element right now.
What I don’t do is ship the generated code as-is. Codegen records the literal sequence of actions you performed, including ones that don’t belong in a maintainable test, like re-clicking something you missed or navigating somewhere by accident. Treat the output as a first draft: pull the locators and structure into a Page Object, drop anything that isn’t part of the actual flow, and add the assertions the recording doesn’t know to make on its own.
That editing step is really the same judgment call as everything in the next section: which locators are worth keeping. For the full recorder walkthrough, including flags for saving auth state and targeting specific languages, see our dedicated Playwright Recorder / Codegen guide.
Choosing Locators That Do Not Break Every Sprint
The fastest way to build a flaky test suite, in any framework, is to identify elements using whatever selector happens to work today. A deeply nested CSS path or an XPath tied to exact DOM structure will pass in your PR and break the next time a developer touches that component’s markup, even if the actual UI looks identical to a user.
// Brittle: breaks the moment the DOM structure changes
await page.locator('//div[@class="login-container"]/form/div/input').fill('user');
Playwright’s answer to this is to locate elements the way a real user, or a screen reader, would: by role, label, or visible text, instead of by internal markup. This is the priority order I actually use, from most to least preferred:
getByRole()– targets accessible roles like button, link, checkbox, or heading. This is what a screen reader relies on, so it tends to survive redesigns.getByLabel()/getByPlaceholder()– built for form fields, tied to what a user sees next to the input.getByText()– matches visible copy on the page. Useful, but breaks if the copy changes for a translation or a wording tweak.getByTestId()– a dedicateddata-testidattribute. This is the escape hatch for elements with no meaningful role, label, or stable text, not the default choice.
// Resilient: targets what the user sees, not how it's built
const usernameInput = page.getByRole('textbox', { name: /email address/i });
await usernameInput.fill('qa_engineer@example.com');

I still reach for getByTestId() regularly, particularly on components a design system owns and where roles or labels are not reliable. It is not a failure to use it. The failure is reaching for a CSS class or an XPath first because it was the quickest thing to copy from DevTools.
getByRole() is the one you’ll use most, so it’s worth understanding in more depth than a priority list can cover. Our dedicated guide on getByRole() in Playwright goes through the full set of accessible roles it recognizes.
Assertions That Wait, Not Just Check
One more habit worth building alongside locators: use Playwright’s built-in assertions instead of checking a boolean yourself.
// Checks once, at that exact instant. Fails if the UI hasn't caught up yet.
expect(await page.isVisible('.alert')).toBe(true);
// Retries automatically until it's true, or the timeout is reached
await expect(page.getByRole('alert')).toBeVisible();
The first version fails constantly on anything that renders asynchronously, not because the feature is broken, but because the check ran before the UI finished catching up. The second version is what actually removes the need for manual waits, and it is the same reason page.waitForTimeout() should not appear anywhere in a Playwright suite. A fixed wait either wastes time waiting for something that already happened, or fails anyway because it did not wait long enough.
Get the locators and the assertions right, and most of what people call “flaky Playwright tests” simply stops happening. The next problem shows up once your suite grows past a handful of files: keeping all these locators from getting duplicated across every test.
Organizing Locators with the Page Object Model
Once you have more than a few test files, the same locator ends up copy-pasted into every one of them. Change one label on the login form and now you are hunting through a dozen files to fix a dozen broken tests, for a change that had nothing to do with test logic.
The Page Object Model solves this by moving locators and the actions built on them into a dedicated class, one per page or component, so every test imports from a single source instead of redefining it.
Both pages/ and fixtures/ (used in the next part of this section) live at your project root, as siblings of tests/, not nested inside it:
your-project/
├── pages/
│ └── LoginPage.ts
├── fixtures/
│ └── baseTest.ts
├── tests/
│ └── auth.spec.ts
├── playwright.config.ts
└── package.json
That’s what makes the ../pages/LoginPage and ../fixtures/baseTest import paths in the code below resolve correctly, they’re written from inside tests/, going up one level to the project root, then into the sibling folder.
Create a pages folder and build your first page object:
// pages/LoginPage.ts
import { Locator, Page } from '@playwright/test';
export class LoginPage {
private readonly page: Page;
private readonly usernameField: Locator;
private readonly passwordField: Locator;
private readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.usernameField = page.getByRole('textbox', { name: /username/i });
this.passwordField = page.getByRole('textbox', { name: /password/i });
this.submitButton = page.getByRole('button', { name: /log in/i });
}
async navigateTo(): Promise<void> {
await this.page.goto('/login');
}
async login(username: string, password: string): Promise<void> {
await this.usernameField.fill(username);
await this.passwordField.fill(password);
await this.submitButton.click();
}
}
A test can now use this instead of repeating locators:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('user can log in', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.navigateTo();
await loginPage.login('qa_engineer', 'password123');
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
That already helps, but notice every test still has to remember to write new LoginPage(page) itself. Do that across enough test files and someone eventually forgets, or instantiates it slightly differently. Playwright’s fixture system removes that step entirely.
Injecting Page Objects with Custom Fixtures
A fixture, in Playwright’s own words, is dependency injection for tests. You already use one every time you write { page } in a test signature. You can extend that same system to hand your test a fully constructed LoginPage automatically, with no manual setup line anywhere.
// fixtures/baseTest.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
type Fixtures = {
loginPage: LoginPage;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
});
export { expect } from '@playwright/test';
Now the test imports from your own fixtures/baseTest instead of @playwright/test directly, and loginPage just shows up, ready to use:
import { test, expect } from '../fixtures/baseTest';
test('user can log in', async ({ loginPage, page }) => {
await loginPage.navigateTo();
await loginPage.login('qa_engineer', 'password123');
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});

This pattern is what actually scales. Add a DashboardPage, a CheckoutPage, or any other class the same way, register it in the same fixtures file, and every test in your suite can pull in exactly the pages it needs without a single manual instantiation. For a full worked example that builds directly on the LoginPage class above, see Automate a Login Page in a Playwright Framework. Once your framework has more than a couple of these page objects, the next challenge is handling the UI patterns that do not fit neatly into a single click and fill, like content buried in Shadow DOM or a flow that opens a second browser tab.
Handling Real-World UI: Shadow DOM, Multiple Tabs, and Network Mocking
Clean login forms are not what actually breaks test suites. It is the parts of the application that do not behave like a simple page: components hidden inside Shadow DOM, a click that opens a new tab instead of navigating in place, or a third-party API you cannot control the state of. These are the cases where Playwright’s design earns its keep.
Piercing Shadow DOM Automatically
Web components often encapsulate their markup inside a Shadow DOM, which used to mean writing custom traversal logic just to reach an element buried inside one. Playwright’s locators pierce open shadow roots automatically, so you interact with a shadow element the same way you would with anything else on the page.
await page.locator('custom-video-player').getByRole('button', { name: /play/i }).click();
No shadow root reference, no manual traversal. If the shadow root is open, this just works.
Tracking a New Browser Tab
Some flows, like an “open in new tab” link or an OAuth redirect, spawn a second page instead of navigating the current one. If you do not account for this, your script keeps interacting with the original tab while the actual content loaded somewhere else.
const [newTab] = await Promise.all([
context.waitForEvent('page'),
page.getByRole('link', { name: /view terms/i }).click(),
]);
await newTab.waitForLoadState('domcontentloaded');
await expect(newTab.getByRole('heading', { name: 'Terms of Service' })).toBeVisible();
The Promise.all() here matters. You start listening for the new page event and trigger the click in the same step, so you cannot miss the event by starting to listen a moment too late.

Mocking Network Responses
This is the capability I rely on most once a suite matures. Instead of depending on a third-party API being up, fast, and in the right state, you can intercept the request and control exactly what comes back.
test('shows an error state when the API fails', async ({ page }) => {
await page.route('**/api/v1/metrics', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
await page.goto('/metrics-panel');
await expect(page.getByText(/unable to load metrics panel/i)).toBeVisible();
});
There is no way to reliably trigger a real 500 from a live third-party API on demand, so before page.route(), testing this error state usually meant it just did not get tested. Now it is one route handler.

The same method works for testing loading states, empty states, or a slow connection. Route the request, decide what comes back, and your test controls the scenario instead of hoping the real backend cooperates.
These three cases (Shadow DOM, new tabs, and network mocking) cover most of what separates a demo test suite from one that survives contact with a real application. The next thing worth getting right is deciding when a failure should stop the whole test, and when it should not.
Hard vs. Soft Assertions
By default, every assertion in Playwright is a hard assertion. The moment one fails, the test stops right there. That’s the right call when nothing after the failure can be trusted, like a login that never went through.
But not every check deserves that power. Say you are validating a dashboard with six independent metric cards. If the third one has a typo in its label, do you really want the test to skip checking cards four through six? A soft assertion logs the failure and lets the test keep running, so you get a complete picture of what broke instead of stopping at the first thing.
test('dashboard metrics render correctly', async ({ page }) => {
await page.goto('/dashboard');
await expect.soft(page.getByRole('heading', { name: /system metrics/i })).toBeVisible();
await expect.soft(page.getByText('Server Status: Operational')).toBeVisible();
// A hard assertion still stops the test if something critical fails
await expect(page.getByRole('button', { name: /download report/i })).toBeEnabled();
});
Both soft assertions run and get reported even if the first one fails. The hard assertion at the end still stops the test immediately if it fails, because a missing download button is not something you want to keep testing around.
I use soft assertions specifically for exactly this shape of test: several independent checks on the same page where one broken label should not hide problems with the other five. For anything sequential, where step two only makes sense if step one succeeded, hard assertions are still the right default. For the full range of built-in matchers beyond the two shown here, see our Playwright TypeScript Assertions reference.
Debugging a Failing Test Locally
When a test fails on your own machine, the fastest path to a fix is UI Mode, not print statements scattered through your test.
npx playwright test --ui
This opens an interactive cockpit: a tree of every test in your suite, a time-travel timeline you can scrub through action by action, a live DOM snapshot for each step, and a locator picker, all in one window. Instead of re-running a test five times to catch what happened at a specific moment, you scrub the timeline once and see it directly.

The locator picker inside UI Mode works the same way it does in the Inspector: click any element on the page and it generates the locator Playwright recommends, live against the real DOM. This is the fastest way I know to confirm a locator is matching what you think it’s matching, instead of guessing from the test code alone.

UI Mode also has watch mode, so it re-runs a test automatically the moment you save the file, which makes it the tool I reach for while actively writing or fixing a test, not just diagnosing one.
For the rare case where you need a live breakpoint, actually pausing execution mid-action and stepping forward one line at a time, the older Playwright Inspector still does that, and UI Mode does not:
npx playwright test tests/first.spec.ts --debug
I reach for --debug maybe once for every twenty times I reach for --ui, specifically when a single interaction is flaky and I need to freeze the exact moment it breaks. For everyday development and debugging, UI Mode is the default.
Whichever one you use, a failing step almost always comes down to one of three things:
- The locator does not match anything, either because it is too specific, targets the wrong element, or the page markup changed.
- The element exists but is not actionable, meaning it is hidden, covered by something else, or disabled at that point in the flow.
- The test ran ahead of the UI, acting on something before an async operation, like a fetch or an animation, finished.
Both of these tools are built for failures you can reproduce on your own machine. Neither is much use for something that only fails in CI, where you cannot open a live browser and watch it. That is a different debugging problem, and it needs a different tool. For a broader walkthrough of both tools together, see Debug a Test in Playwright.
Debugging Failures That Only Happen in CI
Nearly every Playwright team runs into this eventually: a test passes locally every time, then fails consistently in CI. CI runners are headless, slower, and configured differently than your laptop, so timing issues that never surface locally show up reliably once the test runs on a shared runner.
The fix is not to guess. Turn on trace recording for failed runs in your config:
// playwright.config.ts
export default defineConfig({
use: {
trace: 'retain-on-failure',
},
});
This records a full timeline, but only for tests that actually fail, so you are not generating trace files for thousands of passing tests and burning through CI storage for no reason.
When a test fails in CI, download the trace file from your pipeline’s build artifacts and open it locally:
npx playwright show-trace path/to/trace.zip

This gives you a DOM snapshot before and after every single action, so you can scrub through the exact moment things went wrong the same way you would step through a video. Alongside that timeline, the Trace Viewer also shows:
- Every network request the page made, with timing and status codes
- Console output and any JavaScript errors that fired during the run
- The exact locator each action targeted, and whether it resolved

This is the tool that closes the loop on “works on my machine.” You are not reproducing the CI environment to debug it. You are looking at exactly what CI saw, frame by frame, without needing to touch the pipeline again. If your suite fails in CI for reasons beyond what a single trace explains, like flaky infrastructure or environment drift, see Playwright Tests Fail in CI? Fix Common Pipeline Issues.
Common Playwright Errors and How to Fix Them
These are the errors I run into most often, across different projects and teams. Most Playwright failures fall into one of these patterns, and the error message is usually specific enough to point you straight at the cause once you know what it’s telling you.

1. Timeout waiting for locator
TimeoutError: locator.click: Timeout 30000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Submit' })
Playwright waited the full timeout and never found a matching, actionable element. Either the element genuinely never renders, or it renders later than the timeout allows. Confirm the locator with the Inspector’s picker first. If the element does appear, just later, wait for it explicitly instead of assuming:
await page.getByRole('button', { name: 'Submit' }).waitFor({ state: 'visible' });
await page.getByRole('button', { name: 'Submit' }).click();
For a deeper breakdown of this specific error, see Playwright Timeout Errors: Fix.
2. Element is not visible
Error: locator.click: Element is not visible
- element is outside the viewport
The element exists in the DOM, but it’s scrolled out of view or hidden by CSS, and Playwright refuses to act on something a real user couldn’t see either. Scroll it into view first:
await page.getByRole('button', { name: 'Load More' }).scrollIntoViewIfNeeded();
await page.getByRole('button', { name: 'Load More' }).click();
We cover four more real causes of this one in Playwright Element Is Not Visible: 5 Real Fixes.
3. Strict mode violation
Error: locator.click: strict mode violation:
getByText('Continue') resolved to 2 elements
Your locator matched more than one element, and Playwright will not guess which one you meant. This usually means the locator needs to be scoped to a specific parent, not just made more specific in isolation:
// Scope it to the dialog it lives in
await page.getByRole('dialog').getByText('Continue').click();
For the other common causes of this one, see Playwright Strict Mode Violation: Fix in 4 Real Causes.
4. Navigation timeout
TimeoutError: page.waitForURL: Timeout 30000ms exceeded.
Expected URL: "https://app.example.com/dashboard"
Received URL: "https://app.example.com/login"
The page never navigated where the test expected. This is rarely a Playwright problem. It usually means a form submission failed silently, or an auth step didn’t complete. Open the trace and check what the network tab shows the server returned after the submission, rather than assuming the click itself is what’s broken.
5. Cannot read properties of null
TypeError: Cannot read properties of null (reading 'click')
This one almost always comes from using page.$() instead of a locator. page.$() returns null immediately if nothing matches, with no retry, and calling .click() on null throws right away.
// Avoid
const button = await page.$('.submit-btn');
await button.click();
// Use a locator instead, which retries automatically
await page.locator('.submit-btn').click();
If you’ve confirmed the locator itself is correct and the element still isn’t found, Why Playwright Cannot Find Element Even When It Exists covers the less obvious causes.
6. Element is intercepted by another element
Error: locator.click: Element is intercepted by another element
<div class="modal-overlay">
The element you’re targeting is visible and in the right place, but something else, usually a modal or a loading overlay, is sitting on top of it and physically blocking the click. Wait for that overlay to disappear before interacting with what’s underneath it:
await page.locator('.modal-overlay').waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Submit' }).click();
None of these six require a workaround or a hack. Each one has a direct, permanent fix once you know what the error is describing. The pattern worth internalizing is broader than any single fix: the locator isn’t matching the right element, the action ran before the UI was ready, or the application never reached the state the test assumed it would. Once you can sort a failure into one of those three buckets, finding the fix gets a lot faster.
Authentication and Session Management
If every test in your suite logs in through the UI before it does anything else, you are paying for that login flow hundreds of times over. At even a modest 200 tests, a few seconds of login time per test adds minutes to every CI run, and it makes every single test depend on your login page working, whether or not login is what that test is about.
Playwright’s storageState solves this by letting you log in once, save the resulting session, and have every other test start already authenticated. The code below uses placeholder values, your own login URL, field labels, and credentials, swap those for your actual app before running it.
Step 1: Log In Once and Save the Session
Create a setup file that performs the login and saves the result:
// tests/setup/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('qa_engineer@example.com');
await page.getByLabel('Password').fill('TestingIsAwesome123!');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
Step 2: Have Your Other Tests Load That State
In playwright.config.ts, define the setup as its own project, then make your real test project depend on it and load the saved session:
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'authenticated tests',
dependencies: ['setup'],
use: {
storageState: 'playwright/.auth/user.json',
},
},
],
});
Every test in the authenticated tests project now starts with a browser context that already has the session loaded. There is no login step inside the test itself. (The screenshot below is from a separate demo project used to capture this exact ordering, project names differ, but the setup-runs-first pattern is identical.)

The saved file itself is just cookies and origin-scoped storage:
{
"cookies": [ /* ... */ ],
"origins": [
{
"origin": "https://app.example.com",
"localStorage": [ /* ... */ ]
}
]
}
It contains live session tokens, so add it to .gitignore immediately:
playwright/.auth/
In CI, the setup project regenerates this file fresh at the start of every run, so you are never committing a stale or shared session into version control.
Testing With More Than One User Role
Some flows genuinely need two different logged-in users at once, like verifying that an admin can see something a regular user cannot. Save a separate state file per role, then open two browser contexts in the same test:
test('admin sees content a regular user cannot', async ({ browser }) => {
const adminContext = await browser.newContext({
storageState: 'playwright/.auth/admin.json',
});
const userContext = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
const adminPage = await adminContext.newPage();
const userPage = await userContext.newPage();
await adminPage.goto('/admin');
await expect(adminPage.getByText('Admin Panel')).toBeVisible();
await userPage.goto('/admin');
await expect(userPage.getByText('Access denied')).toBeVisible();
await adminContext.close();
await userContext.close();
});
Each context is fully isolated, so the admin session and the regular user session never leak into each other, even though both are running in the same test. For patterns that go further, like testing token expiry or role-based access controls, see Playwright Auth / Security Testing.
With locators, Page Objects, and authenticated sessions all in place, the last piece is pulling everything into a playwright.config.ts that is ready for a real CI pipeline instead of the default one Playwright scaffolds for you.
Building a Production-Ready Config
The config Playwright generates on install runs tests fine, but it is not configured for the way a real pipeline behaves. Here is the version I use as a starting point on real projects, with every setting tied to something covered earlier in this tutorial.
Before pasting this in, install the two packages it depends on:
npm i dotenv
npm i --save-dev @types/node
The first gives you the dotenv import itself. The second is easy to miss: process.env is a Node.js global, and without @types/node installed, TypeScript will throw Cannot find name 'process' the moment you use it, even though the code is otherwise correct.
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '.env') });
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '100%' : undefined,
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'playwright-report/results.json' }],
],
use: {
baseURL: process.env.BASE_URL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 15000,
navigationTimeout: 30000,
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
});
A few of these choices are worth explaining rather than just copying:
retries: process.env.CI ? 2 : 0– retries are on in CI only. Retrying locally just hides a problem you should be fixing right now. In CI, they absorb infrastructure noise, like a runner that briefly stalled, without you having to manually re-trigger the whole pipeline.forbidOnly: !!process.env.CI– if anyone accidentally commitstest.only()while debugging, this fails the CI build instead of silently letting the rest of your suite skip execution in deployment.trace: 'retain-on-failure'– this is the setting from the CI debugging section earlier. It only records for tests that fail, so you get full traces without paying the storage cost across every passing test.workers: process.env.CI ? '100%' : undefined– lets Playwright use all available cores in CI, while leaving your local machine to its own default so it is not competing with everything else running on your laptop.dependencies: ['setup']– this is the auth pattern from the previous section, wired directly into the config so every browser project automatically waits for the login setup to run first, and starts already authenticated.screenshot: 'only-on-failure'andvideo: 'retain-on-failure'– the same reasoning as trace, applied to two more artifact types. Both cost disk space, so both are scoped to failures only, and both get pulled into your CI artifacts alongside the trace when something breaks.
Nothing here is exotic. Every setting maps directly to a real failure mode this tutorial has already walked through: flaky retries, storage bloat, wasted CI minutes, or a login step nobody wants to repeat five hundred times a day. That is really what a “production config” means. It is not more settings, it is settings chosen for reasons you can actually explain.
Testing on Mobile Viewports
The same projects array that runs your suite across Chromium, Firefox, and WebKit can also run it against emulated mobile viewports, using Playwright’s built-in device profiles:
import { devices } from '@playwright/test';
projects: [
// ...your existing desktop projects
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 7'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 14'] },
},
],
This is worth being precise about: it’s emulation, not real device testing. Playwright sets the viewport size, user agent, and touch support to match the device profile, which is genuinely useful for catching responsive layout bugs, but it’s still Chromium or WebKit under the hood, not the actual mobile browser engine running on real hardware. If your suite needs to validate against real devices, that requires pairing Playwright with a device cloud. For the deeper walkthrough, including which device profiles are available and how to handle touch-specific interactions, see Mobile Testing in Playwright.
Running Tests in Parallel Across CI with Sharding
Even with fullyParallel: true, a large suite running on one CI runner is still limited by that single machine’s CPU cores. Sharding splits your test suite across multiple runners at once, so a suite that takes 40 minutes on one machine can run in a fraction of that across four. If your suite is still small enough that sharding is overkill, Run Playwright Tests on GitHub Actions covers the simpler, single-job setup.
Here is a GitHub Actions workflow that shards the suite across 4 parallel jobs, then merges the results into one report. This assumes your project is already in a Git repository connected to GitHub, if it isn’t yet, run git init, create an empty repository on GitHub, then git remote add origin <your-repo-url> and git push -u origin main before continuing. The workflow file itself goes at .github/workflows/playwright.yml, relative to your project root, create the .github and workflows folders if they don’t already exist:
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 40
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- uses: actions/upload-artifact@v7
if: always()
with:
name: blob-report-${{ matrix.shardIndex }}
path: blob-report/
retention-days: 2
merge-reports:
if: always()
needs: [test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 22
- run: npm ci
- uses: actions/download-artifact@v8
with:
path: all-reports
pattern: blob-report-*
merge-multiple: true
- run: npx playwright merge-reports --reporter=html ./all-reports
- uses: actions/upload-artifact@v7
with:
name: playwright-report
path: playwright-report/
retention-days: 14

The --shard flag is what splits the work. shard=1/4 means “run the first quarter of the test files,” and Playwright divides the suite evenly across however many shards you configure. Each shard runs as its own job, on its own runner, with fail-fast: false making sure one shard’s failure doesn’t cancel the other three mid-run.
Because each shard only has its own slice of results, the second job merges them back into a single HTML report once every shard finishes, so you are not stuck opening four separate reports to see the full picture.

This is the same trace: 'retain-on-failure' and retries config from the previous section working exactly as intended here. Any test that fails gets its trace retained, uploaded as part of that shard’s artifacts, and ready to open the moment CI flags it, without you needing to touch the pipeline itself.
A Quick Checklist Before You Ship a Playwright Suite
I run through this list myself before I call any Playwright suite done. Nothing here is new, it’s just the condensed version of everything above:
- Locators target roles, labels, and text, not CSS classes or XPath. If a locator would break from a pure visual redesign with no behavior change, it’s too tightly coupled to markup.
- No
page.waitForTimeout()anywhere in the suite. Every wait should be tied to a real condition: visibility, a URL change, a network response. - Every test is self-contained. No test depends on another test having run first, or on data another test left behind. Order should never matter.
- Authentication happens once, in setup, not inside every test. If your suite logs in through the UI more than once per browser project, that’s the first thing to fix.
trace: 'retain-on-failure', nottrace: 'on'. Recording traces for every passing test burns storage for no benefit.- Retries are enabled in CI and disabled locally. A test that only passes on retry locally is a test you’re choosing to ignore.
- Soft assertions are the exception, not the default. Reach for them specifically for independent checks on one page, and use hard assertions everywhere else.
If your suite already does all seven, you are ahead of most teams I have audited. If it does not, that gap is usually exactly why the suite feels unreliable, no matter how much code has been written to work around it.
Frequently Asked Questions
What is Playwright and why should I use it?
Playwright is an open-source browser automation and testing framework built by Microsoft. It runs a single set of test scripts across Chromium, Firefox, and WebKit, communicating directly with the browser engine instead of through a driver, which is what makes it faster and less prone to the flaky-test problems older tools struggle with.
Is Playwright free to use?
Yes. Playwright is completely free and open source under the Apache 2.0 license. There’s no paid tier for the framework itself.
Which programming languages does Playwright support?
JavaScript, TypeScript, Python, Java, and .NET, with the same core capabilities across all of them. A team isn’t locked into one language just because they chose Playwright.
How do I install Playwright?
Run npm init playwright@latest in a Node.js project (Node 22 or higher). It walks you through a few setup choices and scaffolds a working project, including sample tests and browser binaries, in one step.
Can Playwright tests run in CI/CD pipelines?
Yes, and it’s built for this. Playwright has native support for parallel execution and test sharding across multiple CI jobs, plus built-in trace, screenshot, and video capture for diagnosing failures that only happen in CI.
Is Playwright better than Selenium or Cypress?
It depends on what you need. Playwright’s direct browser connection makes it faster than Selenium’s WebDriver-based architecture, and it supports true multi-tab and cross-browser testing in ways Cypress doesn’t. That said, Selenium still has the broadest browser and language ecosystem, and Cypress has a strong developer experience for single-origin frontend testing. For most new cross-browser end-to-end automation, Playwright is the stronger default today.
Is Playwright beginner-friendly?
Yes. The API is straightforward, and features like Codegen (which records your clicks and generates working test code) and UI Mode (which shows you exactly what a test did, step by step) make it easier to get started than older automation tools, even without deep JavaScript experience.
Does Playwright support mobile testing?
It supports mobile browser emulation, matching a real device’s viewport, user agent, and touch behavior, using built-in device profiles. It does not automate native mobile apps or run on real device hardware directly; that requires pairing it with a third-party device cloud.
Closing Thoughts
I opened this Playwright automation tutorial talking about the years I spent treating flaky tests as normal. They aren’t, and once you’ve worked with a framework that talks directly to the browser instead of relaying commands through a driver, going back to the old way is hard to justify.
Everything in this tutorial reflects how I actually approach Playwright testing on real projects: the architecture that makes Playwright fast, locators that survive a redesign, Page Objects and fixtures that scale past a handful of files, the edge cases that break simpler frameworks, and the errors you will genuinely hit once this is running in CI against a real application.
Start with the first test in this tutorial if you haven’t already. Everything after it builds on that same foundation, one piece at a time. Once this framework is running in a real project, our Enterprise Playwright Automation Framework series picks up from here, going into self-healing locators, retry mechanisms, and reporting at scale.