Home / Blog / Playwright tutorial
Playwright tutorial: from the example test to a suite you can still run in six months
The official quickstart gives you a passing example test. This tutorial is the hour after it: where the spec files go, how the suite signs in to your own application, what a fixture is for before you need one, and what to do the first morning a test goes flaky. Everything below runs on Playwright 1.62.
The first test in the documentation navigates to playwright.dev and checks that
the page title contains the word Playwright. It proves the installation worked. It says nothing
about your product, and the gap between that test and one that covers your own checkout is
where a suite either becomes something a team relies on or something they quietly stop running.
If Playwright is not settled yet, back up one page to
the definition and the limits.
What does npm init playwright@latest actually leave in your repository?
Four files and one folder. The installation guide walks the command itself, so this page will
not: read it there, answer the four prompts, and
come back. It writes playwright.config.ts, a package.json, a lockfile,
and tests/example.spec.ts with one test in it. The documentation also
names the Node requirement, and it is narrower than most projects expect: the latest 22.x, 24.x
or 26.x.
Playwright 1.62 is the version behind every command and sample here, and each of them was run against a small local application before this page shipped.
The example test has three things your suite is going to need and does not have: a login, a database with something in it, and an address it is allowed to point at. Filling those three gaps is where the decisions live, and the expensive ones are expensive because they are cheap to make and slow to unmake.
The config file is where most of them end up, so here it is with the answers already in it. Nothing below is explained yet; the rest of the page fills it in.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Playwright 1.62 · TypeScript · runs
A project is a named configuration, and the browser matrix is the usual reason to have more
than one: add a firefox and a webkit entry and the same specs run
three times. A project can also carry a phone's screen size, user agent and touch input.
Playwright drives browsers, so that covers your site opened on a phone, and it does not cover
an iOS or Android application — nothing in the config changes that.
Where should the tests live: in the app repository, or their own?
Put them in the application repository. A test and the change that breaks it then arrive in the same pull request, one reviewer sees both, and the suite cannot go on describing a version of the product that no longer exists. A separate repository has nothing that keeps those two in step, and the teams who try one end up rebuilding that link by hand.
That has a price and it is paid in CI. Every repository that runs the suite needs browsers installed in its pipeline, which is either a Playwright container image or an install step, and it is a slower job than the unit tests that were there before. Budget for it once and it stops being interesting.
testDir decides where inside the repository. A tests/ directory
beside src/ is the default and it is fine; the shape underneath it matters more. A
tree organised by user journey answers the question people ask in a planning meeting, which is
whether checkout is covered.
baseURL is the line that makes the choice portable. With it set, every navigation
in the suite is a path — await page.goto('/checkout') — and pointing the whole
suite at a review deployment is one environment variable. Skip it and every spec carries an
absolute URL that somebody has to go and find on the day the environment moves.
webServer is the other half: the runner starts your application before the tests
and reuses one that is already up, so a developer who has just cloned the repository types one
command and it works.
One situation changes the answer. Where several applications are under test, or where a QA team works to a release cadence the developers do not share, one suite in one place beats the same fixtures copied into four repositories and drifting apart. Everything else about this decision is preference; the reversal cost is not. Moving a suite later means moving the repository, then every pipeline, secret and readme that pointed at the old path.
How does the suite log in to your own application?
This is the first wall and it arrives the moment you point a test at anything real. The
documentation's answer is a setup project: one file signs in once, writes the browser state to
disk, and every other project starts from that state already authenticated. A
beforeEach that fills the login form is not what the docs recommend, and the reason
is arithmetic — the form gets driven once per suite instead of once per test.
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('sign in once', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.E2E_EMAIL!);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// A login can set its cookies across several redirects, so wait for
// the destination and for something only a signed-in user sees.
await page.waitForURL('**/dashboard');
await expect(page.getByRole('link', { name: 'Sign out' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
Playwright 1.62 · TypeScript · passes
The config from earlier does the rest of the work. The
{ name: 'setup', testMatch: /.*\.setup\.ts/ } project collects that file, and
dependencies: ['setup'] on the browser project makes it run first and makes the
browser project wait for it to pass. The
storageState: 'playwright/.auth/user.json' line is what turns the file on disk
into a signed-in browser for every test. If the setup fails, the dependent project does not run
at all, which is the right failure: a suite of two hundred red tests because nobody could log in
tells you less than one red setup test.
Each of these is cheaper to know now than to meet in a red build.
The stored state expires, and the failure looks like a broken test. The docs
say what that costs you: the stored state has to be deleted when it expires. They also offer
the cheap way out, which is to write the state under the project's
outputDir, a directory that is cleaned automatically before every run — you pay
one sign-in per run and never think about staleness again. Nobody has published a sensible
refresh interval, and this page will not invent one either.
The credentials and the state file stay out of the repository. The sample
above reads two environment variables, and the docs are blunt about the file it writes: the
browser state may contain cookies and headers that could be used to impersonate the test
account, and they strongly discourage checking them into a repository, private or public. The
recommended shape is a playwright/.auth directory added to
.gitignore.
A second role is a second state file, and not a second suite or a second
config. Add a second setup() call in the same file that writes to
playwright/.auth/admin.json, then declare which one a spec wants at the top of the
file with test.use({ storageState: 'playwright/.auth/admin.json' }). Teams that
miss this end up with an admin folder that duplicates the config, and the two copies disagree
within a month.
That is the code done, and the slow part has not started. The suite needs an address it is allowed to sign in to and write to, and getting one takes longer than writing the tests: it belongs to whoever owns environments, it needs a test account that survives a database refresh, and on a shared staging box it needs an agreement that nobody wipes it in the middle of a run. Start that conversation on the day you write the setup file, not on the day the suite is finished.
Which locator do you commit to before there are two hundred of them?
Prefer the locators built on what a person can see and read. Playwright's best-practices guide
gives that a heading of its own, Prefer user-facing attributes to XPath or CSS
selectors, and the reason underneath is the one that bites: "Your DOM can easily
change so having your tests depend on your DOM structure can lead to failing tests." In
practice that means getByRole with a name for anything somebody clicks or reads,
and getByLabel for form fields.
Our own policy on where to stop is on the automation service page and it is the same sentence
here: we reach for a data-testid where the accessible name is generated, where the
text is translated, and where the element belongs to a component library nobody on your side
controls. Adding one edits the application, so it goes through review like anything else.
Write the rule down now, in the repository, in five lines. At forty specs a change of policy is a morning's work. At four hundred it is a project nobody schedules, so the suite carries three conventions at once and the next person to review a spec has no way to know which one a given line was following. Which locator suits which markup, and what to do when an element has no name at all, is the subject of Playwright locators.
What is the suite allowed to assume about the data?
Nobody makes this decision. It gets made by whoever writes the second test, and then it is the suite's architecture. The answers in circulation are not equally survivable.
Assume a fixed seeded dataset. The database is loaded with known records and every test knows the account number. It is the fastest thing to write and it holds until the first test that writes to a seeded record. After that, the spec that reads it passes or fails depending on which worker got there first, and because Playwright runs spec files in parallel by default that ordering changes between runs. The failure arrives months after the cause.
Create what the test needs over the application's API, then remove it. This is the one that survives. Each spec owns its own record, so two specs cannot collide, the suite can be re-run in any order, filtered to one file, or split across CI machines. It costs you a dependency: an endpoint that can create the state and a token allowed to call it. On many products that endpoint exists for the mobile client already. Where it does not, asking a backend team for one is a smaller request than it sounds, and it changes what the suite can be.
Create it through the user interface. It walks the same path a customer walks, which is the argument for it. It is also slow, and it ties every test to every screen in the setup path: a broken login page now fails a test about invoicing, and the report blames invoicing. Keep it for the one spec whose subject is the creation flow itself.
Start on the API route and fall back only where there is no endpoint. Two things have to be true for it: something can create the record, and something can delete it afterwards. If the product has no delete, decide today whether the suite is allowed to leave rows behind, because the answer given by silence is yes.
What is a fixture for, before you need one?
You have three specs and each one opens with the same four lines that build the thing the test
is about. A fixture is where those four lines go. The signing-in half is already gone —
storageState took care of that — so what is left repeating is the record.
// tests/fixtures.ts
import { test as base, expect } from '@playwright/test';
type Fixtures = {
invoiceId: string;
};
export const test = base.extend<Fixtures>({
invoiceId: async ({ request }, use) => {
const created = await request.post('/api/invoices', {
data: { customer: 'Northwind Traders', amount: 4200 },
});
expect(created.ok()).toBeTruthy();
const { id } = await created.json();
await use(id);
await request.delete(`/api/invoices/${id}`);
},
});
export { expect };
Playwright 1.62 · TypeScript · passes
// tests/invoice-detail.spec.ts
import { test, expect } from './fixtures';
test('an invoice opens from its own URL', async ({ page, invoiceId }) => {
await page.goto(`/invoices/${invoiceId}`);
await expect(page.getByRole('heading', { name: 'Invoice' })).toBeVisible();
});
Playwright 1.62 · TypeScript · passes
The spec asks for invoiceId in its argument list and an invoice exists. The call
to use() is the seam: the record is created before it and deleted after it, in one
function, so the cleanup cannot drift into an afterEach in another file and be
forgotten there. A spec that does not ask for the fixture never pays for it, which is the
difference between this and a hook.
One option is easy to reach for and easy to regret. A fixture declared with
{ scope: 'worker' } is built once for a whole worker process, and the docs add a
detail people trip over: a worker fixture also gets its own timeout, equal to the default test
timeout. The saving is real and so is the coupling, because from then on everything that worker
runs is looking at the same record. Reserve it for something the suite only ever reads. How
fixtures compose, override each other and take options is
a longer subject.
What runs on every pull request, and what runs nightly?
A suite that takes twenty minutes stops being run before a merge, and a suite nobody runs before a merge is documentation. So split it: a small set that must pass before anything merges, and everything on a schedule. Tag the small set in the test title's options object.
// tests/pay.spec.ts
import { test, expect } from '@playwright/test';
test('a customer can pay an invoice', { tag: '@smoke' }, async ({ page }) => {
await page.goto('/invoices/inv-1');
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByRole('status')).toHaveText('Paid');
});
Playwright 1.62 · TypeScript · passes
npx playwright test --grep @smoke
npx playwright test --grep-invert @smoke
Playwright 1.62 · CLI · both run
Tags have to start with an @ symbol, an @tag written into the title
itself works the same way, and several tags go in as an array. Once the split has stopped being
a flag somebody remembers to type, move it into the config as two projects, so each side can
carry its own retry count.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
projects: [
{ name: 'smoke', grep: /@smoke/, retries: 0 },
{ name: 'nightly', retries: 2 },
],
});
Playwright 1.62 · TypeScript · runs
Then npx playwright test --project=smoke before a merge and
--project=nightly on a schedule. The nightly project has no filter, so it runs the
smoke specs too, which is what you want: the tagged ones are a subset selected for speed rather
than a separate suite. Zero retries on the smoke set is deliberate — a merge gate that hides a
failure by running it again is not a gate.
Filters select the tests you named, and if those tests sit in a project with dependencies then
the dependencies run too, which is documented and still catches people out. So
--grep @smoke also runs the sign-in setup, and that is why the smoke run works at
all. Pass --no-deps when you want only what you asked for.
Nothing about pipelines is decided here. The workflow file itself belongs to running Playwright in GitHub Actions, and the moment the nightly run outgrows one machine, to splitting a run across CI jobs.
When this bites you: the morning the first test goes flaky
It failed in CI. It passes on your machine. The summary line says 1 flaky, and
that word is a category the runner assigns, with a definition in the documentation behind it: a
test that failed on its first run and passed on a retry. Nothing about your code changed in
between.
Read the trace before you touch a line. It is already being recorded, because
trace: 'on-first-retry' went into the config in the first section for exactly this
morning, and the runner prints the command that opens it at the bottom of the failing job:
Error: expect(locator).toHaveText(expected) failed
Locator: getByRole('status')
Expected: "Paid"
Received: "Unpaid"
Timeout: 2000ms
Call log:
- Expect "toHaveText" with timeout 2000ms
- waiting for getByRole('status')
19 × locator resolved to <p role="status">Unpaid</p>
- unexpected value "Unpaid"
Playwright 1.62 · the failure as the runner printed it
The line to read is the one with the multiplication sign in it. Playwright's web-first matchers re-fetch and re-check until the condition is met or the timeout expires, so a failure means it kept looking and kept finding the same thing — nineteen times, on that run, in two seconds. That rules out a slow page immediately, and it points at the assertion instead: this test was waiting for a value that was never going to arrive. The generic matchers look identical and do not wait at all, so check which of them retry and which run once first.
Then check whether two tests were fighting over one record. That is the data decision from earlier arriving to be paid for, and the tell is a spec that passes alone and fails inside the full run.
Turning retries up is available and it does not fix anything: it converts a failure into a warning, and six weeks later nobody can say which specs are living on them. The systematic version of this morning — reproducing a flake on purpose, splitting local from CI, and sorting every intermittent spec into a cause — is a whole method of its own.
Every decision on this page is one your own team can make, and the usual reason to hand it over is that nobody has the week. When that is the situation, building the end-to-end suite is something we do, and it starts by settling exactly these choices in the open, against one flow, before the rest of the list is touched.
Questions
How long does it take to get a Playwright suite running against a real application?
There is no number here worth giving you, and the length turns on three things, none of them the flow count. How the login works, because a form and a cookie is the easy case and single sign-on through a provider you do not control is a piece of work on its own. Whether an API can create the state a test starts from, or whether the only way there is four screens of clicking. And whether an environment exists that the suite is allowed to write to, which is usually the slowest of the three because somebody else owns it. Settle those three and the flow count starts meaning something.
Do I need the page object model to start?
No. Write the first ten specs with the locators inline and let the repetition tell you what is worth extracting. A page object written before anything repeats is a guess about which parts of the application will move, and a wrong guess becomes a file everyone edits and nobody trusts. Whether the pattern is worth the layer at all is argued on its own page and not here.
Should the tests live in the app repository or their own?
The application repository, unless something specific pushes you out of it. A test and the change that breaks it then arrive in the same pull request and one reviewer sees both. The case that flips it is more than one application under test, or a QA team working to a release cadence the developers do not share, where one suite in one place beats the same suite copied into four repositories. Reversing the choice later is a repository move plus every pipeline, secret and readme that pointed at the old path.
The first CI run went red. What do I open first?
The trace, before you press re-run. The failing job prints the command that opens it, and the call log inside names the locator and reports what it kept seeing while it waited. Two causes turn up over and over once you can read that. The two machines ran a different number of workers, so two specs that share one record met each other for the first time. Or the stored login state had expired, and everything after the setup failed on a screen nobody has ever looked at.
Does any of this change if we write the tests in Python or Java?
Some of it does. The questions stay the same, and so do the answers about where the login state comes from, what the base URL is and what a failure leaves behind. The config file above does not travel: it belongs to the Node test runner, which ships only with the JavaScript and TypeScript binding. Python installs the Pytest plugin with pip install pytest-playwright and reaches the same settings through pytest fixtures and command-line flags. Java is distributed as a set of Maven modules you add to a pom.xml and runs under JUnit or TestNG. Projects, the retries option, the HTML reporter and the on-first-retry trace setting have no direct equivalent in either. Firm86 delivers in all four bindings, and the TypeScript on this page is a house rule about samples that says nothing about the language a client's repository gets.
Which flows must never break, and what does your login look like?
The flow list and the login shape everything above. Send the ranked list, even if it is four lines in an email, and say whether signing in is a form, single sign-on through a provider, or something with a one-time code in it. The login decides how much of the rest is easy, and it is the question we would ask first anyway.