Playwright._
↓ start here · jump to the interactive locator playground · watch a test run
# 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.
npx playwright codegen records your clicks into a test file.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.
# 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.
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.
# 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();
A
404 or 500 still resolves the promise. You must check response.ok (true for 200–299) yourself.
fetch() call AND a simulated server response with status code, latency, and parsed body.› 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
# 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
› HTTP status codes you must know
200 OK | Success. Body has the resource. |
201 Created | POST succeeded. Location header points to the new resource. |
204 No Content | Success with no body. Common for DELETE / PUT. |
301 / 302 / 307 | Redirect. fetch follows by default. |
304 Not Modified | Cached version is still valid. Empty body. |
400 Bad Request | The request is malformed (bad JSON, missing field). |
401 Unauthorized | Not signed in. Sign in and retry. |
403 Forbidden | Signed in but not allowed. Don't retry. |
404 Not Found | The resource isn't there. |
409 Conflict | Stale write (e.g., optimistic concurrency). |
422 Unprocessable | Validation failed. Body has the field errors. |
429 Too Many Requests | Rate-limited. Check Retry-After. |
500 Internal Server Error | Bug on the server. You can retry. |
502 / 503 / 504 | Gateway / load-balancer / timeout. Retry with backoff. |
# 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.
~200ms. Rewrite it to run them in parallel using Promise.all.
The function should still return an object with user and orders.
# Error handling done right
There are three distinct error categories. A robust client distinguishes them.
| Category | What it looks like | What you do |
|---|---|---|
| Network error | fetch rejects: TypeError: Failed to fetch | Retry with backoff. Show "Offline?". |
| HTTP 4xx | Resolves; response.ok === false; status 400–499 | Surface the error to the user. Don't retry. |
| HTTP 5xx | Resolves; response.ok === false; status 500–599 | Retry 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); }
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.
# 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.
› 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
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.
# 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}` } });
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
See the Reusing authentication section — sign in once via UI, save
storageState, every subsequent test boots already signed-in.
# 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; } }
› 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) });
# 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
# 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
| Pattern | Direction | Auto-reconnect | Good for |
|---|---|---|---|
| Polling | client → server (repeated) | n/a | infrequent updates, simple infra |
| SSE | server → client | yes (built-in) | notifications, dashboards, LLM streams |
| WebSocket | both | no (DIY) | chat, multiplayer, collaborative editing |
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.
# 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.
| Header | What it does |
|---|---|
Cache-Control: no-store | Never cache. Use for auth + sensitive data. |
Cache-Control: no-cache | Cache but always revalidate before serving. |
Cache-Control: max-age=3600 | Cache for 1 hour, no questions asked. |
Cache-Control: immutable | Promise: 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, Authorization | Cache 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
# 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; }
# 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'); } }
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.
Playwright fundamentals
Install, write your first test, learn the locator hierarchy, drive the page, assert. The day-one toolbox.
# 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)
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
# 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
- Use locators, not selectors. Locators are lazy — they don't resolve until an action runs — which is what gives Playwright its auto-waiting magic.
- Always
awaiteverything. Every Playwright call returns a Promise. Forgettingawaiton an assertion silently passes a broken test. - Prefer
expect()over manual.isVisible().expectretries; the imperative checks don't.
await, using .isVisible() instead of expect()). Hit CHECK when done.# 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
| Locator | What it matches | When to reach for it |
|---|---|---|
getByRole | ARIA role + accessible name | First choice. Buttons, links, headings, textboxes, checkboxes. |
getByLabel | Form fields by their <label> | Form inputs, especially custom controls. |
getByPlaceholder | Inputs by placeholder text | When there's no label. |
getByText | Visible text content | Plain text (paragraphs, badges, status text). |
getByAltText | Images by alt text | Image elements. |
getByTitle | Elements with title attr | Icon buttons with a title tooltip. |
getByTestId | data-testid attribute | Escape hatch when nothing else fits. |
page.locator(...) | Raw CSS / XPath | Last 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.
Checkout
Review your order and complete payment.
By checking out you agree to our terms of service.
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.
# 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();
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
# 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();
Newsletter signup
Running tests
CLI, UI mode, config — every knob you'll touch when running your nightly E2E suites.
# 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.
› 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
# 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.
# 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, }, });
Scaling up: fixtures, page objects, mocking, auth
Patterns for keeping a 500-test suite maintainable. This is where junior tests become senior tests.
# 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(); });
beforeAll, beforeEach, and afterEach. Each highlight is what's executing right now.# 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/); });
# 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);
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.
page.route() code appears on the right.# 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'],
},
]
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.
# 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); });
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.
› 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 });
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".
Advanced Playwright
The scenarios that come up on real apps — iframes, dialogs, file flows, multi-user tests, mobile emulation, visual regression, accessibility, annotations.
# 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();
getByRole('button') on the outer page — it misses the button inside the iframe. Use frameLocator('iframe.payment').getByRole('button') to reach it.Checkout (outer page)
Below is an embedded Stripe-like form rendered in an iframe.
# 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();
# 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');
# 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(); });
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.
# 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' } }, ],
Headphones
# 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
Pro plan
Pro plan
Pro plan
# 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([]); });
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.
# 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); });
# 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(); }); }
Performance testing
"Experience with Performance testing" is in the preferred quals — here are the metrics, the tools, and the Playwright patterns to measure them.
# Web performance metrics
| Metric | What it measures | Good | Bad |
|---|---|---|---|
| LCP | Largest Contentful Paint — biggest above-fold element rendered | ≤ 2.5s | > 4s |
| INP | Interaction to Next Paint — input → next frame | ≤ 200ms | > 500ms |
| CLS | Cumulative Layout Shift — unexpected reflows | ≤ 0.1 | > 0.25 |
| TTFB | Time to First Byte — server response start | ≤ 800ms | > 1.8s |
| FCP | First 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
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.
# 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}/
- 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.
# 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> ); }
- 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.pushState—page.waitForURL()after a click.
# 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>
page.locator('.btn-primary') is brittle — they rename to btn-success for "save complete" state. Use page.getByRole('button', { name: 'Save' }).
# MVC in 60 seconds
Three layers. The JD mentions it; here's the bare minimum.
| Model | Data + business rules. Django models, Mongoose schemas, ActiveRecord. Owns "what is true". |
| View | What the user sees. Templates (Django), JSX (React). Pure render of the current state. |
| Controller | Glue. 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).
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 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.
- Scope — what's in / out. "Frontend checkout + payment redirect, not Stripe internals."
- Approach — what test types. "E2E happy paths via Playwright, API CRUD via request fixture, visual regression on landing/pricing."
- Pass/fail criteria — when does the release ship? "Zero P0/P1 bugs, < 1% flake rate on nightly."
- Test items & features — concrete list of what's covered.
- Environments — staging vs prod-like? Browsers? Devices?
- Test data — see next subsection.
- Risks & mitigation — "Flaky tests delay deploys → quarantine policy; fixture data drifts → rebuild nightly".
- Schedule & resources.
› Test case design techniques
| Technique | What it does | Example |
|---|---|---|
| Equivalence partitioning | Group inputs that produce the same behaviour; test one from each group | For "age 0–17 = minor, 18+ = adult", test 10 and 25, not every number. |
| Boundary value analysis | Test on, just inside, and just outside the boundaries | For age 18+, test 17, 18, 19. Most bugs live on edges. |
| Decision tables | Matrix of input combinations → expected output | Discount rules: (member ✓/✗) × (cart > $50 ✓/✗) → 4 cases. |
| State transition | Cover legal & illegal state changes | Order: cart → paid → shipped → delivered. Try shipping an unpaid order. |
| Pairwise (orthogonal arrays) | Cover all pairs of input values without combinatorial blow-up | 5 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.
$amount → expected: discount yes/no.
# Test data management
"Experience with test data management" is in the JD. The patterns:
| Pattern | When 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 seeding | Beforehand: nightly job that wipes + re-seeds staging. |
| API-driven setup | Use the request fixture to POST the data the test needs. |
| Per-test isolation | Each 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(); });
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.
# Full-stack scenarios
▸ Scenario A — UI CRUD with optimistic updates
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)
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
// 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:
- Reproduce:
npx playwright test path/to/spec.ts --repeat-each=20 --workers=4 - Capture: add
trace: 'on'and inspectshow-tracefor the failed run - 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
- Missing
- Fix root cause OR quarantine with
test.fixme()+ ticket
# 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-based | Everyone merges into main daily behind feature flags. Easiest QA — one branch to test. |
| GitFlow | Long-lived develop, release branches. QA typically runs against release/X. |
| GitHub Flow | Feature branch → PR → merge → deploy. QA runs the suite on the PR before merge. |
# Google Cloud essentials
"Proficiency with Google Cloud" is in the JD. The 5 services QA actually touches:
| Service | What it is | Why QA cares |
|---|---|---|
| Cloud Build | CI/CD pipeline runner (like GitHub Actions for GCP) | This is where your nightly Playwright suite runs |
| Cloud Run | Serverless containers | Most modern Django/Node apps deploy here — your test target |
| Cloud Storage (GCS) | Object storage (S3 equivalent) | Upload Playwright HTML reports + traces as artifacts |
| Cloud SQL | Managed Postgres / MySQL | The staging DB your nightly resets |
| Secret Manager | Versioned secrets | Where 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'
# 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."
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.
# 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
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.
npx playwright codegen works. Click around the form below; Playwright would write each interaction as a line of code on the right.Sign up
# 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 }}
# Cheat sheet
› 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 test | run all tests, all browsers, all in parallel |
npx playwright test --ui | open the visual UI runner |
npx playwright test --debug | open the Inspector for step-through |
npx playwright codegen URL | record interactions into a test file |
npx playwright show-report | open the last HTML report |
npx playwright show-trace trace.zip | open a recorded trace |
npx playwright install | download browser binaries |
npx playwright install chromium | just one browser |