Most testing tools were built for how browsers worked a decade ago. The result is tests pass locally and fail in CI, parallel runs step on each other, or dynamic content breaks selectors that worked fine yesterday.
Playwright was designed differently. The browser tells Playwright when it’s ready instead of being asked repeatedly. The same 500-test suite that runs for 2 hours with 40 to 50 flaky failures in older setups (like Selenium) finishes in 25 minutes with fewer than 10 in Playwright.
In this article, I will cover everything you need to go from zero to a working, maintainable Playwright test suite.
Why Use Playwright for End-to-End Testing?
Playwright fixes the problems that make end-to-end tests flaky and hard to scale.
- Auto-waiting: Waits for elements to be visible and clickable automatically. No sleep() calls or fragile timing hacks.
- Cross-browser support: Run the same tests on Chromium, Firefox, and WebKit with one API.
- Test isolation: Browser contexts keep sessions, cookies, and storage separated for reliable parallel execution.
- Network interception: Mock APIs, simulate failures, and test edge cases without external dependencies.
- Built-in tooling: Includes code generation, debugging tools, and trace recording out of the box.
Understanding Playwright Automation Architecture
Playwright’s execution model has three layers: browser, context, and page. Every test you write operates within this hierarchy, and how you use it directly affects test isolation, parallelism, and performance.
Browser, Context, and Page: The Three-Layer Model
- Browser is the top-level instance. When Playwright launches Chromium, Firefox, or WebKit, that’s the browser object. It’s the actual browser process running on your machine or CI environment.
- Context sits inside the browser. Think of it as an incognito window with its own cookies, localStorage, session data, and permissions. Contexts are isolated from each other even when they share the same browser process. This is how Playwright runs tests in parallel without state leaking between them.
- Page is what you interact with in your tests. Every tab, every URL, every element interaction happens at the page level. A single context can have multiple pages, which is how Playwright handles multi-tab flows.
Most tests create one context per test and one page per context. Playwright’s test runner handles this automatically via the built-in page fixture.
How Test Execution Works Under the Hood
That three-layer model isn’t just an organizational concept. It maps directly to what happens at runtime.
- The test runner spins up a browser instance
- A fresh browser context is created for the test
- A new page is opened inside that context
- Your test interacts with the page through Playwright’s API
- Playwright communicates with the browser over the Chrome DevTools Protocol for Chromium, the WebDriver BiDi protocol for Firefox, and a custom internal protocol for WebKit.
- After the test completes, the context is destroyed and state is wiped
The CDP layer is what gives Playwright low-level access to the browser: network events, DOM state, JavaScript execution context, console output. This is why features like network interception and trace recording work without external plugins.
Key Components of the Playwright Framework
With the execution model in place, here’s what you’re actually working with when you write Playwright tests:
- Test Runner (@playwright/test): Handles test discovery, parallel execution, fixtures, and reporting. You don’t need Jest or Mocha alongside it.
- Fixtures: They are the dependency injection system that provides page, browser, and context to every test automatically. You can extend them to add custom setup logic, authenticated sessions, or shared utilities without repeating code across tests. Fixtures also handle teardown automatically after each test completes.
- Locators: The primary API for targeting elements. Locators do not query the DOM at the point of definition. The query runs only when an action is triggered, at which point Playwright retries automatically until the element is actionable or the timeout is reached.
- Assertions (expect): Async-aware assertions that retry until the condition is met or the timeout is reached. expect(page).toHaveTitle(‘Dashboard’) keeps checking until the title matches, not just on the first evaluation.
- Trace Viewer: Records a full timeline of every test run: DOM snapshots at each step, network requests, console logs, and errors. When a test fails in CI, the trace tells you exactly what happened without needing to reproduce it locally.
How to Install Playwright and Set Up a Project
Setting up Playwright is quick, but getting the environment right upfront avoids unnecessary failures during installation and test execution. Before running the install command, ensure the following prerequisites are in place.
Prerequisites
Playwright runs on Node.js and depends on a standard JavaScript tooling setup. This means the local environment should include:
- Node.js 18 or higher: Required to run Playwright and manage dependencies. Use node -v to verify the installed version.
- Package manager (npm, yarn, or pnpm): Used to install Playwright and manage project dependencies.
- Code editor (VS Code recommended): While optional, VS Code provides better debugging, test exploration, and integrates well with Playwright through its official extension.
Install Playwright
Once the prerequisites are in place, Playwright can be installed using a single command. The recommended approach is to use the official initializer, which sets up everything required for a working test environment.
Run the following command in your project directory:
npm init playwright@latest
Output:
If using other package managers:
yarn create playwright
Output:
pnpm create playwright
Output:
This command does more than just install Playwright. It bootstraps a complete testing setup so there is no need to configure everything manually.
During installation, a few prompts appear to customize the setup:
- TypeScript or JavaScript: Choose based on project preference. TypeScript is commonly used for better type safety and maintainability.
- Test directory location: Defines where test files will be stored.
- Add GitHub Actions workflow: Useful if CI integration is required from the start.
- Install Playwright browsers: Installs Chromium, Firefox, and WebKit for cross-browser testing.
These choices shape the initial structure, but everything can be modified later.
Verify the Installation
Before writing new tests, it is important to confirm that Playwright is installed correctly and the setup works as expected.
Run the default test suite:
npx playwright test
This executes the sample tests across installed browsers and generates a report. If everything is configured correctly, the tests should pass without errors.
To view the HTML report:
npx playwright show-report
This opens a detailed test report in the browser, showing execution results, logs, and traces.
Playwright Project Structure Explained (Files, Folders, Config)
After installation, Playwright generates a structured project that is ready for test execution. This structure is not just for organization, it defines how tests are discovered, executed, and configured across browsers.
Understanding how each file fits into the workflow makes it easier to scale tests, debug failures, and customize execution.
A typical Playwright project includes the following core components:
- playwright.config.ts: This is the control center of the test setup. It defines which browsers to run, base URLs, timeouts, retries, parallel execution, and reporting options. For example, cross-browser testing is enabled here rather than inside individual tests.
- tests/ folder: This is where test files live. Playwright automatically scans this directory to discover and execute tests. Tests are usually grouped by feature or module so that execution remains organized as the suite grows.
- package.json: Manages project dependencies and scripts. It often includes shortcuts like test, test:headed, or test:debug to simplify execution without remembering full commands.
- node_modules/: Contains all installed dependencies, including Playwright and browser binaries. This folder is managed automatically and is not meant to be modified directly.
Beyond these core files, Playwright may also generate:
- example.spec.ts: A sample test file that demonstrates basic test structure, assertions, and browser interactions
- playwright-report/ (after execution): Stores HTML reports generated after running tests
- test-results/ (optional): Contains traces, screenshots, and videos for debugging failed tests
This structure is intentionally minimal so that teams can extend it based on their testing needs without dealing with unnecessary complexity upfront.
Understanding the Playwright Test Runner
The Playwright Test Runner handles everything from test discovery and parallel execution to fixtures, retries, and reporting. You do not need Jest, Mocha, or any external runner alongside it.
How Playwright Executes Tests in Parallel
By default, Playwright runs test files in parallel. Each file gets its own worker process, so two files never share state, browser instances, or context. Tests within the same file run sequentially unless you explicitly configure them otherwise.
This is controlled in playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
workers: 4, // runs up to 4 test files in parallel
});To also parallelize tests within a single file, add this at the top of the file:
import { test } from '@playwright/test';
test.describe.configure({ mode: 'parallel' });Use this carefully. Tests within the same file sharing any external state, like a database or a shared user account, will conflict when run in parallel.
Example:
import { test, expect } from '@playwright/test';
// Enable parallel execution inside this file
test.describe.configure({ mode: 'parallel' });
test('open Google', async ({ page }) => {
await page.goto('https://www.google.com/');
await expect(page).toHaveTitle(/Google/);
});
test('open GitHub', async ({ page }) => {
await page.goto('https://github.com/');
await expect(page).toHaveTitle(/GitHub/);
});
test('open Playwright', async ({ page }) => {
await page.goto('https://playwright.dev/');
await expect(page).toHaveTitle(/Playwright/);
});Output:
Tracer View – GITHUB
Google Tracer View
Plawright Tracer View
Workers and Isolation Explained
Each worker is an independent Node.js process. It has its own browser instance, its own memory, and no access to variables or state from other workers. When a worker finishes a test file, Playwright can reuse it for the next file in the queue.
This isolation is what makes Playwright parallel execution reliable. There is no shared global state to corrupt between tests. If a test crashes its worker, the other workers keep running unaffected.
You can cap the number of workers to match your machine or CI environment:
export default defineConfig({
workers: process.env.CI ? 2 : 4,
});CI runners typically have fewer CPU cores than local machines. Setting a lower worker count in CI prevents resource contention that causes flaky failures unrelated to your tests.
Retries and Test Reliability
Retries tell Playwright to re-run a failing test before marking it as failed. This is useful for catching genuinely flaky tests in CI without manually re-triggering the whole pipeline.
export default defineConfig({
retries: process.env.CI ? 2 : 0,
});This runs retries only in CI, which is the right default. Retries locally hide problems you should be fixing. In CI, they give you a safety net against infrastructure noise.
When a test fails on the first attempt and passes on retry, Playwright marks it as flaky in the report. This is useful signal: flaky means the test is not deterministically failing, which points to a timing issue or shared state problem worth investigating.
You can also set retries per test:
test('submits the form', { retries: 2 }, async ({ page }) => {
// test logic
});Hooks: beforeEach, afterEach, beforeAll, afterAll
Hooks run setup and teardown logic around your tests without repeating code inside every test block.
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('https://app.example.com');
});
test.afterEach(async ({ page }) => {
// runs after every test, useful for cleanup
});
test.beforeAll(async () => {
// runs once before all tests in this file
// does not receive page, use browser or request fixtures instead
});
test.afterAll(async () => {
// runs once after all tests in this file complete
});A few things worth knowing:
beforeEach and afterEach receive the same fixtures as the test itself, including page. beforeAll and afterAll do not receive page because they run outside the per-test context lifecycle. If you need browser access in beforeAll, use the browser fixture instead.
Hooks defined inside a test.describe block apply only to tests in that block. Hooks defined at the file level apply to all tests in the file.
Example:
import { test, expect } from '@playwright/test';
// Runs once before all tests
test.beforeAll(async () => {
console.log('Starting test suite');
});
// Runs before every test
test.beforeEach(async ({ page }) => {
// Open website before each test
await page.goto('https://example.com');
console.log('Opened website');
});
// Test 1
test('has title', async ({ page }) => {
await expect(page).toHaveTitle(/Example Domain/);
});
// Test 2
test('page contains heading', async ({ page }) => {
await expect(page.getByRole('heading')).toBeVisible();
});
// Runs after every test
test.afterEach(async () => {
console.log('Test completed');
});
// Runs once after all tests
test.afterAll(async () => {
console.log('Finished test suite');
});Tracer View Test 1:
Tracer View Test 2:
Tags, Groups, and Test Filtering
As a test suite grows, running all tests every time becomes impractical. Playwright lets you filter which tests run using tags and grep patterns.
Tag a test by adding the tag in the test title:
test('user can log in @smoke', async ({ page }) => {
// test logic
});Run only tagged tests from the command line:
npx playwright test --grep @smoke
Exclude tagged tests:
npx playwright test --grep-invert @smoke
Group related tests using test.describe:
test.describe('Login flow', () => {
test('valid credentials redirect to dashboard', async ({ page }) => {
// test logic
});
test('invalid credentials show error message', async ({ page }) => {
// test logic
});
});test.describe blocks can be nested, and hooks defined inside a describe block scope to that block only. This keeps setup logic close to the tests that need it instead of running globally across the entire file.
Understanding Fixtures in Playwright
Fixtures are Playwright’s dependency injection system. Every test receives page, browser, and context automatically because they are built-in fixtures. When a test function declares { page } in its arguments, the runner provisions that fixture, hands it to the test, and tears it down cleanly after the test completes.
Fixtures handle setup and teardown as a single unit, which means cleanup always runs even when a test fails. No finally blocks, no manual cleanup scattered across tests.
Built-in Fixtures
Playwright ships with five core fixtures available to every test:
test('example', async ({ page, browser, context, request, browserName }) => {
// page: a new browser tab for this test
// browser: the browser instance
// context: the browser context this page belongs to
// request: for making API requests without a browser
// browserName: 'chromium', 'firefox', or 'webkit'
});In most tests, page is all you need. The others become useful in specific situations: context when you need to open multiple tabs, request for API-only tests, and browserName when test behavior needs to differ per browser.
Creating Custom Fixtures
Custom fixtures extend the base fixtures to add your own setup logic. A common use case is a fixture that logs in before the test and provides an authenticated page.
import { test as base, expect } from '@playwright/test';
type MyFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<MyFixtures>({
authenticatedPage: async ({ page }, use) => {
// setup: runs before the test
await page.goto('https://app.example.com/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// hand the fixture to the test
await use(page);
// teardown: runs after the test completes
await page.goto('https://app.example.com/logout');
},
});Import this test instead of the default one in any test file that needs an authenticated page:
import { test } from './fixtures';
test('dashboard loads correctly', async ({ authenticatedPage }) => {
await expect(authenticatedPage.getByText('Welcome back')).toBeVisible();
});Sharing Setup Across Tests
Fixtures defined in a shared file can be imported across the entire test suite. This is how you avoid repeating login logic, database seeding, or API client setup across dozens of test files.
Create a central fixtures file:
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { DashboardPage } from './pages/DashboardPage';
export const test = base.extend({
dashboardPage: async ({ page }, use) => {
const dashboard = new DashboardPage(page);
await use(dashboard);
},
});
export { expect } from '@playwright/test';Then import from it instead of directly from @playwright/test:
import { test, expect } from '../fixtures';Fixture Scope and Lifecycle
By default, fixtures are scoped to the test. A fresh instance is created for each test and torn down after it. For expensive setup that only needs to happen once, you can scope a fixture to the worker:
export const test = base.extend({
sharedDatabase: [async ({}, use) => {
const db = await Database.connect();
await use(db);
await db.disconnect();
}, { scope: 'worker' }],
});Worker-scoped fixtures are created once per worker process and shared across all tests that run in that worker. Use this for setup that is slow to initialize and safe to share, like database connections or API clients. Avoid it for anything that carries state between tests, like a logged-in browser session.
Writing Your First Playwright Test (Step-by-Step With Examples)
Once Playwright is installed and the project is set up, the next step is to write and run a test. Playwright tests are built using a simple structure where a browser is launched, actions are performed on a page, and results are validated using assertions.
The following steps walk through a complete example.
Step 1: Create a Test File
Navigate to the tests/ folder and create a new file:
tests/example.spec.ts
Playwright automatically detects files with .spec.ts or .test.ts, so placing the file in this folder ensures it gets picked up during execution.
Step 2: Import Playwright Test Functions
Every test starts by importing the required functions:
import { test, expect } from '@playwright/test';- test: Used to define a test case
- expect: Used for assertions to validate outcomes
Step 3: Define a Basic Test
Create a simple test that opens a webpage:
test('open example homepage', async ({ page }) => {
await page.goto('https://example.com');
});Here’s what happens:
- test(‘name’, async () => {}): Defines a test block
- page: Represents a browser tab provided by Playwright
- page.goto(): Navigates to a URL
Step 4: Interact With Elements
Now extend the test to perform actions on the page. For example, clicking a link:
test('navigate to more information page', async ({ page }) => {
await page.goto('https://example.com');
await page.getByRole('link', { name: 'More information' }).click();
});Playwright uses modern locator strategies like getByRole, which rely on accessibility attributes. This makes tests more stable and closer to how users interact with the UI.
Step 5: Add Assertions
A test is only meaningful if it verifies something. Add an assertion to confirm the navigation worked:
test('verify navigation works', async ({ page }) => {
await page.goto('https://example.com');
await page.getByRole('link', { name: 'More information' }).click();
await expect(page).toHaveURL(/.*iana.org/);
});- expect(page).toHaveURL(): Checks if the current URL matches the expected value
- Assertions automatically wait for conditions, so no manual waits are required
Step 6: Run and Verify Your Test
Execute the test using:
npx playwright test
Playwright runs the test across configured browsers and shows results in the terminal.
To run a specific file:
npx playwright test tests/example.spec.ts
To see the browser actions visually:
npx playwright test --headed
This helps in debugging and understanding how the test interacts with the UI.
Playwright provides built-in debugging tools. Run:
npx playwright test --debug
This opens the Playwright Inspector, where each step can be executed and inspected in detail.
Here’s an Example:
import { test, expect } from '@playwright/test';
test('user can navigate to more information page', async ({ page }) => {
await page.goto('https://example.com');
await page.getByRole('link', { name: 'More information' }).click();
await expect(page).toHaveURL(/.*iana.org/);
});This test checks if a user can click a link and get redirected to the correct page.
- test(…): Defines the test case. Playwright runs this as an independent test.
- page: A browser tab provided automatically for the test.
- page.goto(…): Opens the website.
- getByRole(…).click(): Finds the “More information” link and clicks it.
- expect(…).toHaveURL(…): Verifies that the page navigated to the expected URL.
Playwright Test Trace View:
How to Use Locators and Interact with Elements in Playwright
A locator identifies which element a test should interact with. Playwright locators do not query the DOM when you define them. The query runs only when an action is triggered, and Playwright retries automatically until the element is actionable or the timeout expires.
This makes them more reliable than one-time DOM queries and more readable than selectors like .btn-primary > span.
Here are the most commonly used locator methods:
- getByRole: Targets elements based on accessibility roles such as button, link, or checkbox
await page.getByRole('button', { name: 'Login' }).click();- getByText: Finds elements using visible text on the page
await page.getByText('Sign in').click();- getByLabel: Targets form fields using their associated labels
await page.getByLabel('Email').fill('[email protected]');- getByPlaceholder: Finds input fields using placeholder text
await page.getByPlaceholder('Enter password').fill('secret');- getByTestId: Uses custom test IDs defined in the application, useful for stable selectors in larger projects
await page.getByTestId('login-button').click();Playwright Trace Viewer for Email Login :
Once an element is located, Playwright provides actions that simulate user behavior. Common interactions include:
- Clicking an element:
await page.getByRole('button', { name: 'Submit' }).click();- Typing into an input field:
await page.getByLabel('Username').fill('testuser');- Selecting a value from a dropdown:
await page.getByLabel('Country').selectOption('India');- Checking or unchecking elements:
await page.getByRole('checkbox', { name: 'Accept terms' }).check();Common Interaction’s Playwright Trace View :
To connect this with the earlier example, the following line:
await page.getByRole('link', { name: 'More information' }).click();locates a link using its role and visible name, and then performs a click action. The same pattern applies across all interactions in Playwright.
As tests grow, locators can also be stored in variables and reused, which improves readability and keeps test logic clean.
Handling Complex UI Interactions in Playwright
Real applications have dynamic elements, overlays, and delayed rendering. Playwright handles timing automatically, but your test still needs to target the right element and validate the right state. Here is how to handle the cases that trip up most test suites.
Working with Dynamic Elements
Modern UIs often load elements after an API call or user action. Instead of adding manual waits, Playwright allows you to wait for conditions tied to the UI.
await page.getByRole('button', { name: 'Load Results' }).click();
await page.getByText('Results loaded').waitFor({ state: 'visible' });This ensures the test continues only after the expected element appears.
Example:
import { test, expect } from '@playwright/test';
test('handle dynamic loading elements', async ({ page }) => {
// Open the dynamic loading page
await page.goto('https://the-internet.herokuapp.com/dynamic_loading/1');
// Click the Start button
await page.getByRole('button', { name: 'Start' }).click();
// Wait for the dynamically loaded text
const helloText = page.getByText('Hello World!');
await helloText.waitFor({ state: 'visible' });
// Validate the text is visible
await expect(helloText).toBeVisible();
console.log('Dynamic content loaded successfully');
});Tracer View Output:
For visibility-based checks:
await expect(page.getByText('Welcome')).toBeVisible();This confirms that the element is present and visible before moving forward.
Handling Multiple Matching Elements
Sometimes a locator matches more than one element. In such cases, the test needs to be explicit about which one to use.
const items = page.getByRole('listitem');
await items.first().click();Or target a specific one:
await page.getByRole('button', { name: 'Delete' }).nth(1).click();This avoids ambiguity and makes the test deterministic.
Example:
import { test, expect } from '@playwright/test';
test('delete a specific user from admin dashboard', async ({ page }) => {
// open demo admin dashboard
await page.goto(
'https://opensource-demo.orangehrmlive.com/web/index.php/auth/login'
);
// login
await page.getByPlaceholder('Username').fill('Admin');
await page.getByPlaceholder('Password').fill('admin123');
await page.getByRole('button', { name: 'Login' }).click();
// open admin module
await page.getByRole('link', { name: 'Admin' }).click();
// search for a specific user row
const userRow = page
.locator('.oxd-table-row')
.filter({
hasText: 'Admin'
});
// click delete button only inside that row
await userRow
.locator('button')
.nth(1)
.click();
});Output:
Interacting with Hidden or Disabled Elements
Elements may exist in the DOM but not be ready for interaction. Playwright prevents actions on such elements unless they are actionable.
await page.getByRole('button', { name: 'Submit' }).click();If the button is disabled or covered by another element, Playwright waits until it becomes clickable. This removes the need for manual checks in most cases.
Handling Dropdowns and Select Components
Dropdowns often require selecting values after expanding the menu.
await page.getByLabel('Country').selectOption('India');For custom dropdowns (not native <select>), interaction is usually a combination of click and select:
await page.getByText('Select Country').click();
await page.getByText('India').click();Dealing with Modals and Overlays
Modals can block interactions with the rest of the page. The test should explicitly handle them before proceeding.
await page.getByRole('button', { name: 'Open Modal' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Close' }).click();This ensures the modal is fully handled before moving forward.
Handling Navigation and Page Transitions
Some actions trigger navigation or page reloads. Playwright automatically waits for navigation in most cases, but assertions help confirm the outcome.
await page.getByRole('link', { name: 'Dashboard' }).click();
await expect(page).toHaveURL(/dashboard/);This validates that the navigation completed successfully.
Authentication and Session Management in Playwright
Authentication is one of the most common sources of test slowness and flakiness. If every test logs in through the UI before running, you are adding 2 to 5 seconds of setup to each test and introducing a dependency on your login flow being stable. At 200 tests, that adds up.
Playwright solves this with storageState, which lets you capture cookies, localStorage, and session data once and reuse it across tests without going through the login UI again.
Reusing Logged-In Sessions with storageState
The pattern has two parts. First, a setup script logs in once and saves the session to a file:
// tests/setup/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// save session state to file
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});Second, tests load that saved state instead of logging in again:
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'authenticated tests',
dependencies: ['setup'],
use: {
storageState: 'playwright/.auth/user.json',
},
},
],
});Every test in the authenticated project starts with a browser context that already has the session loaded. No UI login required.
Saving and Loading Authentication State
The saved state file is a JSON file containing cookies and origin-scoped storage. It looks like this:
{
"cookies": [...],
"origins": [
{
"origin": "https://app.example.com",
"localStorage": [...]
}
]
}Add this file to .gitignore since it contains session tokens:
playwright/.auth/
In CI, the setup project runs first and generates the file fresh. Subsequent test projects consume it. The session is valid for the duration of the CI run, then discarded.
Handling Cookies and Local Storage
For tests that need to manipulate cookies or storage directly, Playwright provides context-level methods:
// add a cookie
await context.addCookies([{
name: 'session',
value: 'abc123',
domain: 'app.example.com',
path: '/',
}]);
// read cookies
const cookies = await context.cookies();
// clear all cookies
await context.clearCookies();
// set localStorage directly via page evaluation
await page.evaluate(() => {
localStorage.setItem('feature_flag', 'enabled');
});These are useful for testing specific states without going through UI flows to get there.
Multi-User Authentication Testing
Some tests need to verify how different users interact, for example an admin and a regular user in the same flow. Create a separate storage state file for each role:
// tests/setup/auth.setup.ts
setup('authenticate as admin', async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('adminpass');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.context().storageState({ path: 'playwright/.auth/admin.json' });
});
setup('authenticate as user', async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('userpass');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});For tests that need both roles simultaneously, use two browser contexts:
test('admin can see content user cannot', async ({ browser }) => {
const adminContext = await browser.newContext({
storageState: 'playwright/.auth/admin.json',
});
const userContext = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
const adminPage = await adminContext.newPage();
const userPage = await userContext.newPage();
await adminPage.goto('https://app.example.com/admin');
await expect(adminPage.getByText('Admin Panel')).toBeVisible();
await userPage.goto('https://app.example.com/admin');
await expect(userPage.getByText('Access denied')).toBeVisible();
await adminContext.close();
await userContext.close();
});Network Interception and API Mocking in Playwright
Playwright lets you intercept and control network requests at the test level. This is different from API testing. API testing calls your endpoints directly. Network interception sits between your browser and the server, letting you mock responses, simulate failures, and cut external dependencies out of your UI tests entirely.
Intercepting Network Requests with page.route()
page.route() intercepts any request matching a URL pattern before it reaches the server:
test('shows error state when API fails', async ({ page }) => {
await page.route('**/api/users', route => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Internal server error' }),
});
});
await page.goto('https://app.example.com/users');
await expect(page.getByText('Something went wrong')).toBeVisible();
});The URL pattern supports wildcards. **/api/users matches that path on any domain.
Mocking API Responses
Return controlled data instead of hitting real endpoints:
test('renders user list from API', async ({ page }) => {
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]),
});
});
await page.goto('https://app.example.com/users');
await expect(page.getByText('Alice')).toBeVisible();
await expect(page.getByText('Bob')).toBeVisible();
});This makes tests deterministic. The UI always gets the same data regardless of what the API returns in that environment.
Blocking Third-Party Requests
Block analytics, tracking scripts, or any external dependency that slows tests down or introduces variability:
test('loads without analytics', async ({ page }) => {
await page.route('**/*google-analytics*', route => route.abort());
await page.route('**/*hotjar*', route => route.abort());
await page.goto('https://app.example.com');
});This is also useful for testing offline states or forcing specific network conditions.
Modifying Request and Response Data
Intercept a request, let it hit the real server, then modify what comes back:
test('handles modified response', async ({ page }) => {
await page.route('**/api/profile', async route => {
const response = await route.fetch();
const body = await response.json();
body.role = 'admin';
route.fulfill({
response,
body: JSON.stringify(body),
});
});
await page.goto('https://app.example.com/profile');
await expect(page.getByText('Admin')).toBeVisible();
});Use this when you need to test a specific data state that is difficult to set up through your application directly.
Cross-Browser and Mobile Testing in Playwright
Cross-browser coverage is one of Playwright’s headline features. The same test runs against Chromium, Firefox, and WebKit without any browser-specific syntax. Configuration lives in playwright.config.ts, not inside test files.
Running Tests Across Chromium, Firefox, and WebKit
Define projects for each browser in config:
export default defineConfig({
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});Run all three:
npx playwright test
Run a specific browser only:
npx playwright test --project=firefox
Output:
Your tests stay exactly the same. The project configuration handles the browser difference.
Emulating Mobile Devices and Responsive Layouts
Playwright ships with a built-in device registry covering over 30 devices:
export default defineConfig({
projects: [
{
name: 'mobile chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'mobile safari',
use: { ...devices['iPhone 13'] },
},
],
});Device presets set viewport size, user agent, touch support, and device pixel ratio automatically. You can also set these manually for custom breakpoints:
use: {
viewport: { width: 375, height: 812 },
isMobile: true,
hasTouch: true,
}Geolocation and Permissions Testing
Test location-dependent features by setting geolocation at the context level:
test('shows local results', async ({ browser }) => {
const context = await browser.newContext({
geolocation: { latitude: 22.5726, longitude: 88.3639 },
permissions: ['geolocation'],
});
const page = await context.newPage();
await page.goto('https://app.example.com/nearby');
await expect(page.getByText('Kolkata')).toBeVisible();
await context.close();
});Grant or deny other permissions the same way, including notifications, camera, and microphone, without any browser prompt interrupting the test.
How to Run Playwright Tests (Headed vs Headless Mode)
Once tests are written, the next step is execution. Playwright allows tests to run in two modes: headless and headed. The difference is whether the browser UI is visible during execution.
By default, Playwright runs tests in headless mode.
npx playwright test
Headless mode runs tests in the background without opening a visible browser window. This is faster and is typically used in CI pipelines where visual feedback is not required.
For local development, headed mode is often more useful.
npx playwright test --headed
Headed mode launches a real browser window so interactions can be observed. This helps in understanding test behavior and identifying issues related to UI rendering or timing.
You can also run tests in a specific browser:
npx playwright test --project=chromium
Output:
Or a specific test file:
npx playwright test tests/example.spec.ts
In practice, headless mode is used for speed and automation, while headed mode is used for debugging and validation during development.
How to Debug Playwright Tests
When a Playwright test fails, the goal is to identify the exact step that broke and understand what the application was doing at that moment. Playwright gives you two primary tools for this: the Inspector for live step-through debugging, and the Trace Viewer for post-run analysis. Most debugging workflows use both in sequence.
Using the Playwright Inspector
Start by running the failing test in debug mode:
npx playwright test --debug
This opens a visible browser and launches the Playwright Inspector alongside it. The test pauses before each action, giving you full control over execution. From here you can:
- Step through actions one at a time and watch how the UI responds after each one
- Use the locator picker to click any element on the page and get the recommended Playwright locator for it
- Verify whether the locator in your test matches the correct element against the live DOM
When a step fails, the Inspector highlights it and shows the locator that was used. At that point the cause is usually one of three things:
- Locator mismatch: The selector doesn’t match the intended element, either because it’s too broad, targets the wrong element, or the DOM structure changed
- Element not actionable: The element exists in the DOM but isn’t visible, is covered by another element, or is disabled
- Timing issue: The action fires before the element is rendered, typically after an async operation like a fetch, route change, or animation
Reading Trace Viewer Output
The Inspector works well when you can reproduce the failure locally. For failures that only show up in CI, the Trace Viewer is the right tool. Run the test with tracing enabled:
npx playwright test --trace on
After the run completes, open the trace:
npx playwright show-trace trace.zip
The Trace Viewer gives you a full timeline of the test execution. Every action is recorded with a DOM snapshot of the page before and after it ran, so you can scrub through the test frame by frame and see exactly what the UI looked like at each step.
Network requests are logged with timing and response status. Console output and errors are captured inline. This is the primary tool for failures that only surface in CI, where you can’t run the Inspector interactively and reproducing the failure locally isn’t always possible.
Working with Logs and Error Messages
Playwright’s error output is specific enough to act on directly. A typical failure looks like this:
TimeoutError: locator.click: Timeout 30000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Submit' })
- element was not foundThis tells you the locator, the action that failed, and that the element wasn’t found within the timeout. The fix is either the locator is wrong, the element isn’t rendered at that point in the test, or the page hasn’t finished a preceding async operation.
For assertion failures, the output shows both the expected and received values:
Error: expect(received).toHaveURL(expected) Expected: "https://app.example.com/dashboard" Received: "https://app.example.com/login"
This one is a state issue. The test expected a redirect to the dashboard but the user is still on the login page, which usually means a form submission failed silently or an auth step didn’t complete before the assertion ran.
For longer tests where the failure point isn’t immediately obvious, console.log statements at key steps help trace execution flow:
console.log('Form submitted, waiting for redirect');
await page.waitForURL('/dashboard');
console.log('Redirect confirmed, proceeding');The cause of most Playwright failures is one of three things: the locator is not matching the right element, an action runs before the UI is ready, or the application did not reach the expected state before the assertion fired. The Inspector helps identify which one. The Trace Viewer explains why.
Common Playwright Errors and How to Fix Them
Most Playwright failures fall into predictable patterns. The error output is usually specific enough to identify the cause, but knowing what to look for makes the fix faster.
1. Timeout waiting for locator
This error means Playwright waited for the specified duration, 30 seconds by default, and couldn’t find an element matching the locator. The test gives up and fails at that step.
TimeoutError: locator.click: Timeout 30000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Submit' })
- element was not foundThe element either doesn’t exist on the page, hasn’t rendered yet, or the locator isn’t matching anything in the DOM. Start by verifying the locator using the Inspector. If the element renders after an async operation, wait for it explicitly before acting on it:
await page.getByRole('button', { name: 'Submit' }).waitFor({ state: 'visible' });
await page.getByRole('button', { name: 'Submit' }).click();2. Element not visible
This error means Playwright found the element in the DOM but refused to interact with it because it isn’t visible. Playwright won’t act on hidden or off-screen elements by default.
Error: locator.click: Element is not visible - element is outside the viewport
The element may be rendered but scrolled out of view, or hidden via CSS. Scroll it into view before interacting with it:
await page.getByRole('button', { name: 'Load More' }).scrollIntoViewIfNeeded();
await page.getByRole('button', { name: 'Load More' }).click();3. Strict mode violation
This error means the locator matched more than one element on the page. Playwright’s strict mode requires every locator to resolve to a single element. If it finds multiple matches, it refuses to act rather than guessing which one you meant.
Error: locator.click: Error: strict mode violation:
getByText('Continue') resolved to 2 elementsNarrow the locator by scoping it to a parent element or using a more specific selector:
// Scope the locator to a specific parent element
await page.getByRole('dialog').getByText('Continue').click();
// Or target by position if order is reliable
await page.getByText('Continue').nth(0).click();4. Navigation timeout
This error means the page didn’t navigate to the expected URL within the timeout window. Playwright waited for the URL to change and it never did.
TimeoutError: page.waitForURL: Timeout 30000ms exceeded. Expected URL: "https://app.example.com/dashboard" Received URL: "https://app.example.com/login"
The user is still on the login page, which means the form submission failed, an auth step didn’t complete, or a redirect didn’t fire. Open the Trace Viewer and check the network tab to see what the server returned after the form was submitted.
5. Test passes locally, fails in CI
This isn’t a single Playwright error but one of the most common debugging scenarios. The test works perfectly on a local machine and breaks consistently in CI without a clear error pointing to why. The usual causes are:
- Viewport differences: CI runners often use a different default viewport. Set it explicitly in playwright.config.ts under use: { viewport: { width: 1280, height: 720 } }.
- Slower environment: CI machines are slower than local machines. An element that renders in 100ms locally might take 800ms in CI. Increase the global timeout in config or add explicit waits at the failing step.
- Missing environment variables: Base URLs, auth tokens, or feature flags that exist locally aren’t configured in CI. Verify the CI environment has the same variables the tests depend on.
6. Cannot read properties of null
This error usually comes from using page.$() instead of a Playwright locator. page.$() returns null when no matching element is found, and calling .click() on null throws immediately without retrying.
TypeError: Cannot read properties of null (reading 'click')
Replace page.$() with a Playwright locator. Locators retry automatically until the element is actionable or the timeout is reached, so you get the same retry behavior Playwright applies everywhere else.
// Avoid this
const button = await page.$('.submit-btn');
await button.click();
// Use this instead
await page.locator('.submit-btn').click();7. Element intercepted by another element
This error means the element is visible and in the viewport, but something else is sitting on top of it, usually a modal, overlay, or tooltip, preventing the click from reaching it.
Error: locator.click: Element is intercepted by another element <div class="modal-overlay">
If the overlay should be dismissed before the click, close it first. If it’s a loading overlay, wait for it to disappear before proceeding:
await page.locator('.modal-overlay').waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Submit' }).click();Output:
Playwright Best Practices for Stable and Scalable Tests
A Playwright test suite that works for a few tests behaves very differently at scale. These practices help keep tests stable, readable, and maintainable as they grow.
- Use semantic locators over CSS and XPath: Prefer getByRole,getByLabel, and getByText because they target elements based on user-facing attributes. This makes tests more resilient to DOM structure changes compared to CSS or XPath selectors.
- Use chaining and filtering for complex selections: Scope locators to a parent element and filter by text or attributes instead of relying on positional selectors like nth(). This reduces flakiness when the UI layout changes.
- Use explicit waits over fixed delays: Avoid page.waitForTimeout() as it introduces unnecessary delays and still fails under variable load. Use condition-based waits like waitFor({ state: ‘visible’ }), waitForURL, or waitForResponse so tests proceed as soon as the application is ready.
- Use Page Object Model for scalability: Move page interactions into reusable classes. This centralizes logic so UI changes only need to be updated in one place instead of across multiple test files.
- Use Playwright’s built-in assertions: Methods like toHaveURL, toBeVisible, and toHaveText automatically retry until the condition is met. This reduces timing issues compared to one-time checks.
- Use Trace Viewer for debugging failures: Configure tracing with trace: ‘on-first-retry’ so failed tests automatically capture execution details. This helps debug CI failures without needing to reproduce them locally.
- Use test.describe and fixtures to organize tests: Group related tests using test.describe and move repeated setup into fixtures. Fixtures handle setup and teardown cleanly and keep test files focused.
- Keep configuration in playwright.config.ts: Store base URLs, timeouts, and browser settings in a central config. Use environment variables for values that differ across environments like staging and production.
Conclusion
Playwright is currently the most complete end-to-end testing framework available. It handles cross-browser testing, network interception, visual regression, and API testing from a single unified API, with an architecture designed around how modern web apps actually behave.
To build a stable suite, start with semantic locators like getByRole and let Playwright handle waiting. When tests fail, use the Inspector for local debugging and Trace Viewer for CI failures. As the suite scales, move repeated setup into fixtures, adopt the Page Object Model, and keep environment-specific values out of test files and into config.



























