Playwright Testing: A Practical Guide
Build reliable Playwright tests with user-focused locators, isolated scenarios, resilient assertions, CI configuration, and practical debugging steps.
Reliable Playwright tests exercise a user-visible outcome, use locators that describe the interface, and run independently of other tests. Let Playwright’s actionability checks and retrying assertions handle normal page timing; use traces and reports to investigate failures. This guide builds a small test suite and shows how to run and debug it in CI.
1. Install Playwright Test
Playwright Test is Playwright’s test runner. It includes browser automation, assertions, parallel execution, and tracing. Use the documentation for your installed version when adapting setup or configuration.
npm init playwright@latest
The setup prompts let you choose JavaScript or TypeScript, where to put tests, whether to add a CI workflow, and whether to install browsers. If you already have a Node.js project, you can add the runner and browser binaries directly:
npm install --save-dev @playwright/test
npx playwright install
Check the official Playwright overview for current setup details. Playwright lists Chromium, Firefox, and WebKit as supported browser engines.
2. Write a test around a user outcome
Choose one meaningful journey, such as submitting a form and seeing confirmation. Assert what a user can perceive. Avoid asserting internal function names, implementation data structures, or CSS classes that may change without changing user behavior. The Playwright documentation team’s best practices recommend testing the application as users experience it.
For a sample app with a form labeled “Email” and a button named “Subscribe,” a test might look like this:
import { test, expect } from '@playwright/test';
test('shows confirmation after subscribing', async ({ page }) => {
await page.goto('http://127.0.0.1:3000');
await page.getByLabel('Email').fill('reader@example.com');
await page.getByRole('button', { name: 'Subscribe' }).click();
await expect(page.getByRole('status')).toHaveText('You are subscribed');
});
This assumes the application exposes a labeled email input and a status element containing the stated message after a successful submission. Change the URL and expected text to match your app. If your success message is not exposed as a status role, use the role or test ID that reflects the actual contract.
Run it with:
npx playwright test
3. Choose stable, user-facing locators
Locators are how a test finds controls and content. Prefer accessible roles and names because they describe how a user or assistive technology identifies an element. A deliberate test ID is also reasonable when your team treats it as a stable test contract.
| Locator approach | Example | Use it when |
|---|---|---|
| Role and accessible name | page.getByRole('button', { name: 'Save' }) |
The interaction has a meaningful accessible role and name. |
| Label | page.getByLabel('Email') |
Finding a form control by its label matches the user interaction. |
| Placeholder | page.getByPlaceholder('Search') |
The placeholder is the clearest available description. |
| Text | page.getByText('Order complete') |
Visible text is the intended thing to verify or interact with. |
| Test ID | page.getByTestId('checkout-submit') |
The team deliberately maintains this identifier as a testing contract. |
When a page has repeated controls, narrow the locator by its containing region or filter it by visible content instead of relying on a long CSS or XPath path. For example:
const row = page.getByRole('row').filter({ hasText: 'INV-2048' });
await row.getByRole('button', { name: 'Open' }).click();
See the official locator guide for locator methods and their current behavior. If a locator matches more than one element, make the intended target specific rather than selecting the first match without checking the page structure.
4. Keep each test independent
A test should set up the state it needs and should not depend on another test having run first. Playwright gives each test a fresh environment, including when tests share a browser process. Independence makes failures easier to reproduce and prevents order-dependent results.
- Navigate to the needed page in the test or a fixture.
- Create or reset the test data each test needs.
- Do not rely on cookies, local storage, or a prior test’s browser state unless the scenario specifically tests persisted state.
- When tests share a remote account or mutable record, isolate or uniquely identify that data so parallel workers do not race.
Playwright’s writing tests guide explains the test environment and test structure.
5. Let actions and assertions wait
Playwright checks actionability before performing actions, and its asynchronous web-first assertions retry until the condition succeeds or times out. This is designed to handle ordinary rendering and interaction delays; it does not prevent every application or network failure.
Prefer an assertion that waits for the expected state:
await expect(page.getByRole('status')).toBeVisible();
await expect(page.getByRole('status')).toHaveText('You are subscribed');
A one-time read followed by a synchronous comparison can race the page:
// Avoid checking a transient state only once.
const visible = await page.getByRole('status').isVisible();
expect(visible).toBe(true);
Do not add arbitrary fixed sleeps as the first response to a timing failure. Identify the condition the test needs and wait for that condition with a locator or assertion. If a delay is itself the behavior under test, express the expected behavior with an appropriate bounded wait and assertion.
6. Configure browsers and test commands
Run the engines relevant to the browsers your application supports. The official overview lists Chromium, Firefox, and WebKit. The configuration below runs the same suite against all three projects and collects a trace on the first retry:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? 'html' : 'list',
use: {
baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
webServer: {
command: 'npm run start -- --host 127.0.0.1',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
},
});
Change the server command and URL to match your application. If your app is started outside Playwright, remove webServer and ensure the app is running before tests. If not all browser engines are in scope, include only the projects that match your supported audience and risk.
Useful commands:
npx playwright test
npx playwright test tests/subscribe.spec.ts
npx playwright test --project=chromium
npx playwright test --headed
npx playwright test --debug
Test frequently in CI, such as on commits and pull requests. When runtime grows, consider sharding. Playwright’s best practices page recommends Linux for CI as a cost consideration; check that choice against your own browser, system dependency, and environment requirements.
7. Debug a failure with reports and traces
Start with the failing assertion and error output, then inspect the HTML report. Playwright’s Trace Viewer can show a timeline, DOM snapshots, and network requests around the failure. The official best-practices guide recommends collecting traces on the first retry in CI; tracing every test can add performance overhead.
npx playwright show-report
To open a trace file produced by the run:
npx playwright show-trace path/to/trace.zip
Use the timeline to see which action preceded the failure, inspect the DOM snapshot to confirm the target and visible state, and check network activity when a page or API response is missing. A trace provides diagnostic evidence, but it may not explain every failure by itself.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| “Executable doesn’t exist” or browser launch fails | The browser binaries for the installed Playwright package are absent. | Run npx playwright install. In CI, install the browsers required by your projects and provide required system dependencies as appropriate for the runner. |
| Navigation times out or connection is refused | The app server is not running, the configured URL or port is wrong, or startup takes longer than expected. | Check the app locally, align baseURL and webServer.url, and make the server command wait until it is ready. |
| Strict mode reports multiple matching elements | The locator describes several controls on the page. | Scope it to a region, filter by identifying content, or choose a more specific role/name or test ID. |
| Element is not actionable | The target may be hidden, covered, disabled, or still transitioning. | Inspect the page and trace. Wait for the meaningful visible/enabled state, remove an unintended overlay, or correct the locator. |
| Assertion times out | The expected user-visible state never appeared, the app returned an error, or the locator/text is inaccurate. | Inspect the report and trace, verify the app response and expected UI, and assert the actual accessible state. Avoid immediately raising all timeouts. |
| Test passes alone but fails in the suite | Tests may share data or external state, or parallel tests may collide. | Make setup independent, use isolated records, and investigate order and concurrency with repeat runs and traces. |
| CI fails while local run passes | Environment, browser installation, server readiness, or external dependency differs. | Compare browser versions and environment, ensure dependencies are installed, inspect trace network events, and stabilize or isolate external services. |
| Trace or report is missing | Configuration may only collect traces on retry, or the run may not have emitted the expected report. | Confirm reporter and trace settings, rerun the failing case, and retain the output artifacts from CI. |
9. Reliability, performance, and cost
Reliability comes mainly from clear user-visible assertions, independent setup, appropriate locators, and useful diagnostics. Retries can expose intermittent failures, but a test that passes only after retry still deserves investigation; retries do not correct a race or a shared-state defect.
For performance, run only the browser projects needed for your coverage, keep setup focused, and consider sharding a large suite. Parallelism can reduce wall-clock time while increasing resource use and exposing collisions in shared test data. Trace collection also has overhead, so the first-retry strategy is a practical diagnostic setting described by Playwright’s best practices.
CI cost depends on your runner, browser matrix, parallelism, and artifact retention. Playwright’s documentation suggests Linux as a CI cost consideration, but it does not provide a universal savings figure. Measure the workload in your own environment rather than assuming a fixed speed or cost advantage.
Or skip the browser setup
If the task is to capture a page image rather than exercise an interactive workflow, ScreenshotNeo is a website screenshot API and MCP server for developers. One GET request returns a PNG, JPEG, WebP, or PDF. Read the ScreenshotNeo API documentation for options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
- Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
- Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Response headers say the page verdict and billing status.
- An MCP server provides
take_screenshot,get_page_info, andcapture_pdftools for Claude, Cursor, and other MCP clients. - The free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 screenshots; every feature is on every plan.
Sign up for ScreenshotNeo free: 1,000 screenshots a month, no card required.
FAQ
Does a screenshot test replace an end-to-end test?
No. A screenshot checks visual output, while an interaction test can verify actions and resulting behavior. Choose the check that answers the question you need to protect.
Should every test run in all three browser engines?
Only if that coverage matches your supported browsers and risk. Playwright supports Chromium, Firefox, and WebKit; the project matrix is a team decision.
Are retries a fix for flaky tests?
No. Retries can help capture diagnostics or reveal intermittent failures, but investigate the state, timing, or environment that caused the initial failure.


