Run Web Automation Tests at Scale with Agentic AI
Generate tests with AI agents or automate test suites with Selenium, Playwright and other frameworks. Run tests in parallel on TestMu AI scalable cloud grid.
- TestMu AI (Formerly LambdaTest)
- /
- Learning Hub
- /
- Playwright Script Examples: 24 You Can Run in JS and Python
Playwright Script Examples: 24 Runnable Scripts in JS and Python
24 Playwright script examples in JavaScript and Python, run against live pages: forms, iframes, mocking, uploads, downloads, new tabs and visual checks.
Last Updated on:
Playwright examples often target an application you cannot open, so you can read them but not run them. Every script below points at a public page you can open right now.
These are 24 Playwright script examples you can run, all of them in JavaScript and five repeated in Python. Each was executed against those live pages, and every JavaScript example was run again on Playwright 1.64 in October 2026. Four failed on the first attempt, and the three causes are written up near the end with the errors they produced.
TL;DR
These 24 Playwright script examples cover form input, dropdowns, iframes, table search and scraping, network mocking, response waits, API calls, retries, auth reuse, screenshots and visual comparison, dialogs, new tabs, file upload and download, mobile emulation, and keyboard input. Each one runs against a public page on the TestMu AI Selenium Playground, so every selector is real, and five are repeated in Python.
What Do These Examples Cover?
- Both bindings: Twenty-four examples in JavaScript, five of them repeated with the Python sync API, so the same flow can be read line by line in either language.
- Live target pages: Every script points at a public Selenium Playground page rather than a placeholder app, so the URLs and selectors work without an application of your own.
- Reused auth state: Recipe 11 saves a signed-in session with storageState so later specs skip the login flow entirely, usually the largest single saving in a suite.
- Documented failures: Four examples failed on the first run, from duplicate element ids, a React hydration race and a button that stays disabled after fill. Each cause is written up with its error message and its fix.
What Is a Playwright Script?
A Playwright script is code that drives a real browser through the Playwright API. It opens pages, finds elements with locators, performs actions such as click and fill, and checks the result with assertions.
It comes in two shapes. A test is run by the Playwright Test runner, which gives it a fresh page, retries and a report; every numbered example here is a test. A plain automation script is run with Node or Python and suits scraping, screenshots and one-off jobs:
// script.js
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://www.testmuai.com/selenium-playground/');
console.log(await page.title());
await browser.close();
})();Run it with node script.js and it prints the page title. It uses the Chromium build installed in the setup below. To drive the Chrome already on your machine, pass the channel option set to chrome in the launch call, which is how every script here was run.
Whichever shape you write, the locator decides how long the script survives. The Playwright docs recommend role locators first and advise against CSS and XPath, because the DOM changes more often than what the user sees:
| Locator | Example | Use It For |
|---|---|---|
| getByRole | page.getByRole('button', { name: 'Submit' }) | Buttons, links, headings and anything else with an accessible name. |
| getByLabel | page.getByLabel('Email') | Form fields that have a label. |
| getByPlaceholder | page.getByPlaceholder('Search') | Inputs with no label. |
| getByText | page.getByText('Welcome back') | Text that is not interactive. |
| getByTestId | page.getByTestId('cart-count') | Markup you control, where a test id costs one attribute. |
| CSS or XPath | page.locator('#submit') | What is left when none of the others fits. It breaks when the markup changes. |
Most of the examples below still use CSS ids. The playground is an application we do not control, and ids are what its controls carry. On your own application, add labels or test ids and use the rows higher in the table.
Setup Used by Every Example
Each example targets a page on the public TestMu AI Selenium Playground, so the URLs and selectors are real and the scripts execute without an application of your own. If you are setting Playwright up for the first time, start with how to install Playwright and come back. If the framework itself is new to you, the Playwright tutorial explains how it works before any code. If you want the concepts behind the syntax rather than working code, Playwright with JavaScript covers them properly.
Two files. Every one of the Playwright examples after this drops straight into the spec file and runs.
npm init -y
npm i -D @playwright/test
npx playwright install chromium// playwright.config.js
module.exports = {
testDir: './tests',
timeout: 60000,
use: {
headless: true,
// These examples hit a live public site, so keep the worker count
// modest. Hammering it from eight workers produces failures that
// look like test bugs and are actually rate limiting.
baseURL: 'https://www.testmuai.com/selenium-playground/',
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
};The trailing slash on baseURL and the ./ in front of every path belong together. A path that starts with a slash resolves against the domain root, drops /selenium-playground and lands on a 404 page, where every locator then times out.
Run any single example with the test title:
npx playwright test -g "fills a form" --workers=2The flags for filtering, debugging and reporting a run are listed with the other Playwright CLI commands.
1. Fill a Form and Assert
The starting example has two details the test depends on. The locator is qualified by tag because this page reuses the id on wrapper elements, and the script waits for the network to settle because the form is React-rendered.
const { test, expect } = require('@playwright/test');
test('fills a form and reads the value back', async ({ page }) => {
const message = 'Welcome to TestMu AI';
await page.goto('./simple-form-demo/');
// Wait for hydration. Typing before React attaches its handler writes
// to the DOM but not to component state, and the button reads nothing.
await page.waitForLoadState('networkidle');
// "input#" and not "#": this page carries wrapper divs with the same id,
// and a bare #user-message matches three elements, failing strict mode.
await page.locator('input#user-message').fill(message);
await page.locator('#showInput').click();
// Web-first assertion. It retries until it passes or the timeout expires,
// so there is no explicit wait to write here.
await expect(page.locator('#message')).toHaveText(message);
});Strip either of those two lines and the test fails, which is what happened the first time it ran. The full diagnosis is in the failures section below.
2. Dropdowns and Multi-Select
Native select elements have a dedicated method, and it accepts an array for multi-select. There is no separate Select class to import the way Selenium requires.
test('selects single and multiple options', async ({ page }) => {
await page.goto('./select-dropdown-demo/');
// By value. Also accepts { label: 'Wednesday' } or { index: 3 }.
await page.locator('#select-demo').selectOption('Wednesday');
await expect(page.locator('#select-demo')).toHaveValue('Wednesday');
// Multi-select: pass an array. This replaces the whole selection
// rather than adding to it, which trips people up on the second call.
await page.locator('#multi-select').selectOption(['California', 'Florida']);
await page.locator('#printMe').click();
});A custom dropdown built from divs is a different problem entirely, since selectOption only works on a real select element. For those, click the trigger and then click the option by its role and name.
3. Checkboxes and Radios
Use check rather than click. It asserts the resulting state, so a click that lands on a covering label and toggles nothing fails immediately instead of passing silently.
test('checks a box and selects a radio', async ({ page }) => {
await page.goto('./checkbox-demo/');
// These inputs have no id, so locate them by name attribute.
const option = page.locator('input[name="option1"]');
await option.check();
await expect(option).toBeChecked();
await page.goto('./radiobutton-demo/');
// Attribute chaining picks one radio out of a same-named group.
const male = page.locator('input[name="optradio"][value="Male"]');
await male.check();
await expect(male).toBeChecked();
await page.locator('#buttoncheck').click();
});Calling check on an already-checked box is a no-op rather than a toggle, which is the behaviour you want in a test. Use uncheck to clear one, and reserve setChecked for cases where the desired state comes from a variable.
4. Waiting for Dynamic Data
This page fetches a random user from a third-party API on click, so the content arrives after an unpredictable delay. It is the case explicit waits were invented for, and the case Playwright removes the need for.
test('waits for asynchronously loaded content', async ({ page }) => {
await page.goto('./dynamic-data-loading-demo/');
// Same hydration wait as example 1. Without it the click can land
// before the page is ready, and the image never appears.
await page.waitForLoadState('networkidle');
await page.locator('#save').click();
// No sleep and no polling loop. The assertion retries internally until
// the image exists and is visible, or the timeout expires. The timeout
// is generous here because the data comes from a third-party API.
await expect(page.locator('#loading img')).toBeVisible({ timeout: 20000 });
await expect(page.locator('#loading')).toContainText('First Name');
});The 20-second timeout is deliberate. When a test depends on a service you do not control, the timeout has to cover that service's worst case or the test reports your application as broken when the third party was slow. This example failed twice during writing for that reason.
Note: These examples run on one local Chromium. TestMu AI runs the same specs across 3,000+ browser and OS combinations without changing the test code, only the connection. Start free
5. Working Inside an iframe
Playwright resolves the frame at action time, so there is no switching into a frame and no remembering to switch back out. Chain locators off frameLocator and the rest of the API behaves normally.
test('reads content inside an iframe', async ({ page }) => {
await page.goto('./iframe-demo/');
const frame = page.frameLocator('#iFrame1');
await expect(frame.locator('body')).toBeVisible();
// Nested frames chain, and there is no switch-back step to forget.
// const inner = frame.frameLocator('#nested').locator('#field');
});This is the largest ergonomic gap between Playwright and older frameworks. A Selenium test that throws inside a frame leaves the driver's context pointing at that frame, so the next test in the same session fails for an unrelated reason. Playwright has no context to leave behind.
6. Searching a Table
A locator that matches many elements is a collection you can count and filter, which is what makes table assertions short.
test('filters a table and asserts on the rows', async ({ page }) => {
await page.goto('./table-sort-search-demo/');
await page.getByLabel('Search:').fill('New York');
const rows = page.locator('#example tbody tr');
await expect(rows).not.toHaveCount(0);
// filter() narrows a collection by its content, which beats writing
// an XPath that walks the table looking for a sibling cell.
const match = rows.filter({ hasText: 'New York' });
await expect(match.first()).toBeVisible();
});Assert on a property of the result set rather than on an exact row count where you can. A test asserting exactly 12 rows breaks the day someone adds sample data; a test asserting that every visible row contains the search term keeps testing the actual behaviour.
7. Mocking a Network Call
Interception happens in the browser, so the application cannot tell the response was manufactured. This is the shortest route to testing error and empty states that are awkward to produce against a real backend.
test('serves a mocked API response', async ({ page }) => {
// Register the route BEFORE navigating, or the first request escapes.
await page.route('**/api/profile', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ name: 'Mocked User', plan: 'enterprise' }),
}));
await page.goto('./simple-form-demo/');
const res = await page.evaluate(() =>
fetch('/api/profile').then(r => r.json()));
expect(res.name).toBe('Mocked User');
});
test('simulates a server error', async ({ page }) => {
await page.route('**/api/**', route => route.fulfill({ status: 500 }));
await page.goto('./simple-form-demo/');
// Assert on whatever your application renders when its API fails.
});Registering the route after the navigation is the classic mistake here, and it produces a test that passes against the real API and reports nothing useful. Call route first, always.
8. API Calls Without a Browser
The request fixture makes HTTP calls with no page and no browser launch. In our run this test finished in 106 milliseconds against roughly 600 for the equivalent browser test, which is why it is the right tool for seeding data or asserting a status code.
test('asserts on an endpoint directly', async ({ request }) => {
const res = await request.get('https://www.testmuai.com/selenium-playground/');
expect(res.status()).toBe(200);
});
test('seeds data over the API, then verifies in the UI', async ({ page, request }) => {
// Pattern only: /api/items and /items/ stand in for your own app.
// Setup through the API is faster and less brittle than clicking
// through a creation flow that is not what this test is checking.
const created = await request.post('/api/items', {
data: { name: 'fixture-item' },
});
expect(created.ok()).toBeTruthy();
await page.goto('./items/');
await expect(page.getByText('fixture-item')).toBeVisible();
});The second pattern is the one worth adopting deliberately. Reaching a state through the interface tests the creation flow again on every unrelated test, which is both slow and a way to make one broken form fail forty tests. For the same reason, covered further in Playwright API testing, API setup keeps failures pointing at the thing that broke.
9. Retrying a Flaky Block
A retrying assertion cannot help when the action itself was too early. If a click consumed state that had not been attached yet, no assertion timeout will recover it, because the click already happened. toPass retries the whole block, action included.
test('retries the action and the assertion together', async ({ page }) => {
const message = 'Welcome to TestMu AI';
await page.goto('./simple-form-demo/');
await expect(async () => {
await page.locator('input#user-message').fill(message);
await page.locator('#showInput').click();
// Short inner timeout: fail fast so the OUTER retry comes round again.
await expect(page.locator('#message')).toHaveText(message, { timeout: 1000 });
}).toPass({ timeout: 15000 });
});The short inner timeout is the part that makes this work. Leave it at the default and the first attempt burns the entire outer budget waiting on state that will never arrive, so nothing is ever retried.
Use this sparingly and with a comment saying what race it covers. It is a legitimate answer to a genuine startup race in the application, and an excellent way to hide a real defect if applied to a test that fails for a reason worth knowing about.
10. Data-Driven Tests
Declare the tests in a loop rather than looping inside one test. Each case then reports separately, distributes across workers, and keeps running after one of them fails.
const cases = [
{ day: 'Monday', expected: 'Monday' },
{ day: 'Wednesday', expected: 'Wednesday' },
{ day: 'Friday', expected: 'Friday' },
];
for (const { day, expected } of cases) {
// The title must be unique per case, or the report collapses them.
test(`selects ${day} from the dropdown`, async ({ page }) => {
await page.goto('./select-dropdown-demo/');
await page.locator('#select-demo').selectOption(day);
await expect(page.locator('#select-demo')).toHaveValue(expected);
});
}The difference shows up on failure. Written as one test with a loop inside, a broken Monday hides whether Wednesday and Friday work at all. Written this way, you get three verdicts and know immediately whether the problem is one value or the whole control.
Separate tests also parallelise. Three cases declared in a loop occupy three workers; three assertions inside one test occupy one, which matters once the case list is long. The measured effect of that is in Playwright parallel testing.
11. Reusing Auth State
Signing in through the interface once per test is often the largest avoidable cost in a suite. Save the browser state once, then start every later test already authenticated.
test('save and reuse auth state', async ({ browser }, testInfo) => {
// A path inside the output folder of this test, so two workers never
// read and delete the same file.
const statePath = testInfo.outputPath('state.json');
// Sign in once in a throwaway context, then persist cookies and storage.
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('./simple-form-demo/');
await page.evaluate(() => localStorage.setItem('demo-session', 'signed-in'));
await ctx.storageState({ path: statePath });
await ctx.close();
// Every later context starts already signed in. No login steps, no waiting.
const ctx2 = await browser.newContext({ storageState: statePath });
const page2 = await ctx2.newPage();
await page2.goto('./simple-form-demo/');
expect(await page2.evaluate(() => localStorage.getItem('demo-session')))
.toBe('signed-in');
await ctx2.close();
});In a real suite the save step belongs in a setup project that the other projects list under dependencies. It then runs once per run, and it appears in the HTML report and the trace like any other test. One caution: the saved state ages. If your session tokens expire in an hour and the suite runs longer, regenerate it in setup rather than committing the file.
12. Screenshots
Playwright captures the full page, the viewport, or a single element. The element form is the one worth reaching for, because a diff on one component does not fail every time an unrelated banner changes.
test('captures full page and element screenshots', async ({ page }) => {
await page.goto('./simple-form-demo/');
await page.waitForLoadState('networkidle');
// Whole scrollable page, not just the viewport.
await page.screenshot({ path: 'full.png', fullPage: true });
// A single element. Far more stable as a visual baseline.
await page.locator('input#user-message').screenshot({ path: 'field.png' });
});Omitting the path returns a buffer instead of writing a file, which is what you want when the screenshot is being attached to a report rather than committed as a baseline.
13. Dialogs and Alerts
Native alert, confirm, and prompt dialogs block the page. Playwright auto-dismisses them unless you register a handler, which is why a test that expects a confirm to appear sometimes reports that nothing happened.
test('handles a native dialog', async ({ page }) => {
await page.goto('./javascript-alert-box-demo/');
await page.waitForLoadState('networkidle');
let seen = null;
// Register BEFORE the action that triggers it. A handler added afterwards
// never fires, because Playwright already dismissed the dialog.
page.on('dialog', async dialog => {
seen = { type: dialog.type(), message: dialog.message() };
await dialog.accept(); // .dismiss() to cancel
// await dialog.accept('typed'); // for a prompt
});
// Three buttons on this page are named "Click Me". Scope to the row
// that holds the alert one and leave .first() out of it.
await page.locator('p', { hasText: 'JavaScript Alerts' })
.getByRole('button', { name: 'Click Me' }).click();
// The click returns once the handler has dealt with the dialog,
// so no timeout is needed before the assertion.
expect(seen).toEqual({ type: 'alert', message: 'I am an alert box!' });
});Assert on the dialog message, not just that one appeared. A confirm that says the wrong thing still passes a test that only checks the type.
14. Keyboard Input
Keyboard behaviour is worth testing directly, because submitting a form with Enter and submitting it by clicking the button are different code paths in most applications, and only one of them usually has a test.
test('sends keyboard input', async ({ page }) => {
await page.goto('./key-press/');
await page.waitForLoadState('networkidle');
const field = page.locator('input#my_field');
await field.click();
await field.press('Enter');
await expect(page.locator('p#result')).not.toHaveText('');
// Modifiers and sequences:
// await field.press('Control+A');
// await page.keyboard.type('typed slowly', { delay: 50 });
});Use press for a single key or combination and pressSequentially when the application reacts to each keystroke, such as a search box that filters as you type. For plain data entry, fill is faster and less brittle than either.
16. Asserting on a List
A locator that matches several elements can be asserted as a whole. You can check its count, the text of one item, or a subset of texts in order.
test('adds an item and asserts on the list', async ({ page }) => {
await page.goto('./todo-app/');
await page.waitForLoadState('networkidle');
const items = page.locator('li.todo-item');
const before = await items.count();
await page.locator('#sampletodotext').fill('Write a Playwright test');
await page.locator('#addbutton').click();
// Count relative to the starting state, so the test survives a change
// to the sample data.
await expect(items).toHaveCount(before + 1);
await expect(items.last()).toHaveText('Write a Playwright test');
// An array checks a subset, in order. toHaveText with an array
// would need every item on the list.
await expect(items).toContainText(['Run automated tests', 'Write a Playwright test']);
});The list held 5 items when we ran this. Reading the count first keeps that number out of the test, which is the same reasoning as the table example above.
17. Handling a New Tab
A link that opens a new tab gives the test a second page object. Start waiting for the popup event before the click that triggers it, then use the new page like any other.
test('handles a link that opens a new tab', async ({ page }) => {
await page.goto('./window-popup-modal-demo/');
await page.waitForLoadState('networkidle');
await page.locator('input[type="url"]').fill('https://www.testmuai.com/selenium-playground/');
// Create the promise first. Awaiting it here would hang until the
// test times out, because nothing has opened the tab yet.
const popupPromise = page.waitForEvent('popup');
await page.getByText('Open URL', { exact: true }).click();
const popup = await popupPromise;
await expect(popup).toHaveURL(/\/selenium-playground\/$/);
await expect(popup.getByRole('heading', { level: 1 })).toHaveText('Selenium Playground');
// The original page is still open and still usable.
await expect(page).toHaveURL(/window-popup-modal-demo/);
});Both pages live in the same browser context, so cookies and storage are shared between them. If you cannot tell which page will open the tab, listen on the context with context.waitForEvent('page'). Windows and frames get a longer treatment in handling frames and windows in Playwright.
18. Waiting for a Network Response
Example 4 waited for the result of a request to appear on the page. This one waits for the request itself, so the test can check the status code and compare the response body with what the page rendered.
test('waits for a network response and checks it', async ({ page }) => {
await page.goto('./dynamic-data-loading-demo/');
await page.waitForLoadState('networkidle');
const responsePromise = page.waitForResponse(r =>
r.url().includes('randomuser.me') && r.request().method() === 'GET');
await page.locator('#save').click();
const response = await responsePromise;
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.results).toHaveLength(1);
// The name the API returned is the name the page shows.
await expect(page.locator('#loading')).toContainText(body.results[0].name.first);
});The order is the same as in the new-tab example: create the promise, act, then await. Call waitForResponse after the click and a fast response can arrive before the test starts listening, which leaves it waiting for a response that already came.
19. Blocking Images
Aborting image requests cuts network traffic on pages where images are not what the test checks. Match on the resource type, because a file-extension pattern misses every format you did not list.
test('blocks image requests', async ({ page }) => {
let blocked = 0;
await page.route('**/*', route => {
if (route.request().resourceType() === 'image') {
blocked++;
return route.abort();
}
return route.continue();
});
await page.goto('./');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Selenium Playground');
expect(blocked).toBeGreaterThan(0);
});On the playground index this blocked 11 requests in our run. Nine of them were SVG files, which a pattern for png, jpg, webp and gif would have let through.
Measure before you add this to a suite. Across three paired loads of this page, every load finished in about 0.3 seconds or less, and in one pair the blocked load was the slower one. The page requests only 11 images up front, and the Playwright docs note that enabling routing disables the HTTP cache.
20. Uploading a File
setInputFiles sets the file on the input directly, so no operating-system file dialog opens. Passing a buffer means the test needs no fixture file on disk.
test('uploads a file from memory', async ({ page }) => {
await page.goto('./upload-file-demo/');
await page.waitForLoadState('networkidle');
// A valid one-pixel PNG, built in memory.
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64');
await page.locator('input#file').setInputFiles({
name: 'pixel.png', mimeType: 'image/png', buffer: png,
});
await expect(page.locator('#error')).toHaveText('File Successfully Uploaded');
// The rejection path is worth a line too.
await page.locator('input#file').setInputFiles({
name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from('hello'),
});
await expect(page.locator('#error')).toContainText('File type should be');
});This page accepts pdf, png, jpeg and jpg only. Our first attempt sent a text file and got the rejection message, which is how the second half of the test came to exist. To upload a real file, pass its path as a string in place of the object.
21. Downloading a File
A download is an event on the page. Start waiting for it, trigger it, then save the file or read it from where Playwright stored it.
// Needs const fs = require('fs'); at the top of the file.
test('downloads a file and reads it', async ({ page }) => {
await page.goto('./generate-file-to-download-demo/');
await page.waitForLoadState('networkidle');
// pressSequentially, not fill: see the third failure below.
await page.locator('#textbox').pressSequentially('hello from Playwright');
await page.locator('#create').click();
const downloadPromise = page.waitForEvent('download');
await page.locator('#link-to-download').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.txt$/);
await download.saveAs('downloads/' + download.suggestedFilename());
expect(fs.readFileSync('downloads/' + download.suggestedFilename(), 'utf8'))
.toBe('hello from Playwright');
});Call saveAs for any file you want to keep. Playwright deletes the downloaded files that belong to a browser context when that context closes, and in the test runner that happens at the end of each test.
22. Visual Regression Check
toHaveScreenshot compares a screenshot with a saved baseline and fails when the two differ by more than the threshold you set. Pointing it at one element keeps unrelated parts of the page out of the comparison.
test('compares a component against a saved baseline', async ({ page }) => {
await page.goto('./table-pagination-demo/');
await page.waitForLoadState('networkidle');
await expect(page.locator('#table-id'))
.toHaveScreenshot('table.png', { maxDiffPixelRatio: 0.01 });
});The first run fails by design. With no baseline to compare against, Playwright wrote one to tests/examples.spec.js-snapshots/table-chromium-darwin.png and reported that a snapshot does not exist, writing actual. The second run passed against that file.
The file name carries the browser and the operating system. A baseline made on macOS has no counterpart on a Linux CI runner, so generate baselines on the platform that runs the suite, with npx playwright test --update-snapshots. Thresholds and masking are covered in Playwright visual regression testing.
23. Mobile Emulation and Geolocation
A device descriptor sets the viewport, user agent, touch support and pixel ratio in one line. Location and locale are separate context options.
// tests/mobile.spec.js
// test.use at the top of a file applies to every test in that file,
// so emulation gets a spec of its own.
const { test, expect, devices } = require('@playwright/test');
test.use({
...devices['iPhone 13'],
geolocation: { latitude: 25.2048, longitude: 55.2708 },
permissions: ['geolocation'],
locale: 'en-AE',
});
test('emulates a phone, a locale and a location', async ({ page }) => {
await page.goto('./simple-form-demo/');
expect(page.viewportSize()).toEqual({ width: 390, height: 664 });
const seen = await page.evaluate(() => new Promise(resolve =>
navigator.geolocation.getCurrentPosition(pos => resolve({
lat: pos.coords.latitude,
lang: navigator.language,
touch: navigator.maxTouchPoints > 0,
ua: navigator.userAgent,
}))));
expect(seen.lat).toBeCloseTo(25.2048, 3);
expect(seen.lang).toBe('en-AE');
expect(seen.touch).toBe(true);
expect(seen.ua).toContain('iPhone');
});The iPhone 13 descriptor defaults to WebKit, but the config in the setup section pins the project to Chromium, so this ran in Chromium with an iPhone user agent and a 390 by 664 viewport. That covers a responsive layout and logic that depends on location or locale. It says nothing about how Safari on a real iPhone renders the page, and no descriptor can.
24. Scraping a Table into JSON
evaluateAll runs one function in the browser over every element the locator matched and returns plain data to the test. For a table, that is one round trip in place of one per cell.
// Needs const fs = require('fs'); at the top of the file.
test('scrapes a table into JSON', async ({ page }) => {
await page.goto('./table-pagination-demo/');
const rows = await page.locator('#table-id tbody tr').evaluateAll(trs =>
trs.map(tr => {
const cells = tr.querySelectorAll('td');
return {
id: cells[0].textContent.trim(),
firstName: cells[1].textContent.trim(),
lastName: cells[2].textContent.trim(),
email: cells[3].textContent.trim(),
};
}));
expect(rows.length).toBeGreaterThan(0);
expect(rows[0].email).toContain('@');
fs.writeFileSync('rows.json', JSON.stringify(rows, null, 2));
});The scrape returned 40 rows although the page shows 5 at a time, because the pagination hides rows and leaves them in the DOM. For the current page only, wait for the load to settle and narrow the locator to tr:visible. All 40 rows were visible in our run until the pagination script had finished.
Scraping at volume brings its own problems, from pagination to rate limits, and Playwright for web scraping covers them.
The Same Examples in Python
The API is deliberately close across languages. Method names move from camelCase to snake_case, and the synchronous API drops the await, but locators, assertions, and the auto-waiting behaviour are identical. These five ran against the same live pages.
pip install playwright
playwright install chromiumfrom playwright.sync_api import sync_playwright, expect
BASE = "https://www.testmuai.com/selenium-playground"
with sync_playwright() as pw:
browser = pw.chromium.launch()
# 1. Fill a form and assert. Same two details as the JavaScript version:
# wait for hydration, and qualify the duplicated id by tag.
page = browser.new_page()
page.goto(f"{BASE}/simple-form-demo/")
page.wait_for_load_state("networkidle")
message = "Welcome to TestMu AI"
page.locator("input#user-message").fill(message)
page.locator("#showInput").click()
expect(page.locator("#message")).to_have_text(message)
page.close()
# 2. Dropdown. select_option, not selectOption.
page = browser.new_page()
page.goto(f"{BASE}/select-dropdown-demo/")
page.locator("#select-demo").select_option("Wednesday")
expect(page.locator("#select-demo")).to_have_value("Wednesday")
page.close()
# 3. Dynamic content. The assertion retries exactly as it does in JS.
page = browser.new_page()
page.goto(f"{BASE}/dynamic-data-loading-demo/")
page.wait_for_load_state("networkidle")
page.locator("#save").click()
expect(page.locator("#loading img")).to_be_visible(timeout=20000)
page.close()
# 4. Network mocking. route takes a lambda instead of an arrow function.
page = browser.new_page()
page.route("**/api/profile", lambda route: route.fulfill(
status=200, content_type="application/json",
body='{"name": "Mocked User"}'))
page.goto(f"{BASE}/simple-form-demo/")
res = page.evaluate("fetch('/api/profile').then(r => r.json())")
assert res["name"] == "Mocked User"
page.close()
# 5. API call with no browser page at all.
req = pw.request.new_context()
assert req.get(f"{BASE}/").status == 200
req.dispose()
browser.close()The dynamic-content example failed in Python until the hydration wait was added, as the form example did in JavaScript. The race is a property of the application under test, not of the language binding, so a fix you find in one language applies in the other.
Python also offers an async API for use inside an existing event loop. Prefer the synchronous API shown here unless you already have one, since it is shorter and the pytest plugin is built around it.
Three Failures Worth Knowing
Running these Playwright examples against a live site rather than a curated sandbox produced three kinds of failure on the first attempt. All three are common in real applications, so each is documented here with what the error said.
Failure 1: a duplicate id, reported as a missing element
The first form example was written with the obvious locator, page.locator on the bare id, and it failed. The page carries three elements with the id user-message: the input, plus two wrapper divs. Playwright's strict mode refuses to guess which one you meant.
The tempting fix is .first(), and it is wrong. It resolves the error without resolving the ambiguity, so it silently depends on the DOM order staying as it is. Qualifying the selector by tag, input#user-message, describes the element you want and matches exactly one. Selenium never surfaces this, because findElement returns the first match without comment.
Failure 2: hydration, reported as a wrong value
With the locator fixed, the test still failed intermittently, roughly one run in six, with a more confusing error:
Error: expect(locator).toHaveText(expected) failed
Locator: locator('#message')
Expected: "Welcome to TestMu AI"
Received: ""
Timeout: 5000ms
Call log:
- Expect "toHaveText" with timeout 5000ms
- waiting for locator('#message')
13 x locator resolved to the empty <p id="message"> element
- unexpected value ""The element was found every time, and it was empty. The page is React-rendered, and Playwright is fast enough to fill the input in the window between the markup appearing and the event handlers attaching. The value lands in the DOM, the component state stays empty, and the button reads the component state.
Four variants were tried on the live page. Waiting for the network to settle before typing passed 6 out of 6 runs, as did wrapping the action and the assertion in toPass. Clicking the field before filling it passed 5 out of 6. Typing character by character with pressSequentially failed outright, because it hits the same race with more keystrokes.
When an element is found but its value is wrong, suspect the application's readiness before your locator. A longer assertion timeout does not fix it. Wait on a condition that means the handlers exist.
The Playwright docs mark networkidle as discouraged and say to rely on web assertions to judge readiness. On this playground it is the only readiness signal on offer, and the examples depend on it: with every such wait removed, 10 of the 11 examples that use one failed at least once in four runs. On an application you control, expose a real signal, such as an attribute set after hydration, and wait for that. On a page that polls or streams, networkidle may never arrive, and the toPass version in example 9 is the safer choice.
Failure 3: a disabled button, reported as a timeout
The download example was first written with fill, the call the form example uses. The test timed out one line later, on the click:
Test timeout of 60000ms exceeded.
Error: locator.click: Test timeout of 60000ms exceeded.
Call log:
- waiting for locator('#create')
- locator resolved to <button id="create" type="button" disabled="disabled" class="btn btn-dark selenium_btn">Generate File</button>
- attempting click action
2 × waiting for element to be visible, enabled and stable
- element is not enabledThe button was found and it was disabled. fill focuses the field, sets the value and triggers an input event; it sends no key events. The Generate File button stayed disabled after it and enabled once the test typed the same text with pressSequentially, which sends a keydown, a keypress and a keyup for each character.
This is the reverse of failure 2, where typing key by key was the variant that failed outright. Neither call is safe on every page. When a control stays disabled after input, check which events it listens for before reaching for a longer timeout.
Captured Flows vs Hand-Written Specs
Every example on this page is written by hand, which is the right call when a check runs on every commit and you want to own the locators and the assertions. It is a poor trade in two other cases: a flow you need to verify once and will never run again, and a flow whose value is the proof it produces rather than the pass or fail it returns.
For those, capturing the session beats authoring a spec for it. Kane CLI records a session into Test.md, described as an agent-native test framework: any session becomes replayable markdown, with imports, variables, and replay. A captured flow that lands as readable markdown can be diffed in a pull request, edited by hand, and reviewed by someone who was not there, none of which is true of a binary recording or a screen capture.
A captured flow can also leave Kane CLI. Native Playwright export turns a recorded flow into a real Playwright script, so a session you captured to answer a question once can become a maintained spec of the kind written above without retyping it. The two approaches then work in sequence: capture to find out whether the flow is worth owning, then export and hand-edit once it is.
One detail connects directly to the auth-state recipe above. Custom profiles and existing authenticated sessions are supported for captured flows, which is the same problem storageState solves for a scripted suite: the expensive part of most end-to-end flows is getting signed in, and neither approach should be paying that cost on every run.
Where the hand-written version still wins is understanding. A spec you wrote makes the locator strategy explicit, which is what let the duplicate-id and hydration problems above be diagnosed rather than worked around. A captured flow that heals past a changed element without telling you is convenient right up to the run where the change was a real regression, so anything gating a merge is worth reading and owning line by line.
Note: Capture the flow once as replayable markdown, then export it as a native Playwright script when it turns out to be worth maintaining. Read the Kane CLI docs
Running These at Scale
The Playwright examples above all run on one local Chromium. A local WebKit build is not Safari on macOS, and neither one tells you what an older Chrome on Windows does with the same script.
The tests themselves do not change. TestMu AI Automation Cloud runs Playwright in JavaScript, TypeScript, Python, C#, and Java against cloud-hosted desktop browsers on Windows, macOS, and Linux, using real browser engines rather than headless-only environments, so rendering and browser-specific behaviour are accurate. What you swap is the connection and a capabilities block; the locators, the assertions, and the structure of every example above stay exactly as written. The Playwright testing documentation has the capability format.
Take the first of the Playwright examples, add the hydration wait and the tag-qualified locator to your own suite where they apply, and then point the config at more than one browser. The failures documented above are the kind that only appear on a browser or a machine you were not testing on.
Testing from the terminal? Kane CLI by TestMu AI lets SDETs and developers plan, write, run and debug tests from the command line.
Author
Parth Mistry is a Member of Technical Staff at TestMu AI, building SmartUI, the visual regression testing product. He developed and owns the SmartUI CLI, a modular TypeScript tool built on Playwright for multi-browser automation, and built the Storybook CLI for visual regression of UI components. He maintains cross-language SDKs in Python, Java, Ruby, C#, and Node.js, and engineered a Node-based visual rendering service on Kafka, Redis, MySQL, and S3. His migration of that service to an event-driven, KEDA-autoscaled architecture improved execution speed by 60% and cut annual infrastructure cost by $9,600. He also built the end-to-end SmartUI integration with KaneAI. Parth is a Google Summer of Code 2024 contributor and an alumnus of IIT Jodhpur.
Reviewer
Devansh Bhardwaj is a Community Evangelist at TestMu AI with 4+ years of experience in the tech industry. He has authored 30+ technical blogs on web development and automation testing and holds certifications in Automation Testing, KaneAI, Selenium, Appium, Playwright, and Cypress. Devansh has contributed to end-to-end testing of a major banking application, spanning UI, API, mobile, visual, and cross-browser testing, demonstrating hands-on expertise across modern testing workflows.
On This Page
- What Is a Playwright Script?
- Setup Used by Every Example
- 1. Fill a Form and Assert
- 2. Dropdowns and Multi-Select
- 3. Checkboxes and Radios
- 4. Waiting for Dynamic Data
- 5. Working Inside an iframe
- 6. Searching a Table
- 7. Mocking a Network Call
- 8. API Calls Without a Browser
- 9. Retrying a Flaky Block
- 10. Data-Driven Tests
- 11. Reusing Auth State
- 12. Screenshots
- 13. Dialogs and Alerts
- 14. Keyboard Input
- 15. Following a Link and Checking the URL
- 16. Asserting on a List
- 17. Handling a New Tab
- 18. Waiting for a Network Response
- 19. Blocking Images
- 20. Uploading a File
- 21. Downloading a File
- 22. Visual Regression Check
- 23. Mobile Emulation and Geolocation
- 24. Scraping a Table into JSON
- The Same Examples in Python
- Three Failures Worth Knowing
- Captured Flows vs Hand-Written
- Running These at Scale
Playwright Examples FAQs
Did you find this page helpful?
More Related Learning Hubs
TestMu AI forEnterprise
Get access to solutions built on Enterprise
grade security, privacy, & compliance
- Advanced access controls
- Advanced data retention rules
- Advanced Local Testing
- Premium Support options
- Early access to beta features
- Private Slack Channel
- Unlimited Manual Accessibility DevTools Tests




