project: "playwright-playground"
version: "^1.49"
browsers: "chromium · firefox · webkit"
status: // learn by doing

Playwright._

an interactive tutorial — install, write, run, and debug end-to-end browser tests.

↓ start here  ·  jump to the interactive locator playground  ·  watch a test run

$ man playwright

# What is Playwright?

Playwright is Microsoft's end-to-end testing framework for the modern web. One API drives three browser engines — Chromium (Chrome / Edge), WebKit (Safari), and Firefox — plus mobile emulation, all out of the box. Tests are written in TypeScript or JavaScript (with bindings for Python, .NET, and Java).

The selling points: auto-waiting (no sleep() hacks), web-first assertions that automatically retry until they pass or time out, a trace viewer that replays failures frame-by-frame, and built-in parallelism. It's the framework you reach for when Cypress feels constraining or Selenium feels brittle.

cross-browser
One test, three engines. No driver downloads or Selenium grids.
auto-waiting
Locators wait for the element to be ready before clicking, typing, or asserting.
trace viewer
Step-by-step DOM snapshots, network log, console output — replay any failure.
parallel by default
Tests run in parallel workers; failures are sharded across machines in CI.
codegen
npx playwright codegen records your clicks into a test file.
network mocking
Intercept requests, stub responses, or replay HARs from production.
Track 1 of 9

AJAX & HTTP fundamentals

Before you can test a network call you have to understand what's happening. Front-End + REST API testing is in the job's minimum quals — this track covers it end-to-end.

fetchasync/awaitcorsauthabortsstreamingcachingretry
$ man ajax

# What "AJAX" really means today

AJAX originally stood for Asynchronous JavaScript And XML. Today it's a catch-all for any HTTP request a page makes after it has loaded — almost always JSON, almost always via fetch(). The XML part is gone; the rest is the backbone of every modern web app.

1999 — XMLHttpRequest
Microsoft's IE5 ships the API that started it all. Gmail and Maps prove it's a paradigm shift.
2015 — fetch() arrives
Promise-based, cleaner. Native in all modern browsers. Today's default.
2017 — async/await
The syntax that makes fetch readable. Built directly on Promises.
Now — Streaming, SSE, WS
Real-time updates push past simple request/response.

From a QA standpoint, the things that go wrong with AJAX are: timing (assertion runs before response), error handling (test passes a 500), caching (test sees stale data), and CORS (works locally, breaks on staging). Every section below covers one of these.

$ man fetch

# The fetch() API

Every modern AJAX call starts with fetch(url, options). The return value is a Promise<Response> — you read the body with .json(), .text(), .blob(), or .arrayBuffer().

basic GETconst response = await fetch('/api/users/42');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const user = await response.json();
console.log(user.name);

// POST with JSON body
const created = await fetch('/api/users', {
  method:  'POST',
  headers: { 'Content-Type': 'application/json' },
  body:    JSON.stringify({ name: 'Ada', email: 'ada@x.com' }),
});
if (created.status !== 201) throw new Error('create failed');
const user = await created.json();
gotcha #1 — fetch doesn't reject on HTTP errors
A 404 or 500 still resolves the promise. You must check response.ok (true for 200–299) yourself.
— build a request, simulate the round-trip
Toggle the method, headers, and body. The right pane shows the generated fetch() call AND a simulated server response with status code, latency, and parsed body.
generated code + simulated response

Reading different body shapes

await res.json();         // JSON → object
await res.text();         // HTML, CSV, plain text → string
await res.blob();         // images, PDFs, downloads → Blob
await res.arrayBuffer();  // binary data → ArrayBuffer
await res.formData();     // multipart bodies → FormData

// READ THE BODY ONLY ONCE. A second call throws.
const text = await res.text();
const json = JSON.parse(text);  // if you need both, .text() then JSON.parse
$ man http-request

# Anatomy of an HTTP round-trip

What actually happens between fetch() and the response arriving in your code? The same six phases — every time. When tests are flaky, the cause is almost always one of these.

requestPOST /api/users HTTP/1.1                // 1. request line
Host: api.example.com
Content-Type: application/json              // 2. headers
Authorization: Bearer eyJhbGciOiJI...
Cookie: session=abc123
Content-Length: 64
                                             // 3. blank line = end of headers
{"name":"Ada","email":"ada@x.com"}          // 4. body
responseHTTP/1.1 201 Created                        // 1. status line
Content-Type: application/json              // 2. headers
Set-Cookie: session=xyz789; HttpOnly; Secure
Cache-Control: no-store
Content-Length: 89
                                             // 3. blank line
{"id":42,"name":"Ada","email":"ada@x.com"}  // 4. body
— watch the request animate through each hop
Hit ▸ run to see what your browser actually does. Each highlight is a real phase: DNS, TCP, TLS, request, server-think, response, parse.
avg: 87ms — most of it isn't your server
Browser
fetch('/api/users/42')
Server
api.example.com
// hit run to step through

HTTP status codes you must know

200 OKSuccess. Body has the resource.
201 CreatedPOST succeeded. Location header points to the new resource.
204 No ContentSuccess with no body. Common for DELETE / PUT.
301 / 302 / 307Redirect. fetch follows by default.
304 Not ModifiedCached version is still valid. Empty body.
400 Bad RequestThe request is malformed (bad JSON, missing field).
401 UnauthorizedNot signed in. Sign in and retry.
403 ForbiddenSigned in but not allowed. Don't retry.
404 Not FoundThe resource isn't there.
409 ConflictStale write (e.g., optimistic concurrency).
422 UnprocessableValidation failed. Body has the field errors.
429 Too Many RequestsRate-limited. Check Retry-After.
500 Internal Server ErrorBug on the server. You can retry.
502 / 503 / 504Gateway / load-balancer / timeout. Retry with backoff.
$ man async-await

# Async / await — without the footguns

async functions return Promises; await unwraps them. Almost every Playwright test, almost every modern fetch call, is wrapped in this pair. The mistakes are universal.

goodasync function loadProfile(id) {
  const [user, orders] = await Promise.all([
    fetch(`/api/users/${id}`).then(r => r.json()),
    fetch(`/api/orders?user=${id}`).then(r => r.json()),
  ]);
  return { ...user, orders };
}
anti-patterns// ✗ forgot await — `user` is a Promise, not the user
const user = fetch('/api/me').then(r => r.json());
console.log(user.name);  // undefined!

// ✗ sequential when they could parallel — slow
const user   = await fetch('/api/me');
const prefs  = await fetch('/api/prefs');  // only starts after user resolves
const orders = await fetch('/api/orders');

// ✗ swallowing errors with a try/catch that doesn't re-throw
try { await save(); } catch (e) { console.log(e); }
// caller has no idea it failed. Always re-throw or return a Result.
coding exercise · 1 of 12
Parallelize two independent fetches
The function below makes two independent API calls in series, so the total time is ~200ms. Rewrite it to run them in parallel using Promise.all. The function should still return an object with user and orders.
reference solution
async function loadDashboard(userId) { const [user, orders] = await Promise.all([ fetch(`/api/users/${userId}`).then(r => r.json()), fetch(`/api/orders?user=${userId}`).then(r => r.json()), ]); return { user, orders }; }
$ man error-handling

# Error handling done right

There are three distinct error categories. A robust client distinguishes them.

CategoryWhat it looks likeWhat you do
Network errorfetch rejects: TypeError: Failed to fetchRetry with backoff. Show "Offline?".
HTTP 4xxResolves; response.ok === false; status 400–499Surface the error to the user. Don't retry.
HTTP 5xxResolves; response.ok === false; status 500–599Retry with backoff. Then surface.
production-grade fetch wrapperexport async function api(url, opts = {}) {
  let response;
  try {
    response = await fetch(url, { ...opts, headers: { 'Accept': 'application/json', ...opts.headers } });
  } catch (err) {
    throw new NetworkError('no connection', err);
  }

  const body = response.status === 204
    ? null
    : await response.json().catch(() => null);

  if (response.ok) return body;
  if (response.status === 401) throw new UnauthorizedError();
  if (response.status === 422) throw new ValidationError(body?.errors);
  if (response.status >= 500) throw new ServerError(response.status, body);
  throw new ApiError(response.status, body?.message ?? response.statusText);
}
coding exercise · 2 of 12
Write a safe JSON GET that handles all three error categories
Implement safeGet(url) so it: (1) catches network errors with try/catch, (2) checks response.ok and throws new Error(`HTTP ${status}`) on 4xx/5xx, (3) returns the parsed JSON on success.
reference solution
async function safeGet(url) { let response; try { response = await fetch(url); } catch (err) { throw new Error('network error: ' + err.message); } if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return await response.json(); }
$ man cors

# CORS — the most-googled bug

Same-origin policy: a page loaded from app.example.com can't read responses from api.other.com unless the API explicitly allows it via Access-Control-Allow-Origin. The browser enforces this, not the server. Your test in Playwright will pass because Playwright runs as the browser — your user's browser will block the same call if CORS isn't configured.

— simulate four CORS scenarios
Each button picks a request scenario. Watch which one the browser blocks and why.
page
https://app.example.com
api
https://app.example.com/api/users

Solving CORS

server: Expressimport cors from 'cors';
app.use(cors({
  origin: ['https://app.example.com', 'http://localhost:3000'],
  credentials: true,
}));
server: Djangopip install django-cors-headers

# settings.py
INSTALLED_APPS = [..., 'corsheaders', ...]
MIDDLEWARE     = ['corsheaders.middleware.CorsMiddleware', ...]
CORS_ALLOWED_ORIGINS    = ['https://app.example.com']
CORS_ALLOW_CREDENTIALS  = True
QA testing tip
CORS bugs don't show up in Playwright unit-style tests — Playwright runs as the browser, so it can be configured to bypass CORS. Always include a real-browser smoke test from a different origin (e.g. localhost:3000 hitting api.staging.example.com) in your nightly suite.
$ man auth-patterns

# Authentication patterns

Four flavors you'll meet, in roughly the order of how common they are in modern apps.

1. Cookie sessions (server-side state)

// browser sends Cookie: header automatically — IF credentials: 'include'
const res = await fetch('/api/me', { credentials: 'include' });

2. Bearer tokens (JWT / opaque)

const token = localStorage.getItem('auth');
const res   = await fetch('/api/me', {
  headers: { Authorization: `Bearer ${token}` }
});
storing tokens
localStorage is reachable from any JS — vulnerable to XSS exfiltration. For sensitive apps prefer HttpOnly cookies set by the server (JS can't read them but the browser still sends them).

3. API keys (server-to-server)

headers: { 'X-API-Key': process.env.API_KEY }

4. OAuth 2.0 (delegated auth — "Sign in with Google")

// 1. redirect user to provider
window.location = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${ID}&redirect_uri=${URI}&response_type=code&scope=openid email`;

// 2. provider redirects back to /callback?code=XYZ
// 3. your server exchanges code for an access_token
// 4. server sets a session cookie / returns a JWT to the SPA
testing auth in Playwright
See the Reusing authentication section — sign in once via UI, save storageState, every subsequent test boots already signed-in.
$ man abort-controller

# AbortController + race conditions

Users type fast. Search-as-you-type fires N requests. The slowest one might land last — and overwrite the latest, correct results. This is the most common AJAX bug in production. AbortController kills in-flight requests when a newer one starts.

cancellable searchlet activeController = null;

async function search(query) {
  // 1. cancel the previous request, if any
  activeController?.abort();
  // 2. fresh controller for this call
  activeController = new AbortController();

  try {
    const res  = await fetch(`/api/search?q=${query}`, {
      signal: activeController.signal,
    });
    const json = await res.json();
    render(json);
  } catch (err) {
    if (err.name === 'AbortError') return;  // expected — newer query won
    throw err;
  }
}
— see the bug, then fix it with AbortController
Type quickly in the search box. With "Use AbortController" OFF the slowest response wins; flip it ON and watch only the most recent query render.
// requests will show as they fire and resolve

AbortController for timeouts

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
  const res = await fetch(url, { signal: controller.signal });
  return await res.json();
} finally {
  clearTimeout(timer);
}

// shorthand (modern):  AbortSignal.timeout(5000)
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
$ man file-upload

# File upload & download

upload via FormDataconst form = new FormData();
form.append('file', fileInput.files[0]);
form.append('description', 'my upload');

// IMPORTANT: do NOT set Content-Type — the browser sets it with the correct boundary
const res = await fetch('/api/upload', { method: 'POST', body: form });
upload progress (XMLHttpRequest)// fetch() can't report upload progress yet — fall back to XHR for big files.
function uploadWithProgress(file, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const form = new FormData();
    form.append('file', file);
    xhr.upload.onprogress = e => e.lengthComputable && onProgress(e.loaded / e.total);
    xhr.onload  = () => xhr.status < 400 ? resolve(JSON.parse(xhr.responseText)) : reject(xhr.status);
    xhr.onerror = () => reject('network');
    xhr.open('POST', '/api/upload');
    xhr.send(form);
  });
}
download a Blobconst res  = await fetch('/api/report.pdf');
const blob = await res.blob();
const url  = URL.createObjectURL(blob);

const a = document.createElement('a');
a.href = url; a.download = 'report.pdf'; a.click();
URL.revokeObjectURL(url);   // free memory
$ man real-time

# Streaming, SSE & WebSockets

Three patterns when request/response isn't enough:

Streaming response (one-way, HTTP)

const res = await fetch('/api/chat');
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  append(decoder.decode(value, { stream: true }));
}

Server-Sent Events (SSE — one-way, auto-reconnect)

const es = new EventSource('/api/notifications');
es.onmessage = e => console.log('msg', JSON.parse(e.data));
es.addEventListener('order-created', e => refreshOrders(JSON.parse(e.data)));
es.onerror = () => console.log('disconnected — browser will auto-retry');
// Server emits text/event-stream with `data:` lines + blank-line separators.

WebSockets (bidirectional)

const ws = new WebSocket('wss://app.example.com/ws');
ws.onopen    = () => ws.send(JSON.stringify({ type: 'subscribe', room: 'chat' }));
ws.onmessage = e => render(JSON.parse(e.data));
ws.onclose   = () => setTimeout(reconnect, 1000);  // no auto-reconnect — DIY
PatternDirectionAuto-reconnectGood for
Pollingclient → server (repeated)n/ainfrequent updates, simple infra
SSEserver → clientyes (built-in)notifications, dashboards, LLM streams
WebSocketbothno (DIY)chat, multiplayer, collaborative editing
QA testing tip
Playwright supports WebSockets via page.on('websocket', ws => ws.on('framesent'/'framereceived', ...)). For SSE, intercept the route and respond with chunked text/event-stream in your fixture.
$ man caching

# HTTP caching

The browser caches responses based on headers the server sends. Understanding these is essential — half of "the test sees stale data" bugs are a misconfigured Cache-Control.

HeaderWhat it does
Cache-Control: no-storeNever cache. Use for auth + sensitive data.
Cache-Control: no-cacheCache but always revalidate before serving.
Cache-Control: max-age=3600Cache for 1 hour, no questions asked.
Cache-Control: immutablePromise: the URL never changes. Use for hashed static assets.
ETag: "abc123"Fingerprint. Browser sends If-None-Match on revalidation.
Last-Modified: Mon, 01 Jan ...Same idea but timestamp-based.
Vary: Accept, AuthorizationCache key includes these headers' values.
opt out per-fetchawait fetch(url, { cache: 'no-store'    });  // skip cache entirely
await fetch(url, { cache: 'reload'      });  // force network, update cache
await fetch(url, { cache: 'no-cache'    });  // always revalidate
await fetch(url, { cache: 'force-cache' });  // use cache even if stale
$ man retry

# Retry & exponential backoff

Network errors and 5xx responses are usually transient. Retry — but don't hammer. Exponential backoff waits twice as long after each failure (100ms → 200ms → 400ms → 800ms) with a random "jitter" so a thousand clients don't synchronise.

retry with exponential backoff + jitterasync function withRetry(fn, { tries = 4, base = 100 } = {}) {
  let attempt = 0;
  while (true) {
    attempt++;
    try {
      return await fn();
    } catch (err) {
      if (attempt >= tries) throw err;
      if (!isRetryable(err)) throw err;
      const wait = base * 2 ** (attempt - 1) + Math.random() * base;
      await new Promise(r => setTimeout(r, wait));
    }
  }
}
function isRetryable(err) {
  if (err.name === 'NetworkError') return true;
  if (err.status >= 500 && err.status < 600) return true;
  if (err.status === 429) return true;   // rate-limited
  return false;
}
— tune the retry, watch how it behaves
Set the failure rate and max retries. The track below shows each attempt's wait and outcome.
// hit simulate to run
$ man optimistic-ui

# Optimistic UI updates

Show the result before the server confirms — but be ready to roll back. Used by every fast-feeling app: the heart fills instantly, the message ticks "sent" before the network round-trip.

optimistic toggleasync function toggleLike(postId) {
  const prevLiked = state.liked[postId];
  state.liked[postId] = !prevLiked;   // 1. apply optimistically
  render();

  try {
    await api(`/posts/${postId}/like`, { method: prevLiked ? 'DELETE' : 'POST' });
  } catch (err) {
    state.liked[postId] = prevLiked;  // 2. roll back on failure
    render();
    toast('Could not save — try again');
  }
}
QA testing tip
Optimistic UI hides a class of bugs: visually the click "worked" even when the server rejected. Always assert against the persisted state — refresh the page or hit the GET endpoint and verify the field changed.
Track 2 of 9

Playwright fundamentals

Install, write your first test, learn the locator hierarchy, drive the page, assert. The day-one toolbox.

installfirst testlocatorsassertionsactions
$ npm init playwright@latest

# Install

Playwright bootstraps a project for you. The installer asks a few questions (TS or JS, where to put tests, whether to add a CI workflow) and then downloads the browser binaries.

terminal# In an empty (or existing) project directory:
$ npm init playwright@latest

# Pick TypeScript, the default tests/ folder, and "yes" to GitHub Actions.
# Playwright now installs:
#   - @playwright/test
#   - browser binaries for Chromium, Firefox, WebKit
#   - playwright.config.ts
#   - tests/example.spec.ts (a sample test)

Verify everything is wired up:

terminal$ npx playwright test

# Running 3 tests using 3 workers
#   ✓  tests/example.spec.ts:3:1 › has title (1.2s)
#   ✓  tests/example.spec.ts:8:1 › get started link (1.6s)
#
#   3 passed (8.4s)
tip
Need to install browsers later or on CI? npx playwright install downloads them. Add --with-deps on Linux to grab system libraries too.

Project layout

my-app/
├── tests/
│   └── example.spec.ts          // your tests live here
├── playwright.config.ts         // projects, retries, baseURL, reporter
├── tests-examples/              // generated demo tests
└── package.json
— walk through `npm init playwright@latest`
Click through the same prompts the installer asks. Your choices become the install command + project layout.
$ cat tests/login.spec.ts

# Your first test

Every Playwright test follows the same shape: import test and expect, drive the page with a locator API, assert with web-first matchers. Here's a login flow, annotated.

tests/login.spec.tsimport { test, expect } from '@playwright/test';

test('user can sign in', async ({ page }) => {
  // 1. Navigate. baseURL from config means we can pass a path.
  await page.goto('/login');

  // 2. Drive the form. getByLabel reads the accessible name.
  await page.getByLabel('Email').fill('ada@example.com');
  await page.getByLabel('Password').fill('lovelace');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // 3. Assert. expect(...).toBeVisible() auto-retries
  //    until it passes OR the test timeout fires.
  await expect(page.getByRole('heading', { name: 'Welcome, Ada' })).toBeVisible();
  await expect(page).toHaveURL(/\/dashboard/);
});

The three rules

  1. Use locators, not selectors. Locators are lazy — they don't resolve until an action runs — which is what gives Playwright its auto-waiting magic.
  2. Always await everything. Every Playwright call returns a Promise. Forgetting await on an assertion silently passes a broken test.
  3. Prefer expect() over manual .isVisible(). expect retries; the imperative checks don't.
— compose a working test from the snippets
Click snippets on the LEFT in the right order to build a "user can sign in" test on the RIGHT. Some snippets are distractors (forgetting await, using .isVisible() instead of expect()). Hit CHECK when done.
Available snippets
Your test (order matters)
$ man locators

# Locators

A locator is a recipe for finding an element — not the element itself. Playwright re-runs the recipe on every action, so locators survive re-renders. Prefer locators that reflect how a user perceives the page (role, label, text) over implementation details (CSS class names).

The recommended hierarchy

LocatorWhat it matchesWhen to reach for it
getByRoleARIA role + accessible nameFirst choice. Buttons, links, headings, textboxes, checkboxes.
getByLabelForm fields by their <label>Form inputs, especially custom controls.
getByPlaceholderInputs by placeholder textWhen there's no label.
getByTextVisible text contentPlain text (paragraphs, badges, status text).
getByAltTextImages by alt textImage elements.
getByTitleElements with title attrIcon buttons with a title tooltip.
getByTestIddata-testid attributeEscape hatch when nothing else fits.
page.locator(...)Raw CSS / XPathLast resort — brittle to refactors.

Try it: live locator playground

Below is a real DOM rendered inside the page. Type a locator on the left and watch the matched elements highlight on the right. Hit a chip below to try common patterns.

page.
type a locator to begin — matches highlight in green.

Checkout

Review your order and complete payment.

Wireless headphones
$129.00
Mechanical keyboard
$184.00
Free shipping on orders over $100.

By checking out you agree to our terms of service.

under the hood
The playground parses your input as a Playwright locator expression and translates it into DOM queries that approximate the real engine's behaviour (role inference from semantic tags, accessible-name lookup, text matching, test-id attributes). It's not the full engine, but it's faithful for the common cases and will catch your typical mistakes — like passing a regex to getByRole's name option or hitting more than one match.
$ man expect

# Assertions

Playwright's expect() ships with web-first matchers that auto-retry. Use them on locators — they keep polling the DOM until the assertion passes or the timeout fires (default 5s, tunable in config).

assertions// Visibility / state
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();

// Content
await expect(locator).toHaveText('Welcome');
await expect(locator).toContainText('order #42');
await expect(locator).toHaveValue('ada@example.com');
await expect(locator).toHaveAttribute('href', /\/dashboard/);
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveCount(3);

// Page-level
await expect(page).toHaveURL(/\/checkout/);
await expect(page).toHaveTitle(/Acme Store/);

// Negation: prepend .not
await expect(locator).not.toBeVisible();
watch out
expect(locator).toBeVisible() retries. expect(await locator.isVisible()).toBe(true) does not. The second form takes a snapshot, so a slightly slow render will fail. Always use the locator-form for UI assertions.

Custom timeouts

await expect(page.getByText('Report ready'))
  .toBeVisible({ timeout: 30_000 });  // big reports take a while
— pick the right matcher for each scenario
Read the scenario, pick the matcher you'd actually use. Each answer comes with an explanation.
$ man actions

# Actions

Locator methods that do something. Each one auto-waits for the element to be actionable (attached, visible, stable, enabled) before firing.

// Click variants
await locator.click();
await locator.click({ button: 'right' });        // right-click
await locator.click({ modifiers: ['Shift'] });
await locator.dblclick();

// Text input
await locator.fill('ada@example.com');          // clears then types
await locator.pressSequentially('slow type');    // emit each key
await locator.press('Enter');
await locator.press('Control+A');

// Form controls
await locator.check();
await locator.uncheck();
await locator.selectOption('US');
await locator.selectOption(['red', 'blue']);
await locator.setInputFiles('./logo.png');

// Mouse / drag
await locator.hover();
await source.dragTo(target);

// Scrolling and focus
await locator.scrollIntoViewIfNeeded();
await locator.focus();
await locator.blur();
— run actions against a real DOM
Type a chained Playwright statement. The action runs against the sample form on the right; the log below shows what happened.
await page.
// statements you run here will mutate the form on the right

Newsletter signup

Track 3 of 9

Running tests

CLI, UI mode, config — every knob you'll touch when running your nightly E2E suites.

cli flagsui moderunner simconfig
$ man playwright-test

# Running tests

Every flag you'd want is on npx playwright test. The interactive builder below toggles options and shows the exact command you'd run.

$npx playwright test

Filtering what runs

$ npx playwright test tests/checkout.spec.ts         # by file
$ npx playwright test --grep "@smoke"                # by tag in title
$ npx playwright test -g "can sign in"              # by partial title
$ npx playwright test checkout.spec.ts:42            # by line number
$ npx playwright test --project=firefox              # by config project
$ npx playwright test --last-failed                  # re-run only failures

The Inspector / UI mode

The two power tools you'll live in. UI mode is the visual runner: pick a test, watch it run frame-by-frame, time-travel through DOM snapshots, edit and re-run instantly. The Inspector is for step-through debugging — it pauses before each action, highlights the locator on the page, and surfaces a live "pick locator" tool.

$ npx playwright test --ui      # the modern visual runner
$ npx playwright test --debug   # breakpoint + step + locator picker
$ npx playwright test --reporter=line

# Watch a test run

Pick a scenario and hit RUN. The output below is a faithful re-creation of Playwright's reporter, with the same step-by-step action log, retries, and trace pointer.

scenario:
// pick a scenario and press RUN
$ cat playwright.config.ts

# Configuration

A typical playwright.config.ts — one base URL, one set of projects per browser, traces and screenshots on failure, and a web server that's auto-started for the test run.

playwright.config.tsimport { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir:        './tests',
  fullyParallel:  true,
  forbidOnly:     !!process.env.CI,
  retries:        process.env.CI ? 2 : 0,
  workers:        process.env.CI ? 1 : undefined,
  reporter:       'html',

  use: {
    baseURL:            'http://localhost:3000',
    trace:              'on-first-retry',    // trace only when retrying
    screenshot:         'only-on-failure',
    video:              'retain-on-failure',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
    { name: 'mobile',   use: { ...devices['iPhone 14'] } },
  ],

  webServer: {
    command:              'npm run dev',
    url:                  'http://localhost:3000',
    reuseExistingServer:  !process.env.CI,
  },
});
— compose a config from real choices
Toggle the options below. The config block updates live; copy it straight into your project.

  
Track 4 of 9

Scaling up: fixtures, page objects, mocking, auth

Patterns for keeping a 500-test suite maintainable. This is where junior tests become senior tests.

fixturespage objectsnetwork mockingstorage state
$ cat fixtures.ts

# Fixtures & hooks

Fixtures are Playwright's answer to "how do I share setup between tests without sticky global state". They're scoped, lazy, and isolated — each test that asks for a fixture gets its own instance.

Built-in hooks

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

test.beforeEach(async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('ada@example.com');
  await page.getByLabel('Password').fill('lovelace');
  await page.getByRole('button', { name: 'Sign in' }).click();
});

test.afterEach(async ({ page }) => {
  await page.screenshot({ path: `screens/${test.info().title}.png` });
});

test.describe('checkout', () => {
  test.use({ viewport: { width: 1440, height: 900 } });
  test('shows cart total', async ({ page }) => { /* ... */ });
});

Custom fixtures

Roll your own to keep tests DRY. Below: an authedPage fixture that injects a signed-in session before the test sees the page.

fixtures.tsimport { test as base } from '@playwright/test';

type Fixtures = { authedPage: Page };

export const test = base.extend<Fixtures>({
  authedPage: async ({ page, context }, use) => {
    await context.addCookies([
      { name: 'session', value: 'fake-token', url: 'https://app.local' }
    ]);
    await page.goto('/dashboard');
    await use(page);   // hand control to the test
  },
});

export { expect } from '@playwright/test';
import { test, expect } from './fixtures';

test('dashboard loads quickly', async ({ authedPage }) => {
  await expect(authedPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
— watch hooks fire across a describe block
Step through the lifecycle of a file with 2 tests, beforeAll, beforeEach, and afterEach. Each highlight is what's executing right now.
$ cat pages/CheckoutPage.ts

# Page Object Model

When a flow gets reused across tests, wrap it in a class. Each page object exposes actions (do something) and locators (find something) for one page or component. Tests read like prose; the locators have one place to update.

pages/CheckoutPage.tsimport { Page, Locator, expect } from '@playwright/test';

export class CheckoutPage {
  readonly page: Page;
  readonly email: Locator;
  readonly card: Locator;
  readonly payButton: Locator;
  readonly total: Locator;

  constructor(page: Page) {
    this.page       = page;
    this.email      = page.getByLabel('Email');
    this.card       = page.getByLabel('Card number');
    this.payButton  = page.getByRole('button', { name: /Pay \$/ });
    this.total      = page.getByTestId('cart-total');
  }

  async goto()                  { await this.page.goto('/checkout'); }
  async payWith(email, card) {
    await this.email.fill(email);
    await this.card.fill(card);
    await this.payButton.click();
  }
}
import { test, expect } from '@playwright/test';
import { CheckoutPage } from './pages/CheckoutPage';

test('completes a purchase', async ({ page }) => {
  const checkout = new CheckoutPage(page);
  await checkout.goto();
  await checkout.payWith('ada@example.com', '4242 4242 4242 4242');
  await expect(page).toHaveURL(/\/order-confirmation/);
});
— refactor 3 noisy tests into a Page Object
Toggle BEFORE / AFTER. Same suite, same behaviour — but the AFTER version moves locators into one class, so a redesign only touches the page object.

  
$ man network

# Network & API mocking

Real backends are slow and stateful. Intercept requests with page.route() — deny, stub, or modify them at the edge.

// Block analytics and ads entirely
await page.route('**/{gtag,gtm,analytics}.js', route => route.abort());

// Stub one specific endpoint with fixture JSON
await page.route('**/api/cart', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({
      items: [
        { id: 1, name: 'Wireless headphones', price: 12900 }
      ],
      total: 12900,
    }),
  });
});

// Modify a live response — strip a feature flag, throttle pagination, etc.
await page.route('**/api/me', async route => {
  const response = await route.fetch();
  const json     = await response.json();
  json.flags.newCheckout = true;
  await route.fulfill({ response, json });
});

// Wait for a specific request before continuing
const [response] = await Promise.all([
  page.waitForResponse(r => r.url().includes('/api/checkout') && r.ok()),
  page.getByRole('button', { name: /Pay \$/ }).click(),
]);
expect(response.status()).toBe(201);
pro move
Record a HAR file from production and replay it in tests: await page.routeFromHAR('./prod.har');. Your tests get realistic data without depending on the backend being up.
— decide what your test does with each request
Below is a list of requests the page-under-test would normally make. For each, choose ALLOW (let it through), BLOCK (abort), or STUB (return canned data). The generated page.route() code appears on the right.

    
$ cat tests/auth.setup.ts

# Reusing authentication

Logging in before every test is slow. Sign in once, save the storage state, and reuse it.

tests/auth.setup.tsimport { test as setup } from '@playwright/test';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.E2E_USER!);
  await page.getByLabel('Password').fill(process.env.E2E_PASS!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: 'storageState.json' });
});

Then wire it up in the config so every test in the project boots already signed-in:

projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: 'storageState.json' },
    dependencies: ['setup'],
  },
]
Track 5 of 9

REST API testing

"Proficient with Front End & RESTful API Testing" is in the minimum quals. Here's the rest of the picture — direct API tests with Playwright's request fixture, contract testing, and the full CRUD test pattern.

request fixtureCRUD patternschema validationcontract testing
$ man request-fixture

# Direct API testing with Playwright

Playwright ships with a request fixture for hitting APIs without a browser at all. Same auth + cookies as the browser tests, but 10× faster — use it for backend coverage and for seeding test data.

tests/api/users.spec.tsimport { test, expect } from '@playwright/test';

test.describe('POST /api/users', () => {
  test('creates a user and returns 201 with the resource', async ({ request }) => {
    const res = await request.post('/api/users', {
      data: { name: 'Ada', email: 'ada@x.com' },
    });
    await expect(res).toBeOK();          // status 2xx
    expect(res.status()).toBe(201);
    const body = await res.json();
    expect(body).toMatchObject({ name: 'Ada', email: 'ada@x.com' });
    expect(body.id).toEqual(expect.any(Number));
  });

  test('rejects invalid email with 422', async ({ request }) => {
    const res = await request.post('/api/users', { data: { name: 'X', email: 'nope' } });
    expect(res.status()).toBe(422);
    expect((await res.json()).errors).toContain('invalid email');
  });
});

Full CRUD coverage in one test

The canonical pattern: Create → Read → Update → Delete, each step proving the previous one worked.

CRUD round-triptest('user CRUD round-trip', async ({ request }) => {
  // CREATE
  const created = await request.post('/api/users', { data: { name: 'Ada', email: 'a@x.com' } });
  expect(created.status()).toBe(201);
  const { id } = await created.json();

  // READ
  const read = await request.get(`/api/users/${id}`);
  expect(await read.json()).toMatchObject({ id, name: 'Ada' });

  // UPDATE
  const updated = await request.patch(`/api/users/${id}`, { data: { name: 'Ada L.' } });
  expect((await updated.json()).name).toBe('Ada L.');

  // DELETE
  const deleted = await request.delete(`/api/users/${id}`);
  expect(deleted.status()).toBe(204);

  // READ AFTER DELETE — should 404
  expect((await request.get(`/api/users/${id}`)).status()).toBe(404);
});
coding exercise · 3 of 12
Write the orders CRUD test
Write a Playwright API test that creates an order via POST /api/orders, reads it back via GET /api/orders/:id, asserts the body matches, then deletes it via DELETE /api/orders/:id and asserts a 204. Use the request fixture and the expect matchers.
reference solution
import { test, expect } from '@playwright/test'; test('orders CRUD', async ({ request }) => { const created = await request.post('/api/orders', { data: { product: 'widget', qty: 2 }, }); expect(created.status()).toBe(201); const { id } = await created.json(); const read = await request.get(`/api/orders/${id}`); expect(await read.json()).toMatchObject({ id, product: 'widget', qty: 2 }); const deleted = await request.delete(`/api/orders/${id}`); expect(deleted.status()).toBe(204); });

Headers + auth on every request

// playwright.config.ts
use: {
  baseURL: 'https://api.staging.example.com',
  extraHTTPHeaders: {
    'Accept':        'application/json',
    'Authorization': `Bearer ${process.env.API_TOKEN}`,
  },
},

Schema validation with Zod

import { z } from 'zod';
const User = z.object({
  id:    z.number(),
  name:  z.string().min(1),
  email: z.string().email(),
});

test('GET /api/users/1 matches schema', async ({ request }) => {
  const body = await (await request.get('/api/users/1')).json();
  User.parse(body);   // throws if shape doesn't match
});
contract testing in one paragraph
The frontend team agrees on a schema (OpenAPI, JSON Schema, Zod). Both backend tests AND frontend tests validate against it. When the schema changes, both sides break loudly. Beats "the field renamed and nobody noticed for a month".
Track 6 of 9

Advanced Playwright

The scenarios that come up on real apps — iframes, dialogs, file flows, multi-user tests, mobile emulation, visual regression, accessibility, annotations.

iframesdialogsfilescontextsdevicesvisuala11yannotations
$ man frame-locator

# Iframes & nested frames

Embedded widgets — Stripe, reCAPTCHA, third-party docs — render inside <iframe>. The page's locators can't reach inside; use frameLocator() to drill in.

const stripe = page.frameLocator('iframe[name="stripe-card"]');
await stripe.getByPlaceholder('Card number').fill('4242424242424242');
await stripe.getByPlaceholder('MM / YY').fill('12 / 30');
await stripe.getByPlaceholder('CVC').fill('123');

// nested frame? chain frameLocator():
const nested = page
  .frameLocator('iframe.outer')
  .frameLocator('iframe.inner');
await nested.getByRole('button', { name: 'Submit' }).click();
— locator a button INSIDE the embedded frame
Try getByRole('button') on the outer page — it misses the button inside the iframe. Use frameLocator('iframe.payment').getByRole('button') to reach it.
page.
type a locator — see what it matches in the outer page vs. inside the frame.

Checkout (outer page)

Below is an embedded Stripe-like form rendered in an iframe.

iframe.payment
$ man dialogs

# Native dialogs (alert / confirm / prompt)

The browser pauses execution on alert(), confirm(), prompt(), and beforeunload. Playwright auto-dismisses them by default — you almost always want to register a handler explicitly.

handling each dialog type// 1. one-shot handler — runs for the next dialog only
page.once('dialog', async dialog => {
  expect(dialog.type()).toBe('confirm');
  expect(dialog.message()).toContain('Delete this order?');
  await dialog.accept();      // .dismiss() for cancel; .accept('text') for prompt
});
await page.getByRole('button', { name: 'Delete' }).click();

// 2. global handler — runs for every dialog on this page
page.on('dialog', dialog => dialog.accept());

// 3. assert the dialog DID fire (it's an async side-effect)
const dialogPromise = page.waitForEvent('dialog');
await page.getByRole('button', { name: 'Reset' }).click();
const dialog = await dialogPromise;
expect(dialog.message()).toBe('Are you sure?');
await dialog.dismiss();
$ man file-up-download

# File upload & download

Upload

// Single file
await page.getByLabel('Avatar').setInputFiles('./fixtures/avatar.png');

// Multiple files
await page.getByLabel('Attachments').setInputFiles(['./a.pdf', './b.pdf']);

// Generated in memory (no fixture file on disk)
await page.getByLabel('CSV').setInputFiles({
  name:     'data.csv',
  mimeType: 'text/csv',
  buffer:   Buffer.from('a,b,c\n1,2,3'),
});

// Custom upload button (no <input type="file">): use the chooser API
const chooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Upload' }).click();
const chooser = await chooserPromise;
await chooser.setFiles('./logo.png');

Download

// Wait for the download event, then save where you need
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Export CSV' }).click();
const download = await downloadPromise;

expect(download.suggestedFilename()).toMatch(/^orders-\d+\.csv$/);
await download.saveAs('./test-results/export.csv');
const content = await fs.readFile(await download.path(), 'utf8');
expect(content).toContain('order_id,total,status');
$ man browser-context

# Multiple browser contexts (parallel users)

A browser context is an isolated session — its own cookies, storage, cache, permissions. Two contexts = two "users" in the same test. Essential for testing chat, collaboration, share-with-X flows.

two users in one testtest('user A sends a message, user B receives it', async ({ browser }) => {
  const alice = await browser.newContext({ storageState: 'alice.json' });
  const bob   = await browser.newContext({ storageState: 'bob.json' });

  const alicePage = await alice.newPage();
  const bobPage   = await bob.newPage();
  await alicePage.goto('/chat/room-42');
  await bobPage.goto('/chat/room-42');

  await alicePage.getByPlaceholder('Say something').fill('hi bob');
  await alicePage.keyboard.press('Enter');

  await expect(bobPage.getByText('hi bob')).toBeVisible();

  await alice.close();
  await bob.close();
});
contexts vs pages vs popups
A context can hold many pages (tabs). A page can spawn popups. New tabs and popups fire on context.on('page') — listen for them when a link opens target=_blank.
$ man device-emulation

# Device emulation

One project per device profile. Playwright ships ~50 profiles (viewport, DPR, user agent, touch flags). Toggle below to see how a responsive page reshuffles.

// playwright.config.ts
projects: [
  { name: 'iPhone 14 Pro',  use: { ...devices['iPhone 14 Pro'] } },
  { name: 'iPad',          use: { ...devices['iPad Pro 11'] } },
  { name: 'desktop',       use: { viewport: { width: 1440, height: 900 } } },
  { name: 'desktop-dark',  use: { colorScheme: 'dark' } },
  { name: 'tablet-reduced', use: { reducedMotion: 'reduce' } },
],
— preview the same page across viewports
Switch between mobile / tablet / desktop. The frame below resizes; the page reshuffles its layout. Each becomes a Playwright project.

Headphones

Premium wireless · $129
$ man visual-regression

# Visual regression testing

Take a screenshot, compare against a saved baseline, fail if pixels differ. Playwright's toHaveScreenshot() handles antialiasing, animations, and tunable diff thresholds.

visual difftest('pricing page matches baseline', async ({ page }) => {
  await page.goto('/pricing');
  await page.getByRole('button', { name: 'Annual' }).click();

  await expect(page).toHaveScreenshot('pricing-annual.png', {
    maxDiffPixels: 120,        // allow tiny aliasing differences
    mask: [page.locator('.live-clock')],   // hide dynamic regions
    animations: 'disabled',
  });
});

// First run: baseline saved to pricing-annual-chromium.png
// Subsequent runs: compared. Update with:
//   npx playwright test --update-snapshots
— compare baseline vs current vs diff
Sample run where the "Annual" badge color changed. Diff highlights only what shifted.
baseline

Pro plan

ANNUAL
$199 / year
current

Pro plan

ANNUAL
$199 / year
diff

Pro plan

ANNUAL
$199 / year
187 px differ · failed
$ man accessibility

# Accessibility (a11y) testing

Catch WCAG violations as part of the suite. Wire axe-core via the official Playwright integration.

npm i -D @axe-core/playwright

import AxeBuilder from '@axe-core/playwright';

test('homepage has no accessibility issues', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa'])
    .exclude('.third-party-widget')
    .analyze();
  expect(results.violations).toEqual([]);
});
what axe catches
Missing alt text, low color contrast, form fields without labels, illegal heading order, focus traps, missing ARIA roles. NOT: screen-reader experience, keyboard navigation through complex widgets, motion sensitivity. Pair with manual tests.
$ man test-annotations

# Test annotations & soft assertions

Annotations

test.skip('not yet', ...);                          // permanently skip — paused feature
test.fixme('broken on Firefox', ...);                // skip + flag as needing fix
test.fail('known regression', ...);                 // PASS only if it FAILS — guards bug-still-open
test.slow();                                          // triple the per-test timeout

// Conditional skip — runs the function to decide
test.skip(({ browserName }) => browserName === 'webkit', 'safari-only behavior');
test.describe.configure({ mode: 'serial' });           // run tests in this describe sequentially

// Tags — filter with --grep "@smoke"
test('sign in @smoke @auth', async ({ page }) => { /* ... */ });

// Attach files to the report (logs, screenshots, csvs)
test.info().attach('server log', { path: '/tmp/server.log' });

Soft assertions

Normally a failed assertion ends the test. Soft assertions collect failures and let the test continue, so one run reports multiple issues.

test('multiple checks, all reported', async ({ page }) => {
  await page.goto('/dashboard');
  await expect.soft(page.getByText('Welcome')).toBeVisible();
  await expect.soft(page.getByRole('navigation')).toBeVisible();
  await expect.soft(page).toHaveTitle(/Dashboard/);
  // expect.poll for retrying a custom check until it passes
  await expect.poll(async () => (await getOrderCount()), { timeout: 10000 }).toBeGreaterThan(0);
});
$ man parameterized

# Parameterized tests

const users = [
  { name: 'free user',  tier: 'free',  canExport: false },
  { name: 'pro user',   tier: 'pro',   canExport: true  },
  { name: 'admin',      tier: 'admin', canExport: true  },
];

for (const u of users) {
  test(`${u.name} sees export = ${u.canExport}`, async ({ page }) => {
    await loginAs(page, u);
    const btn = page.getByRole('button', { name: 'Export' });
    u.canExport
      ? await expect(btn).toBeVisible()
      : await expect(btn).toBeHidden();
  });
}
Track 7 of 9

Performance testing

"Experience with Performance testing" is in the preferred quals — here are the metrics, the tools, and the Playwright patterns to measure them.

core web vitalslighthousek6 / ArtilleryCPU/network throttle
$ man performance

# Web performance metrics

MetricWhat it measuresGoodBad
LCPLargest Contentful Paint — biggest above-fold element rendered≤ 2.5s> 4s
INPInteraction to Next Paint — input → next frame≤ 200ms> 500ms
CLSCumulative Layout Shift — unexpected reflows≤ 0.1> 0.25
TTFBTime to First Byte — server response start≤ 800ms> 1.8s
FCPFirst Contentful Paint — any pixel≤ 1.8s> 3s

Measure Web Vitals from Playwright

capture Web Vitals during a Playwright testtest('home page LCP < 2.5s', async ({ page }) => {
  await page.goto('/');

  // Inject the web-vitals library + collect a snapshot
  const lcp = await page.evaluate(() => new Promise(resolve => {
    new PerformanceObserver(list => {
      const entries = list.getEntries();
      resolve(entries[entries.length - 1].startTime);
    }).observe({ type: 'largest-contentful-paint', buffered: true });
  }));

  expect(lcp).toBeLessThan(2500);   // 2.5 seconds
});

Network & CPU throttling

// Simulate slow 3G + a thermal-throttled CPU
const client = await page.context().newCDPSession(page);
await client.send('Network.emulateNetworkConditions', {
  offline: false,
  downloadThroughput: 1.5 * 1024 * 1024 / 8,   // 1.5 Mbps
  uploadThroughput:   750 * 1024 / 8,
  latency: 40,
});
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });

Lighthouse audit in CI

npm i -D playwright-lighthouse

import { playAudit } from 'playwright-lighthouse';
test('lighthouse: performance > 80', async ({ page }) => {
  await page.goto('/');
  await playAudit({
    page, thresholds: { performance: 80, accessibility: 90, seo: 90 },
  });
});

Load testing — what Playwright is NOT for

Playwright tests one browser session. Load testing (1000 simultaneous users hitting an API) needs purpose-built tools:

# k6 — JS-based, scriptable
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = { vus: 100, duration: '2m' };
export default () => {
  const res = http.get('https://api.example.com/products');
  check(res, { 'status 200': r => r.status === 200, '< 500ms': r => r.timings.duration < 500 });
  sleep(1);
};
# run: k6 run load.js
Track 8 of 9

The stack you'll be testing

Django + React + Bootstrap. You don't need to BUILD it — you need to recognize it so you can write tests that target the right layer.

djangoDRFreactbootstrapMVCtemplates
$ man django

# Django at a glance

Python web framework. The flow: URL → view → template (or JSON) → response. ORM does the database.

urls.pyfrom django.urls import path
from . import views
urlpatterns = [
  path('', views.home, name='home'),
  path('products/<int:id>/', views.product_detail, name='product'),
]
views.pyfrom django.shortcuts import render, get_object_or_404
from .models import Product

def product_detail(request, id):
    product = get_object_or_404(Product, id=id)
    return render(request, 'product.html', {'product': product})
templates/product.html — Django Template Engine{% extends 'base.html' %}
{% block content %}
  <h1>{{ product.name }}</h1>
  <p data-testid="price">${{ product.price }}</p>
  <form method="post" action="{% url 'add_to_cart' product.id %}">
    {% csrf_token %}
    <button type="submit">Add to cart</button>
  </form>
{% endblock %}

Django REST Framework (DRF)

from rest_framework import viewsets, serializers
from .models import Product

class ProductSerializer(serializers.ModelSerializer):
    class Meta: model = Product; fields = ['id', 'name', 'price']

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
# Auto-generates: GET /products/, POST /products/, GET /products/{id}/, PATCH /products/{id}/, DELETE /products/{id}/
QA checklist for a Django app
  • CSRF tokens — every POST form needs {% csrf_token %}. Test that omitting it 403s.
  • get_object_or_404 — verify 404 path, not just 200.
  • Pagination is default in DRF — test page 2, last page, empty list.
  • Test data: use Django's test fixtures or factory-boy. Don't share state between tests.
$ man react

# React at a glance

UI = function(state). Three things to recognize: components, props, hooks.

ProductCard.tsximport { useState } from 'react';

type Props = { id: number; name: string; price: number };

export function ProductCard({ id, name, price }: Props) {
  const [loading, setLoading] = useState(false);

  async function addToCart() {
    setLoading(true);
    await fetch('/api/cart', { method: 'POST', body: JSON.stringify({ id }) });
    setLoading(false);
  }

  return (
    <article data-testid="product-card">
      <h2>{name}</h2>
      <p>${price}</p>
      <button onClick={addToCart} disabled={loading} aria-label={`Add ${name} to cart`}>
        {loading ? 'Adding...' : 'Add to cart'}
      </button>
    </article>
  );
}
QA checklist for a React app
  • Loading states — when the button reads "Adding..." an assertion that expects "Add to cart" will flake.
  • Hydration mismatch — server rendering vs client rendering can differ briefly. Wait for the page to be idle.
  • Optimistic updates — see Track 1 § Optimistic UI. Assert against the persisted state, not the UI alone.
  • Routes: SPA navigation uses history.pushStatepage.waitForURL() after a click.
$ man bootstrap

# Bootstrap at a glance

Utility-first CSS framework. Recognize the class names; locate by role + name, not by class.

<!-- grid: 12-column responsive -->
<div class="container">
  <div class="row">
    <div class="col-md-6 col-lg-4">...</div>
    <div class="col-md-6 col-lg-8">...</div>
  </div>
</div>

<!-- common components -->
<button class="btn btn-primary">Save</button>
<button class="btn btn-outline-danger">Delete</button>
<div class="alert alert-warning" role="alert">Heads up</div>
<nav class="navbar navbar-expand-lg">...</nav>
don't locate by Bootstrap classes
page.locator('.btn-primary') is brittle — they rename to btn-success for "save complete" state. Use page.getByRole('button', { name: 'Save' }).
$ man mvc

# MVC in 60 seconds

Three layers. The JD mentions it; here's the bare minimum.

ModelData + business rules. Django models, Mongoose schemas, ActiveRecord. Owns "what is true".
ViewWhat the user sees. Templates (Django), JSX (React). Pure render of the current state.
ControllerGlue. Handles the request, fetches/updates the model, picks a view. In Django this is the view function (confusing name).

Why a QA needs this: bugs cluster differently in each layer. Model bugs → wrong data persisted. View bugs → wrong thing rendered. Controller bugs → wrong path / status code. Each maps to a different test layer (unit / integration / E2E).

Track 9 of 9

Test planning, data, Git, cloud, AI & CI

"Develop documented functional and non-functional test parameters... build and execute test plans." This track ties it together.

test planstest datagit for QAGCPAI toolsCI
$ man test-plan

# Test plans & test design

The shape of a test plan

The IEEE-829 outline that interviews often probe. Don't memorize the spec — know the headings.

  1. Scope — what's in / out. "Frontend checkout + payment redirect, not Stripe internals."
  2. Approach — what test types. "E2E happy paths via Playwright, API CRUD via request fixture, visual regression on landing/pricing."
  3. Pass/fail criteria — when does the release ship? "Zero P0/P1 bugs, < 1% flake rate on nightly."
  4. Test items & features — concrete list of what's covered.
  5. Environments — staging vs prod-like? Browsers? Devices?
  6. Test data — see next subsection.
  7. Risks & mitigation — "Flaky tests delay deploys → quarantine policy; fixture data drifts → rebuild nightly".
  8. Schedule & resources.

Test case design techniques

TechniqueWhat it doesExample
Equivalence partitioningGroup inputs that produce the same behaviour; test one from each groupFor "age 0–17 = minor, 18+ = adult", test 10 and 25, not every number.
Boundary value analysisTest on, just inside, and just outside the boundariesFor age 18+, test 17, 18, 19. Most bugs live on edges.
Decision tablesMatrix of input combinations → expected outputDiscount rules: (member ✓/✗) × (cart > $50 ✓/✗) → 4 cases.
State transitionCover legal & illegal state changesOrder: cart → paid → shipped → delivered. Try shipping an unpaid order.
Pairwise (orthogonal arrays)Cover all pairs of input values without combinatorial blow-up5 browsers × 3 OS × 4 devices = 60 combos → reduces to ~15.

Functional vs non-functional

Functional = what the system does. Login, checkout, search.
Non-functional = how well. Performance, security, accessibility, usability, scalability, reliability.

design exercise · 4 of 12
Write boundary test cases for a discount rule
Rule: "10% discount on orders ≥ $50 and ≤ $500." List 6 cart totals that exercise the boundaries (just below, exactly on, just above each edge). Format each line as $amount → expected: discount yes/no.
reference solution
// Six boundary values — three per edge $49.99 → no discount // just below low edge $50.00 → 10% discount // exactly on low edge $50.01 → 10% discount // just above low edge $499.99 → 10% discount // just below high edge $500.00 → 10% discount // exactly on high edge $500.01 → no discount // just above high edge // Also worth adding: $0 (degenerate), negative (illegal input), null (defensive).
$ man test-data

# Test data management

"Experience with test data management" is in the JD. The patterns:

PatternWhen to use
Fixtures (JSON / SQL dumps)Read-only reference data — countries, plans, currencies.
Factories (faker / factory-boy)Generate per-test data with sensible defaults + overrides.
Database seedingBeforehand: nightly job that wipes + re-seeds staging.
API-driven setupUse the request fixture to POST the data the test needs.
Per-test isolationEach test creates & deletes its own data. Avoid order dependencies.
factory pattern (per-test, isolated)async function createUser(request, overrides = {}) {
  const defaults = {
    name:  `Ada ${Date.now()}`,           // unique name
    email: `ada-${crypto.randomUUID()}@x.com`, // unique email
    tier:  'free',
  };
  const res = await request.post('/api/users', { data: { ...defaults, ...overrides } });
  return res.json();
}

test('pro user sees the export button', async ({ page, request }) => {
  const user = await createUser(request, { tier: 'pro' });
  await loginAs(page, user.email);
  await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
});
the three sins of test data
1. Shared mutable state between tests (test 4 breaks because test 3 didn't clean up).
2. Hard-coded IDs (user 42 doesn't exist anymore after a DB refresh).
3. Sensitive data (real PII) in fixtures — anonymize before committing.
$ man full-stack-scenarios

# Full-stack scenarios

▸ Scenario A — UI CRUD with optimistic updates

arrange: seed product act: edit + save assert: UI updated assert: persisted (GET) cleanup: delete
test('edit product price', async ({ page, request }) => {
  const p = await (await request.post('/api/products', { data: { name: 'Widget', price: 10 } })).json();
  await page.goto(`/products/${p.id}/edit`);
  await page.getByLabel('Price').fill('15');
  await page.getByRole('button', { name: 'Save' }).click();
  // Optimistic UI — the field updates immediately. Assert that.
  await expect(page.getByText('$15')).toBeVisible();
  // THEN verify it actually persisted (would fail if the PATCH 500'd).
  const reread = await (await request.get(`/api/products/${p.id}`)).json();
  expect(reread.price).toBe(15);
  await request.delete(`/api/products/${p.id}`);
});

▸ Scenario B — Real-time updates (SSE / WebSocket)

two contexts A posts B receives push assert B's UI

See Track 6 § Multiple browser contexts for the full pattern. Plus listen to page.on('websocket') for protocol-level assertions.

▸ Scenario C — OAuth redirect flow

click "Sign in with Google" redirect intercepted stub the callback assert logged in
// Don't test the real Google OAuth in CI — stub the callback.
await page.route('**/oauth/google', route => route.fulfill({
  status: 302,
  headers: { Location: '/auth/callback?code=fake' },
}));
await page.route('**/auth/callback*', async route => {
  const session = await issueSession('ada@x.com');
  await route.fulfill({ status: 302, headers: { Location: '/dashboard', 'Set-Cookie': session } });
});

await page.getByRole('button', { name: 'Sign in with Google' }).click();
await page.waitForURL('**/dashboard');

▸ Scenario D — Hunting a flaky test

The recipe:

  1. Reproduce: npx playwright test path/to/spec.ts --repeat-each=20 --workers=4
  2. Capture: add trace: 'on' and inspect show-trace for the failed run
  3. Common causes:
    • Missing await — use the ESLint plugin @playwright/eslint-plugin
    • Race with animation — await page.waitForLoadState('networkidle')
    • Locator matches multiple — use .first() or be more specific
    • Snapshot drift — antialiasing; set maxDiffPixels
    • Test data leakage — make tests independent
  4. Fix root cause OR quarantine with test.fixme() + ticket
$ man git-for-qa

# Git for QA

"Experience with Git source control" is in the JD. As QA you don't write release branches; you DO need to:

  • Pull a developer's branch and run their changes locally
  • Open PRs against the test repo (new tests, fixes)
  • Review developer PRs and flag "this needs a new test"
  • Resolve simple merge conflicts on test files
daily workflow$ git pull origin main
$ git checkout -b qa/playwright-checkout-flow
# ... write tests ...
$ git add tests/checkout.spec.ts
$ git commit -m "Add E2E test for guest checkout"
$ git push -u origin qa/playwright-checkout-flow
$ gh pr create --fill           # or do it in the web UI
checking out a dev's branch to test$ git fetch origin
$ git checkout dev/new-checkout-page
$ npm install
$ npm run dev &
$ npx playwright test tests/checkout.spec.ts --headed

Branching strategies

Trunk-basedEveryone merges into main daily behind feature flags. Easiest QA — one branch to test.
GitFlowLong-lived develop, release branches. QA typically runs against release/X.
GitHub FlowFeature branch → PR → merge → deploy. QA runs the suite on the PR before merge.
$ man gcp-for-qa

# Google Cloud essentials

"Proficiency with Google Cloud" is in the JD. The 5 services QA actually touches:

ServiceWhat it isWhy QA cares
Cloud BuildCI/CD pipeline runner (like GitHub Actions for GCP)This is where your nightly Playwright suite runs
Cloud RunServerless containersMost modern Django/Node apps deploy here — your test target
Cloud Storage (GCS)Object storage (S3 equivalent)Upload Playwright HTML reports + traces as artifacts
Cloud SQLManaged Postgres / MySQLThe staging DB your nightly resets
Secret ManagerVersioned secretsWhere API tokens / test creds live; pull at CI time
cloudbuild.yaml — Playwright in Google Cloud Buildsteps:
  - name: 'mcr.microsoft.com/playwright:v1.49.0-jammy'
    entrypoint: 'bash'
    args: ['-c', 'npm ci && npx playwright test --reporter=html']
    env:
      - 'BASE_URL=https://staging.example.com'
    secretEnv: ['API_TOKEN']

  - name: 'gcr.io/cloud-builders/gsutil'
    args: ['-m', 'cp', '-r', 'playwright-report/*', 'gs://qa-reports/$BUILD_ID/']

availableSecrets:
  secretManager:
    - versionName: projects/$PROJECT_ID/secrets/api-token/versions/latest
      env: 'API_TOKEN'
$ man ai-in-qa

# AI tools in your QA workflow

"Experience with AI tools such as Claude or Gemini" is in the JD. The patterns that actually save time:

1. Test generation from acceptance criteria

promptUser story:
"As a logged-in customer, I want to remove an item from my cart, so I can adjust my order before checkout. Confirmation dialog before removal. Cart total updates immediately."

Write 4 Playwright tests that cover the happy path + 3 edge cases. Use locators by role + accessible name. Each test self-cleans by deleting created data.

2. Locator refactoring

"Refactor this test to use getByRole and getByLabel instead of CSS selectors:"
<paste failing test>

3. Flake root-cause analysis

"Here's a trace.zip log + the assertion that failed 3/20 runs. Identify the most likely cause and suggest a fix."
<paste error message + relevant test code>

4. Codegen + AI cleanup

Run npx playwright codegen to record clicks. Paste the output into Claude/Gemini: "Clean this up — remove redundant waits, switch to web-first assertions, group repeated steps into a page object."

AI traps
Hallucinated locator methods (getByCss doesn't exist), outdated API patterns (old page.waitForSelector over expect().toBeVisible()), suggesting page.waitForTimeout() for flake. Always run the generated test before committing.
$ npx playwright show-trace

# Debugging

Trace viewer

The single most powerful tool. Each trace is a recording of the test: DOM snapshots before and after every action, the network log, console output, and screenshots.

$ npx playwright test --trace=on
$ npx playwright show-trace test-results/.../trace.zip

Codegen

Open the recorder, click around your app, and watch Playwright write the locators for you. Useful for getting a starting point — then rewrite for stability.

$ npx playwright codegen https://app.local
# A controlled browser opens. Your interactions become a .ts file.

Step-through with the Inspector

// Pause the script anywhere
await page.pause();   // opens the Inspector + the page side-by-side

// Or run with --debug to pause before the first action
// $ npx playwright test --debug
locator picker
Inside the Inspector or UI mode, click the  ⊕ Pick Locator  button, then hover any element on the page. Playwright suggests the most stable locator for it.
— step through a failed run in a mock trace viewer
Click any step on the LEFT to time-travel through the run. The center shows the DOM snapshot at that moment; the bottom shows console + network. The failing assertion is highlighted in red.
— see Playwright generate locators as you click
This is how npx playwright codegen works. Click around the form below; Playwright would write each interaction as a line of code on the right.

Sign up

// click around → code appears here
$ cat .github/workflows/playwright.yml

# CI integration

Playwright generates a ready-to-use GitHub Actions workflow on install. The pattern matches GitLab CI, CircleCI, Jenkins, etc. — install browsers, run the suite, upload the HTML report as an artifact.

.github/workflows/playwright.ymlname: Playwright Tests
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 'lts/*' }

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        run: npx playwright test

      - if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

Sharding across machines

Big suites? Split them across N runners.

strategy:
  matrix:
    shard: [1/4, 2/4, 3/4, 4/4]
steps:
  - run: npx playwright test --shard=${{ matrix.shard }}
— diagnose common CI pitfalls
A teammate's Playwright CI is broken. Read the symptom, pick the fix.
$ cat cheatsheet.md

# Cheat sheet

no matches — try a different keyword

Locators (preferred order)

page.getByRole('button', { name: 'Save' })by ARIA role + accessible name
page.getByLabel('Email')by form label
page.getByPlaceholder('Search…')by placeholder
page.getByText('Welcome back')by text content
page.getByAltText('Logo')by image alt
page.getByTitle('Settings')by title attribute
page.getByTestId('cart-total')by data-testid
page.locator('.cart .item').first()raw CSS — last resort

Actions

.click()click, with options for button, modifiers, position
.fill('text')clear and type into an input
.press('Enter')press a key
.check() / .uncheck()checkboxes & radios
.selectOption('US')select dropdowns
.setInputFiles('./logo.png')file inputs
.hover()hover the element
.dragTo(target)drag & drop

Assertions (web-first, auto-retrying)

toBeVisible() / toBeHidden()visibility
toHaveText('exact')exact text content
toContainText('partial')substring match
toHaveValue('ada@example.com')input value
toHaveCount(3)locator resolves to N elements
toHaveAttribute('href', /\/x/)attribute value
toHaveURL(/\/checkout/)page url
toHaveScreenshot()visual diff vs baseline

CLI commands

npx playwright testrun all tests, all browsers, all in parallel
npx playwright test --uiopen the visual UI runner
npx playwright test --debugopen the Inspector for step-through
npx playwright codegen URLrecord interactions into a test file
npx playwright show-reportopen the last HTML report
npx playwright show-trace trace.zipopen a recorded trace
npx playwright installdownload browser binaries
npx playwright install chromiumjust one browser