Playwright (UI)
Modern, fast, auto-waiting end-to-end browser automation from Microsoft.
What is Playwright and why use it
Playwright is an end-to-end testing framework that drives real browsers (Chromium, Firefox, WebKit) through a single API. It was built to fix the flakiness that plagued older tools: nearly every action waits automatically for the element to be actionable before interacting, so you rarely write manual sleeps.
- Cross-browser: one test runs on Chromium, Firefox, and WebKit (Safari engine).
- Auto-waiting: actions retry until the element is visible, enabled, and stable.
- Web-first assertions: expect(locator).toBeVisible() polls until it passes or times out.
- Powerful tooling: codegen (record tests), trace viewer (time-travel debugging), and UI mode.
- Fast and parallel by default: tests run in isolated browser contexts across worker processes.
You will practice against TestForge
Every example targets this app: the UI at http://localhost:3100 and the API at http://localhost:3000/api. Start the stack with docker compose up -d, or run the frontend with npm run dev.
Prerequisites
- Node.js 18 or newer (check with: node -v).
- A code editor (VS Code recommended — there is an official Playwright extension).
- The TestForge app running locally, and a test account you can log in with.
- Basic JavaScript/TypeScript familiarity (you can learn the rest as you go).
Installation and setup
Create a project and let Playwright scaffold config, a sample test, and the browser binaries.
mkdir testforge-e2e && cd testforge-e2e
npm init -y
npm init playwright@latest
# Answer the prompts: TypeScript, tests/ folder, add GitHub Actions workflow (optional)
# This also downloads the browsers. To (re)install browsers manually:
npx playwright installPoint Playwright at TestForge by setting a baseURL in playwright.config.ts so tests can use relative paths like page.goto("/login").
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
baseURL: 'http://localhost:3100',
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'] } },
],
});Your first test
A test opens a page, performs actions, and asserts on the result. This one visits the catalog and checks the heading renders.
import { test, expect } from '@playwright/test';
test('catalog page shows a heading and products', async ({ page }) => {
await page.goto('/catalog');
await expect(page.getByRole('heading', { name: 'Catalog' })).toBeVisible();
// At least one product card renders an Add to cart button
await expect(page.getByRole('button', { name: 'Add to cart' }).first()).toBeVisible();
});# Run everything (headless)
npx playwright test
# Run one file in headed mode so you can watch it
npx playwright test tests/catalog.spec.ts --headed
# Open the interactive UI mode (best for developing tests)
npx playwright test --uiLet the tool write the first draft
Run npx playwright codegen http://localhost:3100 to record clicks into runnable code, then clean it up. It is the fastest way to learn good locators.
Locators — finding elements the right way
A Locator is a lazy, auto-retrying handle to an element. Prefer user-facing, role-based locators over brittle CSS/XPath — they survive refactors and mirror how a real user (and assistive tech) sees the page.
// Best: semantic, accessible locators
page.getByRole('button', { name: 'Log in' });
page.getByLabel('Email');
page.getByPlaceholder('Search products...');
page.getByText('Order history');
// Good for app-owned hooks (TestForge exposes some data-testid values)
page.getByTestId('cart-count');
// Use CSS/XPath only as a last resort
page.locator('.card >> text=Add to cart');- Chain and filter: page.getByRole("listitem").filter({ hasText: "Widget" }).
- Narrow scope: const row = page.getByRole("row", { name: "Order 123" }); row.getByRole("button", { name: "Cancel" }).
- first() / last() / nth(i) pick from multiple matches, but a more specific locator is usually better.
Web-first assertions
expect(locator) assertions automatically wait and retry until the condition is met or the timeout expires. This is what eliminates most flakiness — you never assert on a stale snapshot.
await expect(page.getByTestId('cart-count')).toHaveText('1');
await expect(page.getByRole('button', { name: 'Add to cart' }).first()).toBeEnabled();
await expect(page).toHaveURL(/\/checkout/);
await expect(page.getByRole('alert')).toContainText('out of stock');
await expect(page.getByRole('listitem')).toHaveCount(3);Do not mix in manual assertions on values you read yourself
Prefer expect(locator).toHaveText("1") over reading textContent() then asserting — the locator form retries, the manual read captures a single (possibly too-early) moment.
Waiting and auto-waiting
Actions (click, fill) auto-wait for the element to be visible, stable, and enabled. Assertions auto-wait too. That means you almost never need page.waitForTimeout(). When you must wait for something specific, wait for a condition, not a duration.
// Wait for a network response (e.g. the cart POST after Add to cart)
await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/cart') && r.request().method() === 'POST'),
page.getByRole('button', { name: 'Add to cart' }).first().click(),
]);
// Wait for navigation or a URL
await page.waitForURL('**/checkout');
// Anti-pattern: avoid fixed sleeps
// await page.waitForTimeout(3000); // flaky and slowCommon tasks: login, forms, navigation
Logging in is the most common precondition. Here is a reusable login flow against TestForge.
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('customer@testforge.dev');
await page.getByLabel('Password').fill('Password123!');
await page.getByRole('button', { name: 'Log in' }).click();
// On success the app redirects home and shows the account menu
await expect(page).toHaveURL('http://localhost:3100/');
await expect(page.getByText('customer@testforge.dev')).toBeVisible();
});Reusing login state across tests
Logging in for every test is slow. Do it once in a setup project, save the storage state (localStorage + cookies), and reuse it. TestForge keeps the access token in memory and the refresh token in localStorage, so storageState captures a usable session.
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('customer@testforge.dev');
await page.getByLabel('Password').fill('Password123!');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page).toHaveURL('http://localhost:3100/');
await page.context().storageState({ path: authFile });
});Wire it up in the config so every project depends on setup and starts already logged in.
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],Running, reporting, and traces
- npx playwright test — run all tests headless across configured projects.
- npx playwright test --project=chromium -g "log in" — filter by project and title.
- npx playwright show-report — open the last HTML report.
- npx playwright show-trace trace.zip — time-travel debugger with DOM snapshots, network, and console for each step.
The trace viewer is your best debugging tool
With trace: "on-first-retry" set, any flaky failure records a full trace. You can scrub through every action and see exactly what the page looked like when it failed.
Running in CI (GitHub Actions)
name: e2e
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- 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/Best practices
- Prefer getByRole/getByLabel over CSS selectors; add data-testid only when semantics are not enough.
- Keep tests independent and idempotent — each should set up its own state and not depend on order.
- Never use waitForTimeout; wait for conditions (responses, URLs, assertions).
- Use fixtures/Page Object Models for reused flows, but do not over-abstract simple pages.
- Assert on user-visible outcomes, not implementation details.
- Run the same suite across all three browsers in CI to catch engine-specific bugs.
Fixtures and Page Object Models
Fixtures are how Playwright sets up and tears down what a test needs — the built-in page fixture is one example. You can build your own with test.extend to inject a pre-configured helper (an already-authenticated page, a seeded cart, a Page Object) so the test body stays focused on the behavior it verifies.
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/login.page';
// Extend the base test with a ready-to-use LoginPage fixture
export const test = base.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await use(loginPage);
// Anything after use() runs as teardown
},
});
export { expect } from '@playwright/test';A Page Object Model (POM) wraps a page or flow behind named methods and locators, so a UI change is fixed in one place instead of across twenty tests. Reach for a POM when a flow (login, checkout, product search) is reused across several specs or has non-trivial steps — not for a page you touch once.
import type { Page, Locator } from '@playwright/test';
import { expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
constructor(page: Page) {
this.page = page;
this.email = page.getByLabel('Email');
this.password = page.getByLabel('Password');
this.submit = page.getByRole('button', { name: 'Log in' });
}
async goto(): Promise<void> {
await this.page.goto('/login');
}
async login(email: string, password: string): Promise<void> {
await this.email.fill(email);
await this.password.fill(password);
await this.submit.click();
await expect(this.page).toHaveURL('http://localhost:3100/');
}
}import { test, expect } from '../fixtures';
test('user can log in via the LoginPage object', async ({ loginPage, page }) => {
// The fixture already navigated to /login for us
await loginPage.login('customer@testforge.dev', 'Password123!');
await expect(page.getByText('customer@testforge.dev')).toBeVisible();
});A POM can be a liability, not just an asset
Do not wrap every element in a getter or add a method for a single one-line click. A POM that just renames page.getByRole adds indirection without value. Encapsulate flows and stable locators; leave trivial, one-off interactions inline.
Network interception and mocking
page.route intercepts requests before they hit the network, so you can stub a response, modify a real one, or fail it on purpose. This lets you test UI states that are hard to trigger for real — an empty cart, a server error, a slow payment — without touching the backend or its data.
// Stub the TestForge cart endpoint with a fixed payload
await page.route('**/api/cart', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], total: 0 }),
});
});
await page.goto('/cart');
await expect(page.getByText('Your cart is empty')).toBeVisible();You can also let the real request through and rewrite only part of the response — useful for forcing an error path like a declined checkout without changing the server.
// Force the checkout API to return a 402 so we can test the failure UI
await page.route('**/api/checkout', async (route) => {
await route.fulfill({
status: 402,
contentType: 'application/json',
body: JSON.stringify({ message: 'Payment declined' }),
});
});
// Or modify a genuine response instead of fabricating one
await page.route('**/api/orders', async (route) => {
const response = await route.fetch();
const json = await response.json();
json.status = 'shipped';
await route.fulfill({ response, json });
});When you do want the real request to fire, waitForResponse lets you assert on it or synchronize the next step to it, rather than guessing with a timeout.
// Trigger the real checkout and inspect what the server actually returned
const [response] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/checkout') && r.request().method() === 'POST'),
page.getByRole('button', { name: 'Place order' }).click(),
]);
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.orderId).toBeTruthy();Mock the third parties, exercise your own stack
A good rule: stub external dependencies you do not control (a payment gateway, an email provider) so tests are fast and deterministic, but let requests to your own TestForge API run for real in end-to-end tests — otherwise you are only testing your mocks.
Debugging and killing flakiness
A flaky test passes and fails without the code changing. Before you reach for retries, debug the actual cause — the trace viewer and the inspector make that fast because they show you exactly what the page looked like at the moment of failure.
# Step through a test with the Playwright Inspector (pauses on each action)
npx playwright test tests/checkout.spec.ts --debug
# Open a recorded trace to time-travel through a past failure
npx playwright show-trace test-results/checkout-.../trace.zip
# Re-run only the tests that failed last time
npx playwright test --last-failedMost flakiness comes from a handful of causes. Fix the root cause first; use retries only as a safety net for the rare, genuinely non-deterministic failure.
- Racing the app: asserting or reading before the UI settled. Fix with web-first assertions (expect(locator).toHaveText) and waitForResponse instead of waitForTimeout.
- Animations and transitions: an element moves while Playwright clicks. Auto-waiting handles most of it; disable animations in tests or wait for the settled state if not.
- Shared state between tests: one test leaves a cart/order behind that another depends on. Keep tests independent — seed and clean up their own data.
- Time and randomness: tests that depend on the clock or random data. Freeze time or seed deterministic fixtures.
- Auto-retrying only the failing spec (--last-failed) helps you reproduce; it does not fix the flake.
// Scope retries to a single stubborn suite instead of the whole run
test.describe('flaky external widget', () => {
test.describe.configure({ retries: 2 });
test('renders the third-party rating', async ({ page }) => {
await page.goto('/catalog');
// A web-first assertion polls until it passes — no manual sleep needed
await expect(page.getByTestId('rating-stars').first()).toBeVisible();
});
});Retries hide flakiness, they do not fix it
A test that only passes on the second attempt is still broken — it will fail your teammates and erode trust in the suite. Use trace: "on-first-retry" to capture the failure, find the race, and fix it. Reserve retries for failures you have proven are outside your control.
Interview questions and answers
What is auto-waiting in Playwright, and when do you still need an explicit wait?
Before every action, Playwright runs actionability checks — it waits for the element to be attached, visible, stable, enabled, and able to receive events — and web-first assertions poll until they pass or time out. That removes almost all manual waits. You still need an explicit wait when you are synchronizing on something off the DOM, such as page.waitForResponse for a specific API call or page.waitForURL after a redirect. What you should never reach for is page.waitForTimeout with a fixed duration, because it is both slow and flaky.
How do you decide between getByRole and a CSS selector when locating an element?
Prefer user-facing locators like getByRole, getByLabel, and getByText, because they mirror how a real user and assistive technology perceive the page and they survive refactors that change class names or DOM structure. getByRole also implicitly asserts accessibility, which is a nice bonus. CSS or XPath is a last resort for cases with no accessible handle; when you need an app-owned hook, a stable data-testid is better than a brittle structural selector.
How does storageState work, and how would you reuse authentication across a suite?
storageState serializes cookies and localStorage to a JSON file, which you can load into a new browser context so it starts already logged in. The standard pattern is a setup project that logs in once, calls context.storageState({ path }) to save the session, and other projects declare a dependency on it and set storageState to that file. This runs the expensive login a single time instead of per test. For TestForge specifically, the refresh token lives in localStorage, so storageState captures a usable session that survives across tests.
How does Playwright achieve parallelism, and why do tests not interfere with each other?
Playwright runs test files in parallel across multiple worker processes, and the worker count is configurable. Isolation comes from each test getting its own fresh browser context, which is like a brand-new incognito profile with separate cookies, storage, and cache, so tests do not share client-side state. You can also parallelize tests within a single file using fullyParallel or test.describe.configure. The remaining risk is shared server-side state, which is why tests should seed and clean up their own data.
What is the trace viewer and how do you use it to diagnose a failure?
The trace viewer is a time-travel debugger that records a full timeline of a test run: a DOM snapshot before and after every action, plus network requests, console logs, and source. You typically enable it with trace: 'on-first-retry' so a trace is captured only when something actually goes wrong, then open it with npx playwright show-trace. Scrubbing the timeline shows you exactly what the page looked like at the failing step, which usually reveals the race or wrong locator immediately — far faster than adding print statements.
When should you enable retries, and what is the danger of leaning on them?
Retries are appropriate for failures genuinely outside your control — a flaky third-party widget, occasional network noise in CI — and they are commonly enabled in CI but left off locally so flakiness is visible during development. The danger is using them to paper over a real race condition in your own code: a test that only passes on the second attempt is still broken and will erode trust in the suite. The right move is to capture the failure with a trace, fix the root cause, and reserve retries as a narrow safety net.
A test passes locally but is flaky in CI. How do you track it down and fix it?
I start by reproducing it: enable a trace, run with retries so the on-first-retry trace is captured, and open the trace to see the exact failing state. The usual culprits are racing the UI (fixed by web-first assertions and waitForResponse instead of timeouts), animations, and shared state leaking between tests. Once I identify the cause, I fix it at the source — proper waits, independent per-test data, disabling animations, or freezing time — rather than bumping the timeout. Only if the failure is proven external do I accept a scoped retry.
What are the trade-offs of the Page Object Model?
A POM centralizes locators and flows so a UI change is fixed in one place instead of across many tests, which improves maintainability for reused flows like login or checkout. The trade-off is that a poorly built POM adds indirection: wrapping every element in a getter or adding a method per one-line click makes tests harder to read without any payoff. The judgment call is to encapsulate stable, reused flows and locators, but leave trivial one-off interactions inline. Playwright fixtures often complement or even replace a POM for setup concerns.
How does network mocking work with page.route, and when would you mock versus hit the real API?
page.route registers a handler that intercepts matching requests before they leave the browser, and in the handler you can fulfill with a stubbed response, fetch the real one and modify it, or abort to simulate a failure. This lets you drive hard-to-reach UI states like an empty cart or a declined payment deterministically. The guideline is to mock external dependencies you do not control so tests stay fast and stable, but let requests to your own stack run for real in true end-to-end tests — otherwise you are only testing your mocks.
What is the difference between a web-first assertion and manually reading a value, and why does it matter?
A web-first assertion like expect(locator).toHaveText('1') retries automatically, re-evaluating until the condition holds or the timeout expires, so it tolerates the asynchronous nature of a web app. Manually reading a value — calling textContent() and then asserting on the result — captures a single snapshot at one instant, which is frequently too early and produces a flaky failure. The rule of thumb is to assert directly on the locator whenever possible and avoid pulling values out into variables just to compare them, because that throws away the auto-retry that keeps tests stable.
Where to go next
- Official docs: playwright.dev
- API testing with request fixture (see the Playwright API tutorial in this section).
- Component testing, visual comparisons (toHaveScreenshot), and network mocking (page.route).
- Practice: automate the full TestForge checkout flow, including the simulated payment failure and retry.