Playwright Basics

Playwright is an open-source browser automation framework from Microsoft. Its test runner, Playwright Test (@playwright/test), drives Chromium, Firefox and WebKit, gives each test a fresh browser context, waits until an element is ready before acting on it, and retries assertions until the page reaches the expected state. A test reads like a manual test case: open /checkout, fill "Coupon code" with SAVE10, click "Apply", expect a total of 180.00 SAR for two 100.00 SAR lamps.

Why it matters

UI tests are the slowest and most fragile layer of a suite, and many of their failures come from timing and shared state rather than from product defects. Playwright removes several of those causes by design: actions wait, assertions retry, and each test is isolated. That helps only if you use it; fixed sleeps and one-shot checks stay flaky in any tool.

Testers often join a Playwright suite; reading a failing test’s trace quickly separates a product defect from a test defect and gives the bug report precise evidence.

How Playwright Test works

npx playwright test reads playwright.config.ts and runs the test files in parallel worker processes. Each test receives fixtures by name: page is a tab in a new browser context (an isolated profile, like a fresh incognito window), so cookies and storage never leak between tests; request sends HTTP calls, for example to create test data; your own fixtures come from test.extend.

  • Locators: getByRole("button", { name: "Apply" }), getByLabel, getByText, getByTestId. A locator is resolved again at every use, so it survives re-renders, and it is strict: an action on a locator that matches two elements fails instead of picking one.
  • Auto-waiting: before a click, Playwright waits until the element is visible, stable (not moving), receives events (not covered) and enabled; before fill, until the field is visible, enabled and editable.
  • Web-first assertions: await expect(locator).toHaveText("180.00 SAR") retries until it matches or the expect timeout ends, while expect(await locator.textContent()).toBe("180.00 SAR") reads once and races the page.
  • Trace viewer: each action with DOM snapshots before and after it, plus console, network and source; open it first when a test failed unwatched.
One Playwright run: the config and a setup project first, then for every test a fresh context, a locator, an auto-waited action, a retrying assertion and teardown, and at the end a report with traces of retried failures.
  1. 1Read the configbaseURL, timeouts, projects, retries and reporters from playwright.config.tsnpx playwright test
  2. 2Setup projectSigns in once and saves cookies and local storage for the projects that depend on itplaywright/.auth/user.json
  3. 3Fresh context and fixturesA worker opens a new browser context for the test and sets up page and custom fixtures such as checkout
  4. 4LocatorDescribes the element; found again at every use and strict about a single matchgetByRole('button', { name: 'Apply' })
  5. 5Auto-wait, then actBefore click: visible, stable, receives events, enabled; before fill: visible, enabled, editable

    Repeats: Checks again until the element is ready or the test times out

  6. 6Web-first assertionawait expect(total).toHaveText('180.00 SAR')

    Repeats: Retries until it passes or the 5 s expect timeout ends

    Branch: Still failing: the test fails; on CI it is retried and the first retry records a traceFail

  7. 7TeardownFixtures clean up; the context, its cookies and its storage are discarded
  8. 8ReportList output and the HTML report; traces recorded on a retry open in the trace viewernpx playwright show-report

Throughout steps 3–7 Once per test, in parallel workers: no cookies or storage carry over to the next test

Install, project layout and the config file

npm init playwright@latest adds @playwright/test, writes playwright.config.ts and an example test, offers a GitHub Actions workflow and downloads the browsers; on a bare machine, npx playwright install --with-deps installs the browsers and their system libraries. Differences between environments belong in the config, never in the tests:

  • use.baseURL: tests call page.goto("/checkout"), and one environment variable switches between local, staging and a review app.
  • timeout and expect.timeout: 30 seconds per test and 5 seconds per assertion by default. Raise them for a measured reason, never to hide a slow step.
  • projects: one per browser or device profile, plus a setup project that the others list in dependencies, so it runs first.
  • retries: 0 locally, 1–2 on CI. A test that passes only on retry is reported as flaky: investigate it.
  • reporter and trace: list prints progress, html writes playwright-report/, and "on-first-retry" records a trace on the first retry of a failed test.
// shop-e2e/
//   package.json          "@playwright/test" in devDependencies
//   playwright.config.ts  this file
//   tests/                *.spec.ts, auth.setup.ts, fixtures.ts, files/ (uploads)
//   pages/                page objects
//   playwright/.auth/     saved sessions: add to .gitignore
//   playwright-report/    HTML report (generated)
//   test-results/         traces and screenshots (generated)

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

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,                  // whole test, in ms (the default)
  expect: { timeout: 5_000 },       // each web-first assertion (the default)
  fullyParallel: true,              // tests inside one file also run in parallel
  forbidOnly: !!process.env.CI,     // a forgotten test.only fails the CI run
  retries: process.env.CI ? 2 : 0,
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
      dependencies: ['setup'],
    },
  ],
});

Running, debugging and a minimal CI job

The Playwright commands you use daily, then a minimal CI job:

  • npx playwright test tests/checkout.spec.ts:10 runs only the test declared on line 10 (a line inside a test body matches nothing); -g "expired coupon" filters by title (a regular expression), --project=chromium by browser.
  • --headed shows the browser; --ui opens UI mode to pick, re-run and step through tests.
  • --debug opens the Playwright Inspector: one worker, a visible browser, no timeout, paused before the first action. await page.pause() stops at a line you choose in a headed run.
  • npx playwright show-report opens the HTML report and npx playwright show-trace a trace.zip, also one from CI; --trace on traces every test of a local run.
  • CI: container: runs every step inside a Docker image, a ready-made Linux environment. The official image mcr.microsoft.com/playwright holds Node.js, the browsers and their system libraries, so the job runs only npm ci and the tests; its tag must match the @playwright/test version in package-lock.json. secrets.* and vars.* read values stored in the repository settings, so no password sits in the file. CI=true, set by GitHub Actions, turns on the retries and forbidOnly (a forgotten test.only fails the run), and the report with its traces is uploaded even after failures.
# .github/workflows/e2e.yml
name: e2e
on: [pull_request]
jobs:
  e2e:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.55.0-noble   # = @playwright/test version
      options: --user 1001
    steps:
      - uses: actions/checkout@v7
      - run: npm ci               # no "playwright install": the image has the browsers
      - run: npx playwright test
        env:
          BASE_URL: ${{ vars.STAGING_URL }}
          E2E_EMAIL: ${{ secrets.E2E_EMAIL }}
          E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
      - uses: actions/upload-artifact@v7
        if: ${{ !cancelled() }}     # also when tests failed
        with:
          name: playwright-report
          path: playwright-report/

Try it: YAML Formatter Try it: Regex Tester

Signed-in sessions, network stubs, dialogs, tabs and uploads

Signing in through the form in every test is slow and fails everything when login breaks. A setup project signs in once and saves cookies and local storage with storageState; tests load the file with test.use. A sign-in usually lives in a cookie or in local storage; session storage belongs to one tab and is not saved, so an app that keeps its sign-in there does not start signed in. Keep it out of version control and credentials in environment variables. Tests sharing one account must not change data others read, or each worker needs its own account.

page.route intercepts matching requests that the page sends (not calls your server makes): route.fulfill answers with your status and body, route.abort simulates a network failure. Register it before the request is sent. Stub what you do not own or cannot trigger, such as a provider outage; stub everything and the tests prove only the stubs.

Browser dialogs (alert, confirm, prompt) are dismissed automatically unless you register a handler, which must then accept or dismiss each one; an HTML modal is not one: use locators. A link that opens a new tab fires popup: start waiting for it before the click. setInputFiles fills an <input type="file"> from a path relative to the working directory.

// tests/auth.setup.ts: runs once, before the projects that depend on 'setup'
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('sign in once', async ({ page }) => {
  await page.goto('/login');
  // process.env values may be undefined; "!" tells TypeScript they are set (CI secrets)
  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();
  await expect(page.getByRole('heading', { name: 'My account' })).toBeVisible();
  await page.context().storageState({ path: authFile });   // cookies + local storage
});

// tests/account.spec.ts: every test in this file starts signed in
import { test, expect } from '@playwright/test';

test.use({ storageState: 'playwright/.auth/user.json' });

test('saved cards: a provider outage is reported', async ({ page }) => {
  await page.route('**/api/saved-cards', (route) =>
    route.fulfill({ status: 503, json: { error: 'provider unavailable' } }));
  await page.goto('/account/payment');                     // route registered first
  await expect(page.getByRole('alert')).toContainText('try again later');
});

test('terms of use open in a new tab', async ({ page }) => {
  await page.goto('/account');
  const popupPromise = page.waitForEvent('popup');         // wait before the click
  await page.getByRole('link', { name: 'Terms of use' }).click();
  const terms = await popupPromise;
  await expect(terms).toHaveURL(/terms/);
});

test('profile photo upload shows a preview', async ({ page }) => {
  await page.goto('/account/profile');
  await page.getByLabel('Profile photo').setInputFiles('tests/files/avatar.png');
  await expect(page.getByRole('img', { name: 'Photo preview' })).toBeVisible();
});

Try it: Mock API Response Generator Try it: Mock Error Response Generator

Worked example: checkout tests with a page object

Requirement: guest checkout accepts coupons; SAVE10 takes 10% off; an expired code shows an error and keeps the total; if the shipping-quote service is down, the page says shipping will be confirmed by e-mail and still allows the order; removing the last item asks for confirmation in a browser confirm dialog. A desk lamp costs 100.00 SAR; shipping is shown separately, not in the total.

Four short tests replace one long one. Only CheckoutPage knows the screen; a fixture hands each test a ready page object; each test builds its own guest cart through page.request, which shares the page’s cookies, so parallel tests never see each other’s carts. A relabelled field then changes one line, not four tests.

// pages/CheckoutPage.ts: the only file that knows the checkout screen
import type { Locator, Page } from '@playwright/test';

export class CheckoutPage {
  readonly couponInput: Locator;
  readonly applyButton: Locator;
  readonly total: Locator;
  readonly alert: Locator;
  readonly placeOrder: Locator;

  // "private readonly page" also stores the argument as this.page
  constructor(private readonly page: Page) {
    this.couponInput = page.getByLabel('Coupon code');
    this.applyButton = page.getByRole('button', { name: 'Apply' });
    this.total = page.getByTestId('order-total');
    this.alert = page.getByRole('alert');
    this.placeOrder = page.getByRole('button', { name: 'Place order' });
  }

  // A new guest cart for this test: page.request shares cookies with the page.
  async openWith(sku: string, qty: number) {
    await this.page.request.post('/api/cart/items', { data: { sku, qty } });
    await this.page.goto('/checkout');
  }

  async applyCoupon(code: string) {
    await this.couponInput.fill(code);
    await this.applyButton.click();
  }

  async removeItem(name: string) {
    const row = this.page.getByRole('row').filter({ hasText: name });
    await row.getByRole('button', { name: 'Remove' }).click();
  }
}

// tests/fixtures.ts: a test that asks for "checkout" gets a ready page object
import { test as base } from '@playwright/test';
import { CheckoutPage } from '../pages/CheckoutPage';

export const test = base.extend<{ checkout: CheckoutPage }>({
  checkout: async ({ page }, use) => {
    await use(new CheckoutPage(page));
  },
});
export { expect } from '@playwright/test';

// tests/checkout.spec.ts: desk lamp LAMP-01 costs 100.00 SAR
import { test, expect } from './fixtures';

test('valid coupon takes 10% off', async ({ checkout }) => {
  await checkout.openWith('LAMP-01', 2);
  await checkout.applyCoupon('SAVE10');
  await expect(checkout.total).toHaveText('180.00 SAR');
});

test('expired coupon is refused, total unchanged', async ({ checkout }) => {
  await checkout.openWith('LAMP-01', 2);
  await checkout.applyCoupon('OLD5');
  await expect(checkout.alert).toHaveText('This coupon has expired');
  await expect(checkout.total).toHaveText('200.00 SAR');
});

test('shipping quote down: order still possible', async ({ page, checkout }) => {
  await page.route('**/api/shipping-quote', (route) =>
    route.fulfill({ status: 503, json: { error: 'unavailable' } }));
  await checkout.openWith('LAMP-01', 1);
  await expect(page.getByText('Shipping will be confirmed by e-mail')).toBeVisible();
  await expect(checkout.placeOrder).toBeEnabled();
});

test('last item: confirm, then empty cart', async ({ page, checkout }) => {
  await checkout.openWith('LAMP-01', 1);
  page.once('dialog', (dialog) => dialog.accept());   // without a handler: dismissed
  await checkout.removeItem('Desk lamp');
  await expect(page.getByText('Your cart is empty')).toBeVisible();
});

Common mistakes

  • Calling page.waitForTimeout() or raising timeouts instead of awaiting a condition.
  • Forgetting await before an action or an assertion, so the test can pass without checking anything; the @typescript-eslint/no-floating-promises rule catches it.
  • Asserting on a value read once, expect(await locator.isVisible()).toBe(true), instead of the retrying await expect(locator).toBeVisible().
  • Signing in through the UI in every test, or committing the storageState file or a password.
  • Stubbing every request, so the UI tests keep passing after the real API has changed.
  • A Docker image tag that differs from the installed @playwright/test version, so CI cannot find the expected browsers.

The QA perspective

Testers meet Playwright when writing tests for new stories, reviewing pull requests that change tests and triaging red CI runs. In reviews, look for user-facing locators, web-first assertions, no fixed sleeps, one behaviour per test and data each test creates for itself; agree data-testid values with developers where no stable role or label exists.

When CI fails, open the trace before re-running: it shows whether the product misbehaved (a wrong total in the DOM snapshot, a 500 in the network tab) or the test did (a locator that hit the wrong element). Put the title, project, commit and trace in the ticket; ask early which services may be stubbed and which accounts CI may use.

Practice exercise

Review and rewrite a fragile Playwright test

Your task

A colleague wrote this test for the support page: a signed-in user attaches a receipt, sends a ticket and opens the help centre. It used to fail now and then in CI; since the help centre began opening in a new tab, it fails every time. List the problems and rewrite it following this lesson.

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

test('support', async ({ page }) => {
  await page.goto('https://staging.shop.example/login');
  await page.fill('#email', 'sara.qa@example.com');
  await page.fill('#pwd', 'Winter2026');
  await page.click('.btn-primary');
  await page.waitForTimeout(3000);
  await page.goto('https://staging.shop.example/support');
  await page.locator('input[type=file]').setInputFiles('C:/Users/sara/receipt.pdf');
  await page.fill('#message', 'Charged twice for order 1042');
  await page.click('text=Send');
  expect(await page.locator('.toast').isVisible()).toBe(true);
  await page.click('text=Help centre');
  await expect(page).toHaveURL(/help/);
});
Steps
  1. Mark the lines that depend on one machine, one person, real side effects or a fixed delay.
  2. Replace the selectors with user-facing locators and the one-shot check with a web-first assertion.
  3. Decide what moves to the config, to the setup project and to a page.route stub.
  4. Split it into tests that use a SupportPage page object.
Show the model answer
Model answer

Problems: hard-coded staging URLs; signing in inside the test, with a password in the code; a fixed 3-second sleep; markup selectors such as #email, .btn-primary and .toast; a receipt path that exists on one laptop; a one-shot isVisible() check that often runs before the toast appears, the likely old intermittent failure; a real ticket on every run; toHaveURL checking the old page although the help centre opens in a new tab; three behaviours in one test.

Rewrite: baseURL from the config; sign-in moves to the setup project, with credentials from environment variables, and the tests start signed in through test.use({ storageState: "playwright/.auth/user.json" }); the sleep is deleted, because actions and assertions wait. A SupportPage holds getByLabel("Receipt"), getByLabel("Message") and getByRole("button", { name: "Send" }). Test 1 stubs **/api/tickets with 201, attaches tests/files/receipt.pdf, sends and expects getByText("Ticket sent") to be visible. Test 2 stubs 503 and asserts the specified error message; a separate API test still checks the real endpoint. Test 3 waits for popup before clicking "Help centre" and asserts toHaveURL(/help/) on the new page.

Frequently asked questions

Do I have to use TypeScript?

No. Playwright Test, the runner in this lesson, runs JavaScript or TypeScript. Playwright is also available for Python, Java and .NET, with the same locators, auto-waiting and web-first assertions used through that language’s usual test runner, such as pytest. Pick the language your team already uses.

Can I record tests instead of writing them?

Yes, as a draft. npx playwright codegen followed by a URL opens a browser and writes code as you click, preferring role-based locators. Then decide what to assert, move the steps into page objects and remove anything tied to one data set.

If retries turn the CI run green, is the test fine?

No. A test that passes only on retry is reported as flaky. Retries stop one unstable test from blocking every merge, but the cause remains: open the trace of the failed attempt, fix the test or the product, and track it until it is fixed.

Why does a test pass locally and fail only in CI?

Usually because CI differs: a slower machine, parallel workers sharing data, another base URL or account, another browser project, or a missing secret. Before changing any timeout, open the trace from the uploaded report and run the test locally with the same --project and environment variables.

References

  • Playwright DocumentationMicrosoft (Playwright)Official documentation for Playwright, an end-to-end test framework that drives Chromium, WebKit and Firefox.
  • Best Practices — PlaywrightMicrosoft (Playwright)Test user-visible behaviour, keep tests isolated, prefer resilient locators and web-first assertions, and debug with traces.
  • Auto-waiting — PlaywrightMicrosoft (Playwright)The actionability checks Playwright runs before each action — visible, stable, enabled, receiving events — which remove most hand-written waits.
  • Locators — PlaywrightMicrosoft (Playwright)How Playwright finds elements, why locators re-resolve and auto-wait, and why role-based locators are recommended first.

Practise with Mutqan tools

Generate complete mock API responses (status, headers, JSON body) for success, list, detail, error and empty cases.

Mocking

Generate structured error responses (validation, auth, not found, rate limit, server error) in popular formats.

Mocking

Format and validate YAML with consistent indentation and quoting.

Formatting

Test regular expressions live with match highlighting, capture groups and replace preview.

Utilities

Go deeper

Your progress on this topic

Part of the QA Roadmap