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, whileexpect(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.
- 1Read the config
baseURL, timeouts, projects, retries and reporters fromplaywright.config.tsnpx playwright test - 2Setup projectSigns in once and saves cookies and local storage for the projects that depend on it
playwright/.auth/user.json - 3Fresh context and fixturesA worker opens a new browser context for the test and sets up
pageand custom fixtures such ascheckout - 4LocatorDescribes the element; found again at every use and strict about a single match
getByRole('button', { name: 'Apply' }) - 5Auto-wait, then actBefore
click: visible, stable, receives events, enabled; beforefill: visible, enabled, editableRepeats: Checks again until the element is ready or the test times out
- 6Web-first assertion
await 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
- 7TeardownFixtures clean up; the context, its cookies and its storage are discarded
- 8ReportList output and the HTML report; traces recorded on a retry open in the trace viewer
npx 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 callpage.goto("/checkout"), and one environment variable switches between local, staging and a review app.timeoutandexpect.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 asetupproject that the others list independencies, so it runs first.retries: 0 locally, 1–2 on CI. A test that passes only on retry is reported as flaky: investigate it.reporterandtrace:listprints progress,htmlwritesplaywright-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:10runs 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=chromiumby browser.--headedshows the browser;--uiopens UI mode to pick, re-run and step through tests.--debugopens 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-reportopens the HTML report andnpx playwright show-traceatrace.zip, also one from CI;--trace ontraces every test of a local run.- CI:
container:runs every step inside a Docker image, a ready-made Linux environment. The official imagemcr.microsoft.com/playwrightholds Node.js, the browsers and their system libraries, so the job runs onlynpm ciand the tests; its tag must match the@playwright/testversion inpackage-lock.json.secrets.*andvars.*read values stored in the repository settings, so no password sits in the file.CI=true, set by GitHub Actions, turns on the retries andforbidOnly(a forgottentest.onlyfails 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/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
awaitbefore an action or an assertion, so the test can pass without checking anything; the@typescript-eslint/no-floating-promisesrule catches it. - Asserting on a value read once,
expect(await locator.isVisible()).toBe(true), instead of the retryingawait expect(locator).toBeVisible(). - Signing in through the UI in every test, or committing the
storageStatefile 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/testversion, 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
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.
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/);
});- Mark the lines that depend on one machine, one person, real side effects or a fixed delay.
- Replace the selectors with user-facing locators and the one-shot check with a web-first assertion.
- Decide what moves to the config, to the setup project and to a
page.routestub. - Split it into tests that use a
SupportPagepage object.
Show the 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 DocumentationOfficial documentation for Playwright, an end-to-end test framework that drives Chromium, WebKit and Firefox.
- Best Practices — PlaywrightTest user-visible behaviour, keep tests isolated, prefer resilient locators and web-first assertions, and debug with traces.
- Auto-waiting — PlaywrightThe actionability checks Playwright runs before each action — visible, stable, enabled, receiving events — which remove most hand-written waits.
- Locators — PlaywrightHow 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.
Generate structured error responses (validation, auth, not found, rate limit, server error) in popular formats.
Format and validate YAML with consistent indentation and quoting.
Test regular expressions live with match highlighting, capture groups and replace preview.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.