Lesson 2 of 12 · Automation Frameworks
Playwright: Getting Started
Install Playwright Test, understand the generated project, write your first tests with locators and web-first assertions, and debug them with UI mode and traces.
beginner 8 min read Updated
You will learn to
- Create a Playwright Test project and explain each generated file
- Write tests using the page fixture, getByRole locators and web-first assertions
- Configure baseURL, projects, retries and traces in playwright.config.ts
- Run, filter and debug tests with the CLI, UI mode and the trace viewer
Prerequisites
- TypeScript for Testers
- Choosing an Automation Tool
Playwright is an open-source browser automation framework from Microsoft, and Playwright Test (@playwright/test) is its built-in test runner. Together they give you browser control, auto-waiting, assertions, parallel execution, fixtures, reporting and debugging tools in one package, with no extra libraries to wire up. That is a large part of why it has become a default choice for new web automation projects.
This lesson takes you from an empty folder to a set of passing tests you understand line by line. It uses TypeScript, which is Playwright’s default; if you need a refresher see TypeScript for Testers.
Prerequisites
- Node.js 22 (or another currently supported LTS release). Check with
node --version. - A code editor. VS Code with the official “Playwright Test for VSCode” extension gives you run buttons next to each test, a locator picker and debugging.
Install
In a new, empty folder run:
npm init playwright@latest
The initialiser asks a few questions. Sensible answers for a first project:
- TypeScript or JavaScript: TypeScript
- Where to put tests: tests
- Add a GitHub Actions workflow: yes (you can delete it later)
- Install Playwright browsers: yes
It installs @playwright/test as a dev dependency and downloads the browser builds Playwright is tested against: Chromium, Firefox and WebKit. On Linux CI machines, add --with-deps when installing browsers so system libraries are installed too:
npx playwright install --with-deps
What was generated
.
├── .github/workflows/playwright.yml CI workflow
├── package.json @playwright/test in devDependencies
├── playwright.config.ts runner configuration
└── tests/
└── example.spec.ts sample tests
Run the sample tests straight away to confirm everything works:
npx playwright test
npx playwright show-report # opens the HTML report
By default the tests run headless (no visible browser), in parallel, across every browser project defined in the config.
Your first test
Delete the sample and create tests/todo.spec.ts. We will test Playwright’s public TodoMVC demo app:
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc/');
});
test('adds a todo item', async ({ page }) => {
const newTodo = page.getByPlaceholder('What needs to be done?');
await newTodo.fill('Buy milk');
await newTodo.press('Enter');
await expect(page.getByTestId('todo-title')).toHaveText(['Buy milk']);
await expect(page.getByTestId('todo-count')).toHaveText('1 item left');
});
test('completes a todo item', async ({ page }) => {
const newTodo = page.getByPlaceholder('What needs to be done?');
await newTodo.fill('Write tests');
await newTodo.press('Enter');
await page.getByRole('checkbox', { name: 'Toggle Todo' }).check();
await expect(page.getByTestId('todo-item')).toHaveClass(/completed/);
});
Line by line:
import { test, expect } from '@playwright/test': the test function and the assertion library. Always import from@playwright/test.async ({ page }) => ...: each test receives fixtures.pageis a fresh browser page in a fresh browser context (like a new incognito profile), so tests do not share cookies or storage.test.beforeEach: runs before every test in the file.- Locators (
getByPlaceholder,getByRole,getByTestId,getByText) describe how to find an element. They are lazy: the element is looked up when an action or assertion runs. - Actions (
fill,press,check) automatically wait for the element to be attached, visible, stable, enabled and able to receive events before acting. await expect(locator).toHaveText(...)is a web-first assertion: it retries until the condition passes or the timeout (5 seconds by default) expires.
Run only this file:
npx playwright test tests/todo.spec.ts --project=chromium
Web-first assertions vs plain assertions
Compare two ways of checking a heading:
// Good: retries until the heading has the text, or times out
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Dashboard');
// Fragile: reads the text once, immediately, with no retry
expect(await page.getByRole('heading', { level: 1 }).textContent()).toBe('Dashboard');
The second form fails if the heading has not rendered yet at the instant it is read. Use the async expect(locator) matchers for anything on the page: toBeVisible, toBeHidden, toHaveText, toContainText, toHaveValue, toHaveAttribute, toHaveCount, toHaveURL, toHaveTitle, toBeChecked, toBeEnabled. Use plain expect(value) for ordinary values you have already obtained, such as a number from an API response.
Configuration: playwright.config.ts
The generated config is long; these are the settings you will touch first:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI, // fail CI if test.only was committed
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'https://demo.playwright.dev',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
],
});
baseURLlets tests callpage.goto('/todomvc/')with a relative path, so switching environments means changing one value (often read from an environment variable).projectsrun the same tests against different browsers or device profiles.retriesre-run failed tests. Useful in CI to collect evidence, but a test that only passes on retry is still flaky and should be fixed.trace: 'on-first-retry'records a full trace when a test is retried, which is the main tool for debugging CI failures.
The generated config sets workers to 1 on CI as a conservative default; raise it once you know your CI machines can handle more.
Running and filtering tests
npx playwright test # everything, all projects
npx playwright test todo # files whose path matches "todo"
npx playwright test -g "completes" # tests whose title matches
npx playwright test --project=firefox # one project
npx playwright test --headed # watch the browser
npx playwright test --repeat-each=10 # hunt for flakiness
npx playwright test --last-failed # re-run only the failures
In code, test.only focuses a single test while you work, and test.skip skips one. Group related tests with test.describe('Todo filters', () => { ... }).
Debugging tools
Playwright’s debugging tools are one of its strongest features. Learn these three early.
UI mode
npx playwright test --ui
UI mode shows your tests in a sidebar, runs them with a timeline of every action, lets you step through DOM snapshots before and after each action, and re-runs tests when files change. It is the best way to develop and debug tests locally.
Codegen
npx playwright codegen https://demo.playwright.dev/todomvc/
Codegen opens a browser and records your clicks and typing as test code, choosing role-based locators where it can. Treat its output as a draft: add meaningful assertions, remove noise, and restructure. It is particularly good for discovering which locator Playwright recommends for an element.
Trace viewer
When a CI test fails with tracing on, download the trace zip from the report and open it:
npx playwright show-trace path/to/trace.zip
You get every action, network request, console message and DOM snapshot, with timings. Most “it passes locally but fails in CI” problems become obvious within minutes.
Testing APIs with the same runner
Playwright Test also includes an HTTP client through the request fixture, so you can create test data via an API and then verify it in the UI, all in one test:
test('health endpoint responds', async ({ request }) => {
const res = await request.get('https://api.example.com/health');
expect(res.ok()).toBeTruthy();
expect(await res.json()).toMatchObject({ status: 'ok' });
});
The API testing path covers this in depth.
Running in CI
The generated .github/workflows/playwright.yml installs dependencies, installs browsers with --with-deps, runs npx playwright test and uploads the HTML report as an artefact. Commit it and push to see your tests run on every pull request. To create or customise a workflow, try the GitHub Actions Generator.
Practice exercise
Using the TodoMVC demo at https://demo.playwright.dev/todomvc/, write a spec file with three tests:
- Add three todos and assert that exactly three items are shown and the counter reads “3 items left”.
- Complete one todo, click the Active filter link, and assert only two items are listed.
- Add a todo, double-click it to edit, change the text, press Enter, and assert the new text.
Use only getByRole, getByPlaceholder, getByText or getByTestId locators, and only web-first assertions. Run the file in all three browsers. You can also practise individual locator and assertion challenges in the Playwright Lab.
A good solution has no waitForTimeout, no CSS selectors, every Playwright call awaited, and tests that pass independently and in any order.
Show a model answer
import { test, expect, type Page } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc/');
});
async function addTodos(page: Page, items: string[]) {
const input = page.getByPlaceholder('What needs to be done?');
for (const item of items) {
await input.fill(item);
await input.press('Enter');
}
}
test('shows three added todos', async ({ page }) => {
await addTodos(page, ['Buy milk', 'Walk dog', 'Pay bills']);
await expect(page.getByTestId('todo-item')).toHaveCount(3);
await expect(page.getByTestId('todo-count')).toHaveText('3 items left');
});
test('active filter hides completed todos', async ({ page }) => {
await addTodos(page, ['Buy milk', 'Walk dog', 'Pay bills']);
await page.getByTestId('todo-item').filter({ hasText: 'Walk dog' })
.getByRole('checkbox', { name: 'Toggle Todo' }).check();
await page.getByRole('link', { name: 'Active' }).click();
await expect(page.getByTestId('todo-title')).toHaveText(['Buy milk', 'Pay bills']);
});
test('edits a todo', async ({ page }) => {
await addTodos(page, ['Buy milk']);
await page.getByTestId('todo-title').dblclick();
const editBox = page.getByTestId('todo-item').getByRole('textbox', { name: 'Edit' });
await editBox.fill('Buy oat milk');
await editBox.press('Enter');
await expect(page.getByTestId('todo-title')).toHaveText(['Buy oat milk']);
});Why it is good: a small helper removes repetition, each test sets up its own data in a fresh context, assertions retry automatically, and filter({ hasText }) targets the right row without positional indexes. If a locator such as the edit textbox’s accessible name differs in the current demo, use codegen or the UI mode locator picker to confirm it, then adjust.
Key takeaways
- Start with
npm init playwright@latest; it installs@playwright/test, browsers, a config and a CI workflow. - Each test gets an isolated
pagein a fresh browser context via fixtures. - Prefer user-facing locators such as
getByRole, and web-first assertions likeawait expect(locator).toBeVisible(), which retry automatically. playwright.config.tscontrolsbaseURL, browser projects, retries, workers and tracing.- Debug with UI mode, codegen and the trace viewer instead of adding sleeps.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.
Practise it
Related interview questions
- A Playwright test passes locally but fails intermittently in CI. How do you debug and fix it?
- Explain Browser, BrowserContext and Page in Playwright. Why do contexts matter for test isolation?
- What is your locator strategy in Playwright, and why prefer getByRole?
- What are web-first assertions, and why is expect(await locator.textContent()).toBe() an anti-pattern?