Playwright Tutorial for Beginners: Install, Write and Debug Your First Tests
Go from an empty folder to a working browser test suite. Learn how to install Playwright, choose locators that do not break, reuse a login, read a trace and run everything in CI.
- Playwright drives Chromium, Firefox and WebKit with one API, and includes its own test runner.
- Find elements the way users do, with
getByRoleandgetByLabel, instead of long CSS paths. - Actions and web-first assertions wait automatically, so you rarely need manual sleeps.
- When a test fails in CI, the trace viewer shows you exactly what happened at each step.
This Playwright tutorial for beginners takes you from an empty folder to a small test suite you can trust. You only need basic JavaScript. We use TypeScript files because that is what the installer sets up by default, but the examples read almost like plain JavaScript.
Every test here runs against demo.playwright.dev/todomvc, a public to-do app that the Playwright team hosts for practice, so you do not have to build anything first. Commands and options can change between releases, so check them against the version you install.
What is Playwright?
Playwright is an open-source end-to-end testing framework from Microsoft. It opens a real browser, clicks and types like a user, and checks what appears on the page. You can write tests in JavaScript, TypeScript, Python, Java or .NET.
Three things make it attractive to beginners. First, everything comes in one package: the test runner, assertions, parallel execution and reports. Second, it waits for elements on its own, which removes a major source of flaky tests. Third, its tools, such as the UI mode and trace viewer, make failures easy to understand.
| Question | Playwright | Cypress |
|---|---|---|
| Browsers | Chromium, Firefox, WebKit | Chrome family, Edge, Firefox; WebKit is experimental |
| Languages | JS, TS, Python, Java, .NET | JS, TS |
| Parallel runs | Built in and free | Usually through its paid cloud or third-party tools |
| Multiple tabs | Supported | Limited |
Cypress is still an excellent tool, and its interactive runner is very friendly. If you want to compare them properly, read our Cypress tutorial for beginners too.
Install Playwright
Install a current LTS version of Node.js, create a folder, and run the installer inside it:
mkdir pw-demo
cd pw-demo
npm init playwright@latest
It asks a few questions, such as TypeScript or JavaScript, where to keep tests, and whether to add a GitHub Actions workflow. The defaults are fine. It then downloads the browsers and creates a project like this:
pw-demo/
playwright.config.ts # browsers, retries, reporters, base URL
package.json
tests/
example.spec.ts # a sample test
Try the sample straight away:
npx playwright test # run all tests, no visible browser
npx playwright test --ui # open the interactive UI mode
npx playwright show-report # open the HTML report
By default, tests run headless and once per configured browser, so a single test usually appears three times in the results.
Your first test
Create tests/todo.spec.ts:
import { test, expect } from '@playwright/test';
test('adds a to-do item', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc/');
const newTodo = page.getByPlaceholder('What needs to be done?');
await newTodo.fill('Write a Playwright test');
await newTodo.press('Enter');
await expect(page.getByTestId('todo-title'))
.toHaveText('Write a Playwright test');
});
Here is what each part does. test declares a test. { page } is a fixture: Playwright hands you a fresh page in a fresh, isolated browser context for every test, so tests cannot leak cookies or data into each other. Each browser step is asynchronous, which is why you see await everywhere. If that keyword is new to you, read our JavaScript async/await guide first.
Run just this file in one visible browser:
npx playwright test todo --project=chromium --headed
Tip: Run npx playwright codegen https://demo.playwright.dev/todomvc/ to open a browser that records your clicks as test code. It is a great way to discover locators, but always tidy up the recording before you commit it.
Choose good locators
A locator describes how to find an element. Your choice of locator decides how often tests break when the page changes, so this is the most valuable habit to learn. Prefer them in roughly this order:
| Locator | Use it for | Example |
|---|---|---|
getByRole |
Buttons, links, headings, inputs | getByRole('button', { name: 'Save' }) |
getByLabel |
Form fields with a label | getByLabel('Email') |
getByPlaceholder |
Inputs without a label | getByPlaceholder('Search') |
getByText |
Non-interactive text | getByText('Order placed') |
getByTestId |
When nothing else is stable | getByTestId('cart-total') |
locator |
CSS as a last resort | locator('.price') |
Role-based locators match what a user or screen reader perceives. A test using getByRole('button', { name: 'Save' }) survives a redesign that changes every CSS class. It fails only if the button’s visible meaning changes, which is exactly when you want to know. They also nudge your team towards accessible markup.
You can narrow a locator down. For example, to find the delete button in one specific row:
const row = page.getByRole('listitem')
.filter({ hasText: 'Buy milk' });
await row.getByRole('button', { name: 'Delete' }).click();
Assertions and auto-waiting
Before Playwright clicks an element, it checks that the element is visible, stable, enabled and able to receive the click. If not, it keeps retrying until the timeout. That is auto-waiting.
Assertions on locators, called web-first assertions, work the same way. They retry until the condition is true or the timeout expires:
await expect(page.getByText('Saved')).toBeVisible();
await expect(page.getByTestId('todo-item')).toHaveCount(3);
await expect(page).toHaveURL(/.*completed/);
Compare the two styles below. The first checks once and fails if the page is still updating. The second waits for the page to catch up.
// Fragile: reads the text once
expect(await page.getByTestId('count').textContent()).toBe('3');
// Reliable: retries until it matches
await expect(page.getByTestId('count')).toHaveText('3');
You should almost never need page.waitForTimeout(3000). A fixed sleep is slow when the app is fast and still fails when the app is slow.
A realistic test
Good tests follow a user’s goal and check its visible result. This one adds items, completes one and verifies the filtered list:
test('completed filter shows only finished items', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc/');
const input = page.getByPlaceholder('What needs to be done?');
for (const item of ['Buy milk', 'Pay rent']) {
await input.fill(item);
await input.press('Enter');
}
await page.getByRole('listitem')
.filter({ hasText: 'Buy milk' })
.getByRole('checkbox')
.check();
await page.getByRole('link', { name: 'Completed' }).click();
await expect(page.getByTestId('todo-title')).toHaveCount(1);
await expect(page.getByTestId('todo-title')).toHaveText('Buy milk');
});
Hooks and fixtures
When every test in a file starts the same way, move that step into a hook:
test.beforeEach(async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc/');
});
Better still, set baseURL in playwright.config.ts and write page.goto('/'), so the same tests can target local, staging or production. As a suite grows, wrap repeated page actions in classes. Our Page Object Model tutorial shows how and when to do that.
Reuse a login
Logging in through the form before every test wastes time. Instead, log in once in a setup step, save the browser state to a file and start tests from it.
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: '.auth/user.json' });
});
Then add a setup project in your config, and make your browser projects depend on it with dependencies: ['setup'] and use: { storageState: '.auth/user.json' }. This example assumes your app has those labels and routes, so adjust them to match. Add .auth/ to .gitignore, and keep credentials in environment variables, never in code.
Debug failures
Playwright gives you several ways to see what went wrong:
- UI mode (
--ui) lets you run tests and step through each action, with a snapshot of the page. Use this while writing tests. - Inspector (
--debug) pauses at each step so you can move through the test line by line. - Trace viewer records the actions, snapshots, network calls and console messages of a run. This is the best tool for CI failures.
Turn traces on for retried tests in your config, then open one after a failure:
// playwright.config.ts
use: { trace: 'on-first-retry' }
// after a failed run
npx playwright show-trace test-results/<test-folder>/trace.zip
Run Playwright in CI
If you accepted the GitHub Actions option during setup, a workflow already exists at .github/workflows/playwright.yml. Its core steps look like this:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
Uploading the report matters, because it lets you download traces from a failed run and debug on your own machine. Read our GitHub Actions CI/CD guide to learn how workflows fit together.
Common beginner mistakes
- Forgetting
await. The test moves on before the action completes and fails in confusing ways. Theno-floating-promiseslint rule catches this. - Using hard sleeps. Replace them with a web-first assertion on what you are waiting for.
- Depending on test order. Each test should create its own data so it can run alone and in parallel.
- Testing everything through the browser. Keep end-to-end tests for important journeys, and push details down to faster levels. Our guide to unit vs integration testing explains the split.
- Relying on retries. Retries help you survive flakiness in CI, but investigate any test that needs one.
Final thoughts
You now know the workflow most Playwright projects rely on: install, write a test around a user goal, find elements by role, assert with retrying checks, and use traces when something fails. Next, write three tests for the most important journey in your own app and add them to your pipeline. Three reliable tests that run on every pull request are worth more than fifty that nobody trusts.
Frequently asked questions
Is Playwright good for beginners?
Yes. The installer sets up a working project, codegen records tests for you, and UI mode makes failures easy to see. Basic JavaScript is enough to start.
Is Playwright free?
Yes. Playwright is open source and free to use, including parallel runs. You only pay for any machines or cloud services you choose to run it on.
Should I learn Playwright or Selenium?
For a new web project, Playwright is usually quicker to get productive with. Selenium remains useful when a team already has a large suite or needs its wider browser and language ecosystem.
Can Playwright test APIs?
Yes. The built-in request fixture sends HTTP calls, so you can test APIs directly or set up data before a browser test.
Does Playwright work with Python?
Yes. Playwright has an official Python package with a pytest plugin. The concepts in this tutorial, such as locators and auto-waiting, carry over directly.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.