World’s largest virtual agentic engineering & quality conference
Fourteen Playwright script examples in JavaScript and Python, each executed against a live page: forms, dropdowns, iframes, tables, mocking, and auth reuse.

Swastika Yadav
Author

Devansh Bhardwaj
Reviewer
Last Updated on: August 6, 2026
On This Page
Most Playwright examples online cannot actually be run, because the application they target does not exist. Every script below points at a public page you can open right now.
These are fourteen Playwright script examples you can run rather than read, all of them in JavaScript and five repeated in Python. Every one was executed against those live pages while writing this article, which is worth stating because three of them failed on the first attempt, and both failures are documented at the end rather than edited out.
Overview
These fourteen Playwright examples cover form input, dropdowns, iframes, table search, network mocking, API calls, retries, auth reuse, screenshots, dialogs, 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?
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 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' } }],
};Run any single example with the test title:
npx playwright test -g "fills a form" --workers=2The starting example, with two details that are not decoration. 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 exactly what happened the first time it ran. The full diagnosis is in the failures section below.
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.
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.
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/');
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 a deliberate choice, not a safety blanket. 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 exactly 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
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.
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.locator('input[type="search"]').first().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.
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.
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 }) => {
// 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 actually broke.
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.
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.
Signing in through the interface once per test is the single largest avoidable cost in most suites. Save the browser state once, then start every later test already authenticated.
const fs = require('fs');
test('save and reuse auth state', async ({ browser }) => {
// 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: 'state.json' });
await ctx.close();
// Every later context starts already signed in. No login steps, no waiting.
const ctx2 = await browser.newContext({ storageState: 'state.json' });
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();
fs.unlinkSync('state.json');
});In a real suite the save step belongs in a global setup file so it runs once per suite rather than once per 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.
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.
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
});
await page.getByRole('button', { name: 'Click Me' }).first().click();
await page.waitForTimeout(800);
expect(seen.type).toBe('alert');
});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.
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.
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()One result from running these is worth stating, because it decides how much of this page transfers. The dynamic-content example failed in Python until the hydration wait was added, exactly 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.
Running these Playwright examples against a live site rather than a curated sandbox produced two failures on the first attempt. Both are common in real applications and neither appears in most Playwright example collections, so both are documented here with what the error actually 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 actually want and matches exactly one. Selenium never surfaces this, because findElement returns the first match without comment, which is a difference worth understanding rather than a Playwright inconvenience.
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. It was simply 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.
The general lesson transfers past this page. When an element is found but its value is wrong, suspect the application's readiness rather than your locator, and reach for a wait on a condition that means the handlers exist rather than for a longer assertion timeout.
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. The format matters more than it sounds. 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.
The capture is also not a one-way door. Native Playwright export means a recorded flow comes back out as 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. That is the practical reason to treat the two approaches as a sequence rather than a choice: capture to find out whether the flow is worth owning, 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 quietly heals past a changed element 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
The Playwright examples above all run on one local Chromium, which is the right place to start and the wrong place to stop. 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 two failures documented above are the kind that only appear on a browser or a machine you were not testing on, which is the entire argument for not staying local.
Author
Swastika Yadav is a community evangelist and Developer Advocate with 4+ years of experience in software testing, full-stack development, and developer tooling. She has worked with Phyllo, Turso, and UnitedHealth Group, contributing to test-driven applications, QA practices, and developer experience strategies. Swastika holds a B.Tech in Computer Science and is recognized as a contributor to the global QA and developer community. She engages with 8,500+ LinkedIn followers and a 60K+ Twitter audience of testers, developers, and tech leaders.
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.
Did you find this page helpful?
More Related Blogs
TestMu AI forEnterprise
Get access to solutions built on Enterprise
grade security, privacy, & compliance