


Web App Testing with Playwright
Tests a local web app in a real browser with Playwright: user flows, console errors, and screenshots as evidence.
What it does
Code that compiles and a screen that works are different claims. A button can be wired up correctly and still do nothing useful, and a page can look completely normal while throwing errors that break the next step in the journey.
With this skill active, the assistant opens the app in a real browser and walks the flow like a person would: type in the fields, click the button, wait for the page to answer. It watches the browser console and the network the entire time, and it takes screenshots at the moments its checks depend on. Nothing is assumed from reading the source.
What comes back is a plain verdict for each flow, the first step that failed if one did, any errors the browser reported, the screenshots, and steps you can repeat by hand. A flow that finishes with errors in the console is reported as a failure with the reason written out, rather than quietly passed.
When to reach for it
Use it when you need proof that a screen works: after a UI change, before a release, when reproducing a bug someone reported, or when you want before-and-after screenshots of a flow.
Skip it for logic with no user interface, where a normal unit test is faster and clearer. It also needs a running copy of the app on your own machine or a staging site; it is not for testing production, and no flow should ever send real payments or real email.
What's inside
webapp-testing-playwright/
├── SKILL.md
├── references/
│ └── examples.md
└── scripts/
└── with_server.py
---
name: webapp-testing-playwright
description: Drives a local web app in a real browser with Playwright to prove a flow actually works - clicking through the real steps, capturing console and network errors, and saving screenshots as evidence. Use to verify a UI change end to end, reproduce a UI bug, or check whether a page is throwing at runtime.
license: Apache-2.0
metadata:
source: anthropics/skills
---
# Web App Testing with Playwright
You verify user interface behavior by driving a real browser, not by reading the code and reasoning about what should happen. A flow is working when a browser completed it and the console stayed quiet.
Playwright is a browser automation library that controls Chromium, Firefox and WebKit from a script.
**Language.** The examples here use the Python sync API (`playwright.sync_api`), which is what the bundled helper script expects. The JavaScript API has the same methods in camelCase:
| Python | JavaScript |
| --- | --- |
| `page.get_by_role("button", name="Sign in")` | `page.getByRole('button', { name: 'Sign in' })` |
| `page.wait_for_load_state("networkidle")` | `page.waitForLoadState('networkidle')` |
| `page.on("console", handler)` | `page.on('console', handler)` |
| `expect(locator).to_be_visible()` | `await expect(locator).toBeVisible()` |
Pick whichever matches the project. Never introduce a second language into a repository just to run a test.
## Workflow
1. **Get the flow in user steps.** "Open /signup, fill email and password, submit, expect the dashboard." If the request is vaguer than that, ask what counts as success before writing a line.
2. **Get the app running and reachable.** Confirm the URL responds before automating. A run against a dead server wastes the whole cycle.
3. **Reconnoiter.** On any page you have not automated before, load it, wait, screenshot, and list the interactive elements. Discover selectors from the rendered page rather than guessing from source.
4. **Write the script one step at a time.** Each user action followed immediately by the assertion that proves it worked.
5. **Capture console messages and failed requests** for the whole run, from before the first navigation.
6. **Screenshot the states your assertions depend on**, plus the failure state if there is one.
7. **Run it, headless.** Fix the script if it is the script that is broken; report the app if the app is broken.
8. **Report a verdict with evidence.** Pass or fail per flow, the failing step, console and network output, screenshot paths.
```text
Browser test checklist
[ ] Flow written down as user steps with a success condition
[ ] App reachable at a known URL before the run
[ ] Selectors discovered from the rendered page, by role/label/text
[ ] Every action followed by an assertion on the result
[ ] Console and network listeners attached before the first navigation
[ ] Screenshots taken at the asserted states
[ ] Verdict reported with the first failing step named
```
## Choosing the approach
```text
Is the page static HTML with no client-side rendering?
yes -> read the file, take selectors from the markup, drive it over a file:// URL
no -> is the dev server already running?
no -> start it with scripts/with_server.py
yes -> reconnoiter first, then act
```
## Starting the app
`scripts/with_server.py` starts one or more servers, waits until each accepts connections, runs your automation, then shuts everything down even if the automation fails.
Run `python scripts/with_server.py --help` before using it, and treat it as a black box. Read its source only if you need to change how it behaves.
```bash
python scripts/with_server.py --server "npm run dev" --port 5173 -- python flow_login.py
python scripts/with_server.py \
--server "python -m app.server" --port 8000 \
--server "npm run dev" --port 5173 \
-- python flow_checkout.py
```
Your automation script then assumes the server is up and contains only browser logic. If a server never starts, the helper exits with code 3 and prints the path to that server's log; read the log before blaming the test.
## Reconnaissance before action
On an unfamiliar page, look before you click:
```python
page.goto("http://localhost:5173/signup")
page.wait_for_load_state("networkidle")
page.screenshot(path="artifacts/signup-initial.png", full_page=True)
for button in page.get_by_role("button").all():
print("button:", button.inner_text())
for field in page.locator("input, textarea, select").all():
print("field:", field.get_attribute("name"), field.get_attribute("type"))
```
`networkidle` is the right blunt instrument for reconnaissance. In the real flow, wait for the specific thing you care about instead: an element, a response, a URL change.
## Selectors
Prefer handles that describe what a user sees, in this order:
1. `get_by_role("button", name="Add to cart")` - role and accessible name
2. `get_by_label("Email address")` - form fields by their label
3. `get_by_text("Order confirmed")` - visible copy
4. `get_by_test_id("cart-badge")` - an explicit test hook, if the project uses them
5. CSS or XPath - last resort, and only when nothing above works
Role and label selectors survive refactors and quietly check accessibility at the same time: if `get_by_label` cannot find a field, that field has no label and a screen reader user cannot use it either. A selector like `div.sc-bdVaJa > span:nth-child(3)` is broken the moment someone reorders the markup.
## Console and network capture
Attach the listeners before the first `goto`, or the messages from initial load are lost.
```python
console_errors, failed_requests = [], []
page.on("console", lambda msg:
console_errors.append(f"[{msg.type}] {msg.text}") if msg.type in ("error", "warning") else None)
page.on("pageerror", lambda err: console_errors.append(f"[pageerror] {err}"))
page.on("requestfailed", lambda req:
failed_requests.append(f"{req.method} {req.url} - {req.failure}"))
page.on("response", lambda res:
failed_requests.append(f"{res.status} {res.url}") if res.status >= 400 else None)
```
A run with a red console is a fail unless you can name why that specific message is harmless. Write the reason in the report; do not just drop it.
## What counts as a pass
- The asserted element or text is present and visible, not merely in the DOM.
- No console errors and no `pageerror` events, or a written justification for each one.
- No 4xx or 5xx responses on the flow's own requests.
- The screenshot shows the state the assertion claims.
"The script ran without raising" is not a pass. A page that navigates and then renders an error screen will run without raising.
## Report format
```text
Flow: add to cart from a product page
Verdict: FAIL
Steps: 1 navigate /p/123 ok
2 click "Add to cart" ok
3 cart badge reads "1" ok
4 console clean FAIL
Console: [error] TypeError: cart.items is undefined (cart.js:44)
Network: all 200
Screenshots: artifacts/cart-after-click.png
Repro: load /p/123, click "Add to cart", watch the console
```
## Example
**Request.** "Check that login works: /login, email and password, submit, expect the dashboard greeting."
**Script** (`flow_login.py`, run through the server helper):
```python
from playwright.sync_api import sync_playwright, expect
console_errors = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.on("console", lambda m: console_errors.append(f"[{m.type}] {m.text}")
if m.type == "error" else None)
page.on("pageerror", lambda e: console_errors.append(f"[pageerror] {e}"))
try:
page.goto("http://localhost:5173/login")
page.get_by_label("Email address").fill("test.user@example.com")
page.get_by_label("Password").fill("correct-horse-battery")
page.screenshot(path="artifacts/login-filled.png")
with page.expect_response("**/api/session") as response_info:
page.get_by_role("button", name="Sign in").click()
assert response_info.value.status == 200, f"session returned {response_info.value.status}"
expect(page.get_by_role("heading", name="Welcome back, Test")).to_be_visible(timeout=5000)
page.screenshot(path="artifacts/login-dashboard.png", full_page=True)
print("PASS" if not console_errors else "FAIL (console)")
finally:
for line in console_errors:
print("console:", line)
browser.close()
```
**Run:**
```bash
python scripts/with_server.py --server "npm run dev" --port 5173 -- python flow_login.py
```
**Report:**
```text
Flow: login with valid credentials
Verdict: PASS
Steps: 1 navigate /login ok
2 fill email and password ok
3 submit -> POST /api/session 200
4 dashboard heading visible ok
5 console clean ok
Screenshots: artifacts/login-filled.png, artifacts/login-dashboard.png
```
The submit step waits on the actual session response rather than on a timer, so a slow machine changes the duration of the test and not its result.
## Common mistakes
- Asserting the page loaded instead of asserting the action worked.
- `wait_for_timeout` sprinkled around to paper over races. It is flaky by construction: too short on a loaded machine, wasted time on a fast one.
- Selecting by a generated class name or `nth-child`, which the next refactor renames.
- Ignoring the console, so a green run hides an exception every real user will hit.
- Screenshotting after the page has moved on, which produces evidence of the wrong moment.
- One flow depending on state another flow left behind. Each flow seeds what it needs and cleans up after itself.
- Reading a helper script's source to learn what it does when `--help` would have answered it.
## Edge cases
- **Authentication in the way.** Log in once, save storage state with `context.storage_state(path=...)`, and reuse it. Do not automate the login form at the start of every unrelated flow.
- **The element genuinely is not there.** Before reporting a bug, screenshot and dump `page.content()`. A missing element and a failed render look identical from a selector error.
- **Third-party widgets and iframes.** Use `frame_locator` for the frame. Console noise from a third-party script is still reported, but it can be justified in the report rather than counted against the flow.
- **Anything destructive.** Never run these tests against production, and never let a flow send real payments, real email, or real webhooks. Local or staging with test data only.
- **Flake you cannot explain.** Rerun ten times and record the rate before calling it flaky. Then find the race; a retry that hides it is a decision to ship the race.
- **No dev server, only a built bundle.** Serve the build directory over a static server and point the helper at it. Testing a `file://` bundle that expects a base path will mislead you.
- **The script is what is broken.** If the script fails on step one against a page that clearly works in a browser, fix the script. Report app bugs only when the browser reproduces them by hand.
## Files
- `scripts/with_server.py` - run this, do not read it. Starts servers, waits for their ports, runs your automation, and shuts everything down afterward. Start with `python scripts/with_server.py --help`.
- `references/examples.md` - three complete runs to read before writing your own: a failing add-to-cart flow with a console error, reconnaissance on an unfamiliar page, and a static HTML page driven over a `file://` URL.
## Attribution
Adapted from the `webapp-testing` skill in [anthropics/skills](https://github.com/anthropics/skills/tree/main/skills/webapp-testing), Apache-2.0. The decision tree, the reconnaissance-then-action pattern and the server helper come from there; the selector guidance, capture harness and report format are ours.
Install it
Unzip the download into .claude/skills/. The folder is already named right.
Unzip into ~/.claude/skills/ to use it everywhere.
Upload the zip under Settings, then Capabilities, then Skills (paid plans).
How to call it
Just describe the task. The description line inside the skill does the matching. Or call it directly:
/webapp-testing-playwright
test this flow in a real browserdoes the login actually work end to endcheck whether this page is throwing console errorsreproduce this UI bug with Playwrighttake before and after screenshots of the signup flow
Works beyond Claude
Skills use the open Agent Skills format. The exact same folder works in OpenAI Codex, Gemini CLI, Cursor, GitHub Copilot and other tools that read the format.
Using a plain chat app? Paste SKILL.md into ChatGPT or Gemini as instructions. You lose auto-triggering, but the method still works.
agentskills.ioPattern credit: anthropics/skills — webapp-testing