Playwright Tutorial for Beginners: Your First Test (2026)

16 min readupdated September 10, 2026

You install Playwright, write your first test, and it fails. The error says the button was not found. You open the app yourself and the button is right there. So what happened?

A test can fail because it looked before the page was ready. Repeated timing failures can reduce trust in end-to-end testing. Playwright waits until elements are ready before acting. Its web-first assertions also retry.

Playwright is a test automation framework made by Microsoft. It drives a real browser, so your test clicks buttons, fills forms and reads results the way a person does. This tutorial takes you from an empty folder to a suite you can trust in CI.

You do not need any setup to follow along. Every example runs in the TestAcademy editor.

What Playwright is, and why teams pick it

Playwright runs your test in Node.js and controls the browser over a websocket connection. Three things make teams choose it.

It waits for you. Before every click, Playwright checks that the element is attached, visible, stable and enabled. You do not write the waiting yourself.

It runs on all three engines. Chromium, Firefox and WebKit, from one test file. WebKit is the engine behind Safari, so you get Safari coverage without a Mac.

It is fast. Tests run in parallel by default, and each one gets a fresh browser context instead of a fresh browser.

How Playwright works: browser, context and page

Three words appear everywhere in Playwright. Learn them now and the rest of the API makes sense.

A browser is one running browser process. Starting one is slow, so Playwright starts it once and reuses it.

A context is an isolated session inside that browser. It has its own cookies, its own local storage and its own cache. Think of it as a private window. Creating one takes milliseconds.

A page is a single tab inside a context.

This is why Playwright is both fast and clean. Every test gets its own context, so no test can see another test's login or leftover data. You get isolation without paying to restart the browser each time.

Browser (started once)
└── Context (one per test, isolated)
    └── Page (a tab)

Install Playwright and set up a project

You need Node.js installed. Playwright supports the latest 22.x, 24.x and 26.x releases.

Make a folder for your project, then run one command:

npm init playwright@latest

It asks four questions:

  1. TypeScript or JavaScript. Pick TypeScript. You get autocomplete on every locator and assertion, which matters more than you expect when learning.
  2. Where to put tests. The default is a tests folder.
  3. Add a GitHub Actions workflow. Say yes. It writes a working CI pipeline you would otherwise spend an afternoon on.
  4. Install the browsers. Say yes. This downloads Chromium, Firefox and WebKit, about 300MB.

If you skip the browser download, or you clone a project that already uses Playwright, install them later:

npx playwright install

If you see "Executable doesn't exist", check whether you skipped this step. This guide explains the fix.

What the installer creates

You now have four things:

playwright.config.ts     how tests run
package.json             your dependencies
package-lock.json        exact versions
tests/example.spec.ts    a sample test

The config file is the one worth reading. Here is the part you will actually change:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
  ],
});

Four settings do most of the work:

  • baseURL lets you write page.goto('/login') instead of the full address. Change one line to point the suite at staging.
  • fullyParallel runs test files at the same time.
  • retries re-runs a failed test. Two retries in CI, none locally, is a sensible default.
  • trace: 'on-first-retry' records what happened when a test fails. More on that below, because it is the feature that will save you the most time.

A project is a named way of running the whole suite. The three above run every test on each engine.

Your first test, line by line

Here is a complete test. Read it, then we will take it apart.

import { test, expect } from '@playwright/test';

test('user can sign in', async ({ page }) => {
  await page.goto('/');
  await page.getByLabel('Email').fill('demo@nimbus.app');
  await page.getByLabel('Password').fill('secure123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByTestId('welcome-message')).toContainText('Welcome back');
});

Line 1 imports two things. test defines a test. expect makes a check.

Line 3 declares the test. The first argument is the name. Write it as a behaviour, so a failure reads like a bug report. "user can sign in" tells you what broke. "test 2" does not.

The { page } part is a fixture. A fixture is a ready-made object Playwright hands your test. Ask for page and you get a fresh tab in a fresh context. You never create or close it yourself.

Line 4 opens the app. Because baseURL is set, '/' is enough.

Lines 5 and 6 find each input by its visible label and type into it.

Line 7 finds the button by its role and its name, then clicks it.

Line 8 checks the result. This is the assertion, and it is the only line that can fail the test.

Notice that every line has await. Every Playwright action returns a promise. Leave out await and your test races ahead of the browser. It may then fail somewhere unrelated.

Locators: how to find elements

A locator is a description of how to find an element. It is not the element itself. That distinction matters: a locator is re-evaluated every time you use it, so it still works after the page re-renders.

Playwright gives you seven ways to build one:

page.getByRole('button', { name: 'Sign in' }) // role and visible name
page.getByLabel('Email')                      // form label
page.getByPlaceholder('Search products')      // placeholder text
page.getByText('Welcome back')                // visible text
page.getByAltText('Company logo')             // image alt text
page.getByTitle('Close')                      // title attribute
page.getByTestId('cart-count')                // data-testid attribute

Which should you use? The official guidance is to prefer user-facing attributes over CSS or XPath, because CSS and XPath depend on implementation details that change.

In practice, use this order:

  1. getByRole for anything interactive. It matches what a screen reader announces, so it doubles as an accessibility check.
  2. getByLabel for form fields.
  3. getByTestId when there is no honest role or label.
  4. page.locator('.price') with CSS only as a last resort.

I have watched a team lose most of a day to a suite built on nth-child selectors after a designer added one wrapper div. Forty tests went red and not one of them had a real bug behind it.

Narrowing down. When a locator matches more than one element, chain and filter instead of reaching for an index:

page.getByTestId('product-card')
  .filter({ hasText: 'Alpine Tent' })
  .getByRole('button', { name: 'Add to cart' });

If a locator matches two elements and you try to click it, Playwright refuses and throws a strict mode violation. That feels annoying on day one. It is protecting you from a test that silently clicks the wrong row. Here is how to read and fix that error.

Actions: how to interact

Once you have a locator, act on it:

await locator.click();               // click
await locator.dblclick();            // double click
await locator.fill('some text');     // set an input value
await locator.clear();               // empty an input
await locator.press('Enter');        // press a key
await locator.check();               // tick a checkbox
await locator.uncheck();             // untick it
await locator.selectOption('DE');    // choose from a select
await locator.hover();               // move the mouse over it
await locator.setInputFiles('a.pdf') // attach a file

Use fill rather than typing character by character. It sets the value and fires the right events in one step, and it is much faster.

Before any of these run, Playwright checks that the element is attached to the DOM, visible, stable, able to receive events and enabled. If a modal is covering your button, the click waits rather than landing on the overlay.

Assertions: how to check the result

An assertion is the point of the test. Playwright assertions start with expect and retry until they pass or time out:

await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toHaveText('Order confirmed');
await expect(locator).toContainText('confirmed');
await expect(locator).toHaveValue('demo@nimbus.app');
await expect(locator).toHaveCount(8);
await expect(locator).toBeEnabled();
await expect(locator).toBeChecked();
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle(/Dashboard/);

Add .not to check the opposite:

await expect(page.getByTestId('error')).not.toBeVisible();

One rule saves most beginners. Use the retrying assertions above, not a plain boolean check:

// Wrong. isVisible() answers immediately, before the app has responded.
expect(await page.getByTestId('toast').isVisible()).toBe(true);

// Right. toBeVisible() retries until it is true or the timeout is reached.
await expect(page.getByTestId('toast')).toBeVisible();

The first version passes on a fast machine and fails in CI. The second is stable in both.

Auto-waiting, and why you rarely need a sleep

Old test tools checked the page once, immediately. Anything slower than the test failed, so people added sleeps everywhere. That makes a suite both slow and unreliable, because a fixed wait is a guess about a number that changes.

Playwright waits in two places:

  • Before an action, until the element is ready to be acted on.
  • Inside an assertion, retrying the check until it passes or the timeout ends.

So this works even when signing in takes three seconds:

await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('welcome-message')).toBeVisible();

If you find yourself writing waitForTimeout, stop and find the condition you are really waiting for. Use a retrying assertion when it expresses that condition. The full comparison across tools is in waiting in Cypress, Playwright and Selenium.

Running your tests

npx playwright test                          # everything, headless
npx playwright test --ui                     # UI mode, the best way to develop
npx playwright test --headed                 # watch the browser
npx playwright test tests/login.spec.ts      # one file
npx playwright test --project=chromium       # one browser
npx playwright test -g "sign in"             # tests matching a name
npx playwright show-report                   # open the last HTML report

Headless means the browser runs without a visible window. It is the default and it is what CI uses.

Debugging: the three tools that matter

This section is worth more than the rest of the tutorial combined. Most people never learn these and debug by adding print statements.

UI mode is where you should write tests:

npx playwright test --ui

You get a time-travel view of every step. Hover a step and see the page as it was at that moment. Edit the test and it re-runs on save. There is also a locator picker that highlights what your selector matches.

Trace viewer is how you debug a failure you cannot reproduce, usually one that only happens in CI. With trace: 'on-first-retry' in your config, a failing test records a full trace on its retry. Download it from the CI artifacts and open it:

npx playwright show-trace trace.zip

You get the DOM snapshot before and after every action, the network log, the console and a screenshot timeline. The official guidance is to use the trace viewer exactly this way for CI failures.

Codegen writes a first draft for you:

npx playwright codegen localhost:3000

A browser opens and records what you do as a test. Do not ship what it produces without editing it. Treat it as a way to discover the right locator when an element is awkward.

Grouping tests and shared setup

test.describe('Login page', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('shows validation errors for an empty form', async ({ page }) => {
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page.getByTestId('email-error')).toBeVisible();
  });

  test('rejects a wrong password', async ({ page }) => {
    await page.getByLabel('Email').fill('demo@nimbus.app');
    await page.getByLabel('Password').fill('wrong');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page.getByTestId('form-error')).toContainText('incorrect');
  });
});

beforeEach runs before each test in the group. Use it for navigation and setup you repeat, not for creating data one test depends on.

Test isolation, and why every test starts clean

Each test gets its own browser context. That means its own cookies, its own local storage and no memory of any other test.

This is deliberate. The official guidance is that every test should run independently. A suite where test 4 only passes after test 3 has run is a suite you cannot run in parallel, cannot re-run a single test from, and cannot trust when it goes red.

The rule: a test must create everything it needs. If it needs a saved contact, it creates one. It never assumes an earlier test left one behind.

Logging in once, instead of in every test

Isolation raises an obvious problem. If every test starts logged out, does every test have to sign in through the form? That would be slow.

The answer is storageState. Sign in once in a setup step, save the cookies and local storage to a file, then load that file for every test.

// auth.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('demo@nimbus.app');
  await page.getByLabel('Password').fill('secure123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByTestId('welcome-message')).toBeVisible();

  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

Then point your tests at that file in the config, and they all start signed in. Keep testing the login form itself in its own test, without the saved state.

Mocking network requests

Sometimes the thing you are testing depends on a service you do not control. A payment provider, a maps API, a partner feed. The official guidance is blunt: only test what you control, and mock the rest.

page.route intercepts a request before it leaves the browser:

test('shows a message when the catalogue is empty', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    await route.fulfill({ json: [] });
  });

  await page.goto('/shop');
  await expect(page.getByText('No products found')).toBeVisible();
});

This is also the only sane way to test error states. Returning a 500 from a real server on demand is hard. Faking one takes two lines:

await page.route('**/api/products', (route) => route.fulfill({ status: 500 }));

Cross-browser and mobile

The three projects in your config already run every test on Chromium, Firefox and WebKit. To emulate a phone, add another project:

projects: [
  { name: 'chromium',      use: { ...devices['Desktop Chrome'] } },
  { name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
],

devices sets the viewport size, the user agent and touch support. It is emulation, not a real handset, so it catches layout and touch problems but not device-specific rendering bugs.

Running in CI

If you accepted the GitHub Actions option during setup, you already have a working pipeline. The parts that matter in any CI system:

  • Install browsers with npx playwright install --with-deps. The --with-deps flag adds the Linux system libraries the browsers need.
  • Set retries: 2 for CI only, so a single blip does not fail the build.
  • Turn on tracing for retries, then upload the report and traces as artifacts.
  • Run on Linux. It is the cheapest option on every provider.

The official advice is simply to run them often. A suite that runs on every pull request finds problems while the change is still fresh in someone's head. We cover sharding and what to measure in running tests in CI.

Common errors and what they mean

Error What it means Fix
Executable doesn't exist Browsers were never downloaded npx playwright install
strict mode violation Your locator matched more than one element Filter or narrow it
Timeout 30000ms exceeded The element never became ready Check the locator first, then the app
element is not visible It exists but is hidden or zero-size Wait for the state that reveals it
Target page, context or browser has been closed Usually a missing await Await every action

The two you will meet first have their own guides: strict mode violations and timeout 30000ms exceeded.

Five mistakes to avoid in your first Playwright tests

  1. Forgetting await. The test runs ahead of the browser and fails somewhere that has nothing to do with the real problem.
  2. Fragile selectors. div > div:nth-child(3) > button breaks on any layout change. Use roles, labels and test IDs.
  3. Manual sleeps. waitForTimeout(3000) hides the real condition and slows every run.
  4. Testing several behaviours in one test. When it fails you cannot tell which part broke. One behaviour per test.
  5. Tests that depend on each other. The moment you run in parallel, they break.

Best practices, short version

  • Name tests after the behaviour, not the number.
  • Prefer getByRole and getByLabel. Reach for CSS last.
  • Use retrying assertions. Never assert on await locator.isVisible().
  • Let every test create its own data.
  • Mock third-party services. Test only what you control.
  • Turn on traces for retries before you need them, not after.

Frequently asked questions

Do I need to know JavaScript to use Playwright? Basic JavaScript is enough to start: variables, functions and async/await. You do not need to be a front-end developer.

Is Playwright better than Selenium? For a new web project, Playwright is usually faster to write and more stable, because waiting is built in. Selenium supports more languages and more browsers, including older ones. The full comparison is here.

Can Playwright test APIs as well as UI? Yes. It ships a request fixture for calling HTTP endpoints directly, which is useful for setting up data before a UI test.

How long does a Playwright test take to run? A simple test is usually one to three seconds. The browser starts once and each test gets a fresh context, which takes milliseconds.

Does Playwright work with React, Vue or Angular? Yes. It drives the rendered page in a real browser, so the framework behind it makes no difference.

Remember this

A Playwright test is three steps: go to a page, act on it, assert the result. Locators describe elements rather than capturing them. Assertions retry, so you almost never write a wait yourself. Every test gets a clean context, so every test must set up its own data.

The official documentation is at playwright.dev, and the best practices page is worth reading once you have written a few tests.

Practice now

Reading about a test is not the same as writing one. Open the Playwright editor. It has a login app and starter code.

Then try the graded problem below. You write the test, and we run it twice: once against the working app, and once against a copy where we broke the exact behaviour on purpose. If your test still passes on the broken copy, it was not really checking anything, and we will tell you which behaviour it missed.

now prove it

Prove the login really checks the password

Reading is the easy part. Write the test yourself, then we break the app on purpose to check it would really catch the bug.

Solve this problem →

Learn this in full

Reading about a problem is not the same as fixing one. Pick a lecture or a practice problem and write the test yourself.

Keep reading