Practice Learn Playwright fundamentals
Draft
Draft - private preview - this page is not yet wired into the main sidebar. Internal review only.
Concept reference - Playwright 101

Playwright fundamentals - six-module roadmap

A guided tour through the parts of Playwright you actually use every day. Six modules - setup, locators, actions, assertions, fixtures, and the test runner. Each module ships with API chips, a few short illustrative snippets, and direct links to the TTA practice pages so you can stop reading and start clicking.

6Modules
40+Concepts
24Exercises
V1 ->Next: framework

1Setup and your first test

Install the Playwright Test runner, look at the generated playwright.config.ts, and run a single spec against a TTA practice page.

npm init playwright@latest playwright.config.ts npx playwright test --ui --headed --project=chromium test() page.goto
graph LR
  A[npm init playwright]:::start --> B[install browsers]
  B --> C[playwright.config.ts]
  C --> D[sample spec]
  D --> E[npx playwright test]:::ok
  classDef start fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
  classDef ok fill:#dcfce7,stroke:#15803d,color:#14532d;
                
Setup pipeline - one command bootstraps config, sample spec, and three browsers.

What the runner gives you

  • Project bootstrap. npm init playwright@latest writes playwright.config.ts, a sample spec, a GitHub Actions workflow, and installs the three browser binaries (Chromium, Firefox, WebKit).
  • Config knobs you will touch first. testDir, timeout, retries, use.baseURL, use.trace, use.screenshot, and the projects array for cross-browser runs.
  • Run modes. npx playwright test for headless CI, --headed to watch in a real window, --ui for the watch-mode panel with time travel, --debug to step with the inspector.
tests/smoke.spec.ts
import { test, expect } from '@playwright/test';

test('load TTA practice landing', async ({ page }) => {
  await page.goto('/playwright/widgets/expect.html');
  await expect(page).toHaveTitle(/Assertions/);
});

Exercises

  1. Initialise a fresh project, set use.baseURL to https://app.thetestingacademy.com, and write a spec that opens /playwright/widgets/expect.html with a relative path.
  2. Add a second project to playwright.config.ts for firefox and run only that project from the CLI with --project=firefox.
  3. Run the same spec in three modes - --ui, --headed, and headless - and note which one shows the time-travel timeline.
  4. Set retries: 1 in the config, force a failure with expect(page).toHaveTitle('nope'), and confirm the trace from the retry appears in test-results/.
use: { baseURL: 'https://app.thetestingacademy.com' }
Ex 1 - baseURL set, then page.goto('/playwright/widgets/expect.html').
chromium firefox --project=firefox
Ex 2 - second project added, run by name.
--ui time travel --headed watch live headless CI default
Ex 3 - three run modes. Only --ui has the time-travel timeline.
attempt 1 - fail retry trace.zip test-results/ contains the retry trace
Ex 4 - retry=1 keeps the retry trace under test-results/.

2Locators and selectors

Find elements the way Playwright wants you to - by role, label, and test-id. Fall back to CSS or XPath only when the semantic API isn't enough.

page.getByRole page.getByText page.getByLabel page.getByPlaceholder page.getByTestId page.getByAltText page.locator .filter() .first / .nth frameLocator page.locator('css') xpath=
graph TD
  A[getByRole / getByLabel / getByTestId]:::best --> B[getByText / getByPlaceholder]:::good
  B --> C[page.locator css]:::ok
  C --> D[xpath= fallback]:::last
  classDef best fill:#dcfce7,stroke:#15803d,color:#14532d;
  classDef good fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
  classDef ok fill:#fef3c7,stroke:#b45309,color:#7c2d12;
  classDef last fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d;
                
Locator priority pyramid - role first, XPath last.

Pick your locator in this order

  1. Role-based. page.getByRole('button', { name: 'Login' }) is closest to what a real user does and survives style changes.
  2. Label / placeholder. For form fields - getByLabel('Email'), getByPlaceholder('[email protected]').
  3. Test id. getByTestId('cart-badge') when there is no visible text. Configure testIdAttribute in the config if your app uses a different attribute name.
  4. Text. getByText('Add to cart') for ad-hoc spans and links.
  5. CSS / XPath. The escape hatch for legacy markup. Prefer chained .locator() + .filter() over long XPath.

Chaining and filtering

Locators compose. The strict mode error you get from a locator that resolves to two elements is your prompt to narrow down with a child locator or a filter.

row + cell from a table
const row = page.getByRole('row', { name: 'Burj Khalifa' });
const joined = row.getByRole('cell').nth(3);
await expect(joined).toHaveText('2010');

Frames and shadow DOM

For iframes use page.frameLocator('iframe[name=editor]').getByRole('textbox'). Shadow DOM works transparently with the semantic locators - getByRole and getByText pierce open shadow roots automatically. You only need extra ceremony for closed shadow roots, which are rare.

Exercises

  1. On /multiple_element_filter.html count the visible product cards with getByRole('article'), then filter to cards containing the text "Out of stock".
  2. Open /webtable.html, locate the row for "Burj Khalifa" using getByRole('row'), and read the value in the fourth cell.
  3. On the Tall Buildings table at /tables/practice.html, assert the row count and then read the "Joined" column for the third row.
  4. Inspect the shadow-DOM widget at /widgets/shadow-dom.html. Find the inner button using getByRole without a manual shadow-piercing selector.
  5. Use a CSS escape-hatch locator (page.locator('.alert.alert-danger')) where the role-based one is awkward, then justify in 1 sentence why role would still be better.
card 1 card 2 Out of stock card 3 card 4 Out of stock card 5 card 6
Ex 1 - getByRole('article').filter({ hasText: 'Out of stock' }) -> 2.
Name City Height Year One WTC Burj Khalifa Dubai 828m 2010 Shanghai Tower getByRole('row',{name:'Burj Khalifa'}).getByRole('cell').nth(3)
Ex 2 - row matched by name, fourth cell read as '2010'.
Tall Buildings (5 rows) row 3 - read 'Joined' expect(rows).toHaveCount(5) then row.nth(2).cell('Joined')
Ex 3 - row count, then third row's Joined column.
shadow-root (open) Confirm getByRole('button',{name:'Confirm'}) pierces open shadow
Ex 4 - getByRole transparently enters open shadow DOM.
.alert.alert-danger - Login failed page.locator('.alert.alert-danger').toBeVisible() role='alert' would survive style refactors
Ex 5 - CSS works, but role='alert' is more semantic.

3Actions

Click, fill, select, drag, keyboard. Every action auto-waits for the element to be actionable, so most flakiness around "the element wasn't ready" disappears.

locator.click locator.dblclick locator.fill locator.type locator.pressSequentially locator.selectOption locator.check / .uncheck locator.dragTo page.keyboard page.mouse page.on('dialog') setInputFiles
graph LR
  A[action requested]:::s --> B{attached?}
  B -- no --> X[retry until timeout]:::w
  B -- yes --> C{visible?}
  C -- no --> X
  C -- yes --> D{stable?}
  D -- no --> X
  D -- yes --> E{receives events?}
  E -- no --> X
  E -- yes --> F{enabled?}
  F -- no --> X
  F -- yes --> G[click fires]:::ok
  X --> B
  classDef s fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
  classDef w fill:#fef3c7,stroke:#b45309,color:#7c2d12;
  classDef ok fill:#dcfce7,stroke:#15803d,color:#14532d;
                
Auto-wait: five actionability checks retry until the action becomes safe.

Actionability checks before every click

Playwright waits for the element to be attached, visible, stable, receive events, and not disabled before firing the action. You almost never need a manual waitForSelector.

Filling forms

  • fill('Aarav') clears the field and sets the value in one step - this is what you want 95% of the time.
  • type('Aarav', { delay: 50 }) simulates key-by-key typing. Use this when the page has a debounced search or a typeahead.
  • pressSequentially('Aarav') is the newer, clearer name for the same key-by-key behaviour.
  • selectOption('Z to A') works by visible text by default. Pass { value: 'za' } when only the value attribute is stable.
  • check() and uncheck() idempotently toggle checkboxes and radios.
  • setInputFiles('./fixtures/avatar.png') uploads without ever opening the native file chooser.

Drag and drop, keyboard, dialogs

Drag with source.dragTo(target) for tidy HTML5 drag-and-drop. Use page.mouse for finer-grained control when the app uses pointer events. Press multi-key combos via page.keyboard.press('Control+Shift+P'). For native confirm / alert / prompt, register a handler with page.on('dialog', d => d.accept()) before triggering the action that opens it.

Exercises

  1. On /tables/practice.html fill the QA Profile form with fill for every text input, selectOption for the dropdown, check for the checkbox group, and assert the submission summary.
  2. On /widgets/dnd.html drag a card from the "Backlog" column to "In Progress" using dragTo, then assert both columns have the new item counts.
  3. On /widgets/dialogs.html register a single handler that accepts alert and confirm but cancels prompt. Trigger all three and assert the page state.
  4. On /widgets/keyboard-form.html tab between three fields, press Enter to submit, and assert the confirmation toast.
  5. On /widgets/upload-download.html upload a small text file with setInputFiles and assert the displayed file name and size.
Aarav [email protected] Mumbai QA SDET Submit Saved.
Ex 1 - fill, selectOption, check, click, assert summary.
Backlog (2) card-A moved -> In Progress (1) card-B
Ex 2 - dragTo moves card-B; counts become Backlog 1, In Progress 2.
alert d.accept() PASS confirm d.accept() PASS prompt d.dismiss() cancelled
Ex 3 - single handler routes accept / accept / dismiss.
field 1 Tab field 2 Tab field 3 press('Enter') Saved
Ex 4 - Tab x3 then Enter -> toast.
setInputFiles() notes.txt notes.txt 2.4 KB no native chooser opens
Ex 5 - setInputFiles attaches and the UI shows name + size.

4Assertions with expect

Web-first assertions retry until they pass or time out. That is the secret to non-flaky tests. Learn the locator-level expects, then layer in soft assertions for full-page checks.

expect(locator).toBeVisible .toBeHidden .toHaveText .toContainText .toHaveValue .toBeChecked .toHaveCount .toHaveAttribute .toHaveClass expect(page).toHaveURL .toHaveTitle expect.soft expect.poll .not.toBeVisible
graph LR
  A[expect locator]:::s --> B[check matcher]
  B -- pass --> D[continue]:::ok
  B -- fail --> C[wait 100ms]
  C --> E{timeout reached?}
  E -- no --> B
  E -- yes --> F[throw error]:::bad
  classDef s fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
  classDef ok fill:#dcfce7,stroke:#15803d,color:#14532d;
  classDef bad fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d;
                
Web-first assertion poll - retry the matcher until pass or timeout.

Web-first auto-waiting

Each expect(locator).toX() retries internally up to the configured timeout (default 5 seconds). You almost never need await page.waitForTimeout(...). Sleeps are a smell - prefer a real assertion or a network wait.

Soft assertions

expect.soft records the failure but lets the test continue, so a single test can check three things on the order-summary page and report all three. Combine soft asserts with a final regular expect to make the test fail at the end.

soft + hard mix
await expect.soft(page.getByText('Order #')).toBeVisible();
await expect.soft(page.getByText('Total $99.97')).toBeVisible();
await expect(page).toHaveURL(/checkout-complete/);

Exercises

  1. On /widgets/expect.html exercise eight different locator-level assertions back to back - visibility, text, value, count, attribute, class, checked, URL.
  2. On /webtable.html assert the table has exactly the number of rows the page header claims, then read the second row and check its text.
  3. Force a flaky check with a 2-second delayed update on /widgets/toasts.html and prove that expect(locator).toBeVisible() waits without an explicit sleep.
  4. Write three soft assertions on the order-confirmation summary, then a final expect(page).toHaveURL. Confirm the test reports all three soft failures when you intentionally break the price line.
  5. Use expect.poll to wait for an API-driven counter on the page to reach a target value within 8 seconds.
visible PASS text PASS value PASS count PASS attr PASS class PASS checked PASS URL PASS 8 / 8 web-first assertions green no manual waits needed
Ex 1 - eight locator-level expects back to back.
Header says: 5 tall buildings row 2 - assert text
Ex 2 - toHaveCount(5) then second row.toHaveText(...).
t=0 2s waiting... poll, poll, poll visible PASS expect(toast).toBeVisible() - no sleep
Ex 3 - toBeVisible polls until the delayed toast shows.
soft: Order # soft: Total FAIL soft: button expect(page).toHaveURL(/complete/) test fails with all 3 reported
Ex 4 - soft asserts collect; final hard assert fails the test.
expect.poll(() => counter()).toBe(10) 3 poll 1 5 poll 2 7 9 10 match PASS in 4s
Ex 5 - expect.poll waits until counter reaches 10.

5Fixtures and hooks

Fixtures replace the setup/teardown gymnastics from other runners. Built-ins give you page, context, and browser. Custom fixtures inject your page objects, your logged-in user, your API client.

page context browser request test.beforeAll test.afterAll test.beforeEach test.afterEach test.extend use() test.step test.info()
graph LR
  A[beforeAll]:::all --> B[beforeEach]:::each
  B --> C[test body]:::test
  C --> D[afterEach]:::each
  D --> E{more tests?}
  E -- yes --> B
  E -- no --> F[afterAll]:::all
  classDef all fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
  classDef each fill:#fef3c7,stroke:#b45309,color:#7c2d12;
  classDef test fill:#dcfce7,stroke:#15803d,color:#14532d;
                
Fixture lifecycle - beforeAll once, beforeEach / afterEach per test.

Built-in fixtures

  • page - a fresh browser tab per test, isolated from other tests.
  • context - the parent browser context. Use it directly to open extra pages, set cookies, or change viewport on the fly.
  • browser - the running Chromium / Firefox / WebKit instance. Rarely needed except for very advanced setups.
  • request - a dedicated APIRequestContext for HTTP calls, separate from the browser session.

Custom fixtures with test.extend

Wrap setup so every spec gets a ready-to-use object - a logged-in page, a constructed LoginPage, a seeded cartId. The fixture function returns through await use(value); anything after use runs as teardown.

fixtures/test-base.ts
import { test as base } from '@playwright/test';
type Fix = { signedInPage: Page };

export const test = base.extend<Fix>({
  signedInPage: async ({ page }, use) => {
    await page.goto('/playwright/ttacart/index.html');
    // log in once per test - or load storageState for once-per-suite
    await use(page);
  },
});

Hooks

test.beforeAll and test.afterAll run once per worker. test.beforeEach and test.afterEach run around every test. Wrap multi-step business logic in test.step('checkout - step 1', async () => { ... }) so the HTML report and trace show readable stages.

Exercises

  1. Write a beforeEach that lands every test on /widgets/test-modifiers.html and asserts the page title before the test body runs.
  2. Create a custom fixture filledForm that opens /tables/practice.html and pre-fills three fields. Two specs should reuse it.
  3. Inside a single test, group three logical phases with test.step - "load", "interact", "verify". Confirm the steps show up as collapsible nodes in the HTML report.
  4. Use test.info().annotations.push({ type: 'jira', description: 'TTA-123' }) and confirm the annotation appears in the report.
beforeEach page.goto / title check test body runs already on /widgets/test-modifiers.html
Ex 1 - beforeEach lands every test on the same page.
filledForm /tables/practice 3 fields pre-filled spec A reuses spec B reuses
Ex 2 - one fixture, two specs consume it.
step: load 1 action step: interact 5 actions step: verify 3 asserts HTML report shows 3 collapsible nodes test name -> step load -> step interact -> step verify
Ex 3 - three steps appear as collapsibles in the report.
test cart adds item jira TTA-123 annotations appear next to the test name in the report
Ex 4 - jira annotation shown in HTML report.

6Test runner basics

Group, filter, parallelise. The Playwright runner is built around describe blocks, modifiers, tags, and a multi-worker model that runs files in parallel.

test.describe test.only test.skip test.fixme test.fail test.slow --grep @smoke --grep-invert retries workers describe.serial describe.parallel
stateDiagram-v2
  [*] --> pending
  pending --> running
  running --> passed
  running --> failed
  running --> skipped
  failed --> flaky : retry passed
  flaky --> [*]
  passed --> [*]
  failed --> [*]
  skipped --> [*]
                
Test runner state machine - retries can promote failed to flaky.
graph LR
  A[all specs]:::all --> B{grep filter}
  B -- match @smoke --> C[selected smoke specs]:::ok
  B -- no match --> D[excluded]:::skip
  C --> E[run]
  classDef all fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
  classDef ok fill:#dcfce7,stroke:#15803d,color:#14532d;
  classDef skip fill:#f1f5f9,stroke:#94a3b8,color:#475569;
                
Tags + grep - title-embedded tags filter the run.

describe and modifiers

  • test.describe('login', () => { ... }) groups related specs.
  • test.only isolates one spec during local debugging. CI should fail if .only sneaks in - set forbidOnly: !!process.env.CI in the config.
  • test.skip / .fixme / .fail document why a spec is paused, broken, or expected to fail.
  • test.slow triples the timeout for that single test. Useful for legitimately slow flows like a multi-step checkout.

Tags via grep

Embed tags right in the title: test('@smoke standard user logs in', ...). Then run them by name with --grep @smoke or invert with --grep-invert @flaky. Tag conventions to start with: @smoke, @regression, @p0, @e2e, @visual.

Parallelism, retries, timeouts

Files run in parallel by default, tests within a file run serially. Override with test.describe.configure({ mode: 'parallel' }) to parallelise inside a file. retries: 1 in CI catches network blips. Per-test timeout via test.setTimeout(60_000); per-action timeout via locator.click({ timeout: 10_000 }).

Exercises

  1. Wrap four related specs in a single describe('cart', () => { ... }) and run them serially with describe.configure({ mode: 'serial' }).
  2. Tag two specs @smoke and three @regression. Run only smoke from the CLI and confirm the count.
  3. Mark one spec test.fixme with a comment explaining the bug ticket, and another test.slow with a 90-second budget.
  4. Set workers: 4 in the config, run the full suite, and observe the worker assignment column in the HTML report.
  5. Add retries: 1 and intentionally fail a test once - confirm the retry runs and the final status reflects the retry result.
describe('cart') - serial mode add remove qty total 4 specs run in fixed order
Ex 1 - describe.configure({ mode: 'serial' }).
npx playwright test --grep @smoke @smoke A @smoke B @regression @regression Running 2 tests smoke selected, regression skipped
Ex 2 - --grep @smoke matches 2 of 5.
test.fixme // bug TTA-456 skipped, marked test.slow timeout x3 = 90s runs, just longer
Ex 3 - fixme documents a bug, slow widens the budget.
w-1 spec a spec e w-2 spec b spec f w-3 spec c spec g w-4 spec d spec h
Ex 4 - workers: 4 distributes files across runners.
attempt 1 FAIL retry PASS final status = flaky (retry-passed)
Ex 5 - retries: 1 + a transient failure becomes 'flaky'.
Next stop -> Once you can confidently land all six modules, jump to Playwright advanced for POM, fixtures with DI, network interception, visual regression, and CI sharding.