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.
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.
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.
Add a second project to playwright.config.ts for firefox and run only that project from the CLI with --project=firefox.
Run the same spec in three modes - --ui, --headed, and headless - and note which one shows the time-travel timeline.
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/.
Ex 1 - baseURL set, then page.goto('/playwright/widgets/expect.html').Ex 2 - second project added, run by name.Ex 3 - three run modes. Only --ui has the time-travel timeline.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.
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
Role-based.page.getByRole('button', { name: 'Login' }) is closest to what a real user does and survives style changes.
Label / placeholder. For form fields - getByLabel('Email'), getByPlaceholder('[email protected]').
Test id.getByTestId('cart-badge') when there is no visible text. Configure testIdAttribute in the config if your app uses a different attribute name.
Text.getByText('Add to cart') for ad-hoc spans and links.
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.
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.
On /multiple_element_filter.html count the visible product cards with getByRole('article'), then filter to cards containing the text "Out of stock".
Open /webtable.html, locate the row for "Burj Khalifa" using getByRole('row'), and read the value in the fourth cell.
On the Tall Buildings table at /tables/practice.html, assert the row count and then read the "Joined" column for the third row.
Inspect the shadow-DOM widget at /widgets/shadow-dom.html. Find the inner button using getByRole without a manual shadow-piercing selector.
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.
Ex 1 - getByRole('article').filter({ hasText: 'Out of stock' }) -> 2.Ex 2 - row matched by name, fourth cell read as '2010'.Ex 3 - row count, then third row's Joined column.Ex 4 - getByRole transparently enters open shadow DOM.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.
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.
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.
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.
On /widgets/dialogs.html register a single handler that accepts alert and confirm but cancels prompt. Trigger all three and assert the page state.
On /widgets/keyboard-form.html tab between three fields, press Enter to submit, and assert the confirmation toast.
On /widgets/upload-download.html upload a small text file with setInputFiles and assert the displayed file name and size.
Ex 1 - fill, selectOption, check, click, assert summary.Ex 2 - dragTo moves card-B; counts become Backlog 1, In Progress 2.Ex 3 - single handler routes accept / accept / dismiss.Ex 4 - Tab x3 then Enter -> toast.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.
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.
On /widgets/expect.html exercise eight different locator-level assertions back to back - visibility, text, value, count, attribute, class, checked, URL.
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.
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.
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.
Use expect.poll to wait for an API-driven counter on the page to reach a target value within 8 seconds.
Ex 1 - eight locator-level expects back to back.Ex 2 - toHaveCount(5) then second row.toHaveText(...).Ex 3 - toBeVisible polls until the delayed toast shows.Ex 4 - soft asserts collect; final hard assert fails the test.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.
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-suiteawaituse(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.
Write a beforeEach that lands every test on /widgets/test-modifiers.html and asserts the page title before the test body runs.
Create a custom fixture filledForm that opens /tables/practice.html and pre-fills three fields. Two specs should reuse it.
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.
Use test.info().annotations.push({ type: 'jira', description: 'TTA-123' }) and confirm the annotation appears in the report.
Ex 1 - beforeEach lands every test on the same page.Ex 2 - one fixture, two specs consume it.Ex 3 - three steps appear as collapsibles 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 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 }).
Wrap four related specs in a single describe('cart', () => { ... }) and run them serially with describe.configure({ mode: 'serial' }).
Tag two specs @smoke and three @regression. Run only smoke from the CLI and confirm the count.
Mark one spec test.fixme with a comment explaining the bug ticket, and another test.slow with a 90-second budget.
Set workers: 4 in the config, run the full suite, and observe the worker assignment column in the HTML report.
Add retries: 1 and intentionally fail a test once - confirm the retry runs and the final status reflects the retry result.
Ex 1 - describe.configure({ mode: 'serial' }).Ex 2 - --grep @smoke matches 2 of 5.Ex 3 - fixme documents a bug, slow widens the budget.Ex 4 - workers: 4 distributes files across runners.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.