What you'll learn
Quick Answer
Playwright is a browser-automation library from Microsoft for end-to-end testing. It drives a real browser - Chromium, Firefox, or WebKit - and its defining feature is auto-waiting: locators and expect assertions retry until the page is ready, so you almost never write manual delays. A first test is four lines: go to a page, find elements by their role, act, and assert.
What end-to-end testing with Playwright means
A unit test checks one function in isolation. An end-to-end (E2E) test checks the whole stack the way a user experiences it: it opens a page in a real browser, clicks a real button, and asserts on what actually renders. If the API is down, form validation is broken, or a CSS change hid the submit button, an E2E test catches it. A unit test never would.
Playwright is the tool that drives the browser. It ships three engines - Chromium (Chrome and Edge), Firefox, and WebKit (the engine behind Safari) - and one test file runs against all three.
The @playwright/test package is a complete test runner. It has its own test() and expect(), runs files in parallel, retries failures, and records a trace you can replay step by step. You do not bolt Playwright onto Jest or Mocha - for this layer of testing it replaces them.
Writing your first test
Install the runner and download the browsers:
npm init -y
npm i -D @playwright/test
npx playwright install chromium
Most projects start with npm init playwright@latest instead, which also scaffolds a playwright.config.js and an example spec. Either way, a test looks like this:
const { test, expect } = require('@playwright/test');
test('a visitor can add a todo', async ({ page }) => {
await page.goto('http://localhost:4399/');
await page.getByLabel('New todo').fill('Buy milk');
await page.getByRole('button', { name: 'Add' }).click();
await expect(
page.getByRole('listitem').filter({ hasText: 'Buy milk' })
).toBeVisible();
});
The page fixture is a fresh, isolated browser tab per test. getByLabel and getByRole return locators - lazy references to elements, not the elements themselves, so nothing is queried until you act or assert on them. expect(locator).toBeVisible() is a web-first assertion: it keeps re-checking until the element appears or the timeout is hit.
Run everything with npx playwright test. It executes headless and in parallel across files, prints a pass/fail list, and on any failure writes an HTML report - open it with npx playwright show-report - with the error, a stack trace, and a screenshot at the moment it failed.
Locators: find elements the way a user does
Playwright pushes you to select elements by what they mean, not by their markup. The recommended locators, roughly in order of preference:
getByRole('button', { name: 'Add' })- the accessibility role plus its accessible name. It survives refactors and doubles as an accessibility check.getByLabel('New todo')- a form control by its<label>text.getByText('My Todos')- visible text. The match is a substring by default, sogetByText('Todos')also matches "My Todos". Pass{ exact: true }to require the whole string.getByTestId('cart')- adata-testidattribute, for when nothing semantic is stable.
Locators chain and filter: page.getByRole('listitem').filter({ hasText: 'Buy milk' }) narrows a list to one row. Compared with brittle CSS like .sidebar > div:nth-child(3) button, role-based locators keep passing when the design changes but the behaviour does not - which is exactly what you want a test to track.
When nothing semantic fits, page.locator() is the escape hatch and still takes a CSS or XPath selector. Reach for it last: a test that depends on class names breaks every time someone refactors the styles, and those failures teach the team to distrust the suite.
Auto-waiting is the whole point
In older tools you scatter sleep(2000) everywhere and tests still fail at random. Playwright removes almost all of that.
Before any action, Playwright runs actionability checks: it waits for the element to be attached to the DOM, visible, stable (not animating), enabled, and not covered by another element. Only then does it click or type. If the element never becomes ready, the action fails with a clear message instead of silently clicking nothing.
Assertions behave the same way. In a test app where a new list item is added 400ms after the click - simulating a network save - this passes with no explicit wait:
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.locator('#count')).toHaveText('1 item');
toHaveText re-polls until the text matches or the assertion timeout (5 seconds by default) expires. You describe the end state you expect and Playwright waits for reality to catch up. Manual waits become the rare exception, used only for genuinely unusual timing.
The mistakes that make tests flaky
Three errors account for most beginner pain.
1. Using isVisible() when you mean toBeVisible(). The method await locator.isVisible() returns a boolean for right now - it does not wait. Called immediately after a click that triggers an async update, it returns false even though the element is about to appear. The assertion await expect(locator).toBeVisible() retries. Use the assertion; reach for the boolean method only when you truly want an instantaneous check.
2. Forgetting await. Every Playwright call is async. expect(page.getByRole('heading')).toHaveText('Home') without await returns a pending promise, the test moves on, and a real failure is swallowed - or resurfaces later as an unhandled rejection in a different test. Enable the @typescript-eslint/no-floating-promises rule or the eslint-plugin-playwright package to catch this.
3. Locators that match more than one element. If getByRole('button', { name: 'Delete' }) resolves to two buttons, Playwright throws strict mode violation: ... resolved to 2 elements rather than guessing which one you meant. Narrow it with .first(), .nth(1), or .filter(). The error is the tool protecting you from a test that would otherwise be non-deterministic.
Running, debugging, and CI
The commands you will actually use:
npx playwright test- run everything headless, in parallel.npx playwright test --ui- interactive mode with a time-travel view of every step.npx playwright test --debug- step through a test with the Playwright Inspector.npx playwright codegen localhost:4399- click around a page and Playwright writes the locators and actions for you.npx playwright show-trace- open a recorded trace (screenshots, DOM snapshots, network, console) after a failure.
For CI, let the config start your app so tests never race an unready server:
// playwright.config.js
module.exports = {
webServer: {
command: 'npm run start',
url: 'http://localhost:4399',
reuseExistingServer: !process.env.CI,
},
};
The classic "passes locally, fails in CI" causes are: CI runs headless while you ran headed, a different viewport size, and slower machines exposing timing assumptions. Role-based locators plus the default auto-waiting fix most of it, and turning on retries: 2 in the config for CI runs distinguishes a genuinely broken test from a flaky one - a test that fails then passes on retry is flagged as flaky rather than red.
