VOOZH about

URL: https://tech-insider.org/playwright-tutorial-end-to-end-testing-2026/

⇱ How to Master Playwright Testing: 13-Step Tutorial [2026]


Skip to content
April 13, 2026
24 min read

Playwright has become the go-to testing framework for modern web applications, and for good reason. With built-in support for Chromium, Firefox, and WebKit, automatic waiting, and a powerful trace viewer, it solves the pain points that made Selenium and Cypress frustrating. This Playwright tutorial walks you through 13 hands-on steps to build a complete end-to-end testing suite from scratch, covering everything from installation to CI/CD integration.

Whether you are testing a single-page application or a complex multi-page workflow, Playwright gives you a unified API that works across all major browsers. By the end of this tutorial, you will have a production-ready test suite with page object models, API testing, visual regression checks, and parallel execution configured for your CI pipeline. Every code block in this guide is tested against Playwright 1.59 and Node.js 22 LTS as of April 2026.

Prerequisites and Environment Setup

Before writing your first Playwright test, you need a working development environment. This tutorial uses TypeScript throughout because Playwright’s TypeScript support provides better autocompletion, type checking, and IDE integration. Here is what you need installed on your machine before starting.

PrerequisiteMinimum VersionRecommended VersionPurpose
Node.js18.x22.x LTSJavaScript runtime
npm9.x10.xPackage manager
VS Code1.85+LatestIDE with Playwright extension
Git2.40+LatestVersion control
Operating SystemWindows 10+, macOS 12+, Ubuntu 20.04+Ubuntu 22.04 / macOS 14Development platform
Disk Space500 MB1 GBBrowser binaries

Playwright downloads its own browser binaries during installation, so you do not need Chrome, Firefox, or Safari installed separately. The framework bundles specific, tested versions of each browser engine to ensure consistent test results across environments. As of Playwright 1.59, those bundled versions are Chromium 147, Firefox 148, and WebKit 26.4.

Verify your Node.js installation by running node --version in your terminal. If you see a version below 18, upgrade using the official Node.js installer or a version manager like nvm. The LTS version (22.x as of April 2026) is the safest choice for long-term projects.

Step 1: Install Playwright and Initialize Your Project

Start by creating a new directory for your project and initializing Playwright with the official CLI tool. The npm init playwright command scaffolds a complete project structure with configuration files, example tests, and the GitHub Actions workflow template.

👁 Step 1: Install Playwright and Initialize Your Project
mkdir playwright-demo && cd playwright-demo
npm init playwright@latest

# When prompted, select:
# ✔ Do you want to use TypeScript or JavaScript? → TypeScript
# ✔ Where to put your end-to-end tests? → tests
# ✔ Add a GitHub Actions workflow? → true
# ✔ Install Playwright browsers? → true

This command creates the following project structure. Understanding this layout is important because every file serves a specific purpose in the Playwright ecosystem.

playwright-demo/
├── tests/
│ └── example.spec.ts # Example test file
├── tests-examples/
│ └── demo-todo-app.spec.ts # Full demo test suite
├── playwright.config.ts # Main configuration file
├── package.json
├── package-lock.json
└── .github/
 └── workflows/
 └── playwright.yml # CI workflow template

After installation completes, Playwright downloads three browser binaries (Chromium, Firefox, and WebKit) into a cache directory. This typically takes 200-400 MB of disk space. You can verify the installation by running npx playwright --version, which should output the installed version number. If you are working in a corporate environment behind a proxy, set the HTTPS_PROXY environment variable before running the install command.

Step 2: Configure Playwright for Real-World Testing

The default playwright.config.ts file works for basic tests, but production projects need customized settings. Open the configuration file and replace its contents with a battle-tested configuration that handles multiple browsers, retries, parallel execution, and reporting.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
 testDir: './tests',
 fullyParallel: true,
 forbidOnly: !!process.env.CI,
 retries: process.env.CI ? 2 : 0,
 workers: process.env.CI ? 1 : undefined,
 reporter: [
 ['html', { open: 'never' }],
 ['json', { outputFile: 'test-results/results.json' }],
 ['list']
 ],
 use: {
 baseURL: 'https://demo.playwright.dev/todomvc',
 trace: 'on-first-retry',
 screenshot: 'only-on-failure',
 video: 'retain-on-failure',
 },
 projects: [
 {
 name: 'chromium',
 use: { ...devices['Desktop Chrome'] },
 },
 {
 name: 'firefox',
 use: { ...devices['Desktop Firefox'] },
 },
 {
 name: 'webkit',
 use: { ...devices['Desktop Safari'] },
 },
 {
 name: 'mobile-chrome',
 use: { ...devices['Pixel 7'] },
 },
 {
 name: 'mobile-safari',
 use: { ...devices['iPhone 14'] },
 },
 ],
});

This configuration enables several important features. The fullyParallel option runs all tests in parallel across available CPU cores, which dramatically reduces total execution time for large test suites. The retries setting adds automatic retry logic in CI environments, where flaky tests are more common due to shared resources. The trace option set to on-first-retry captures a full execution trace only when a test fails and retries, giving you detailed debugging data without the storage overhead of tracing every test run.

The reporter array generates three output formats simultaneously: an interactive HTML report for manual review, a JSON file for programmatic analysis, and a list reporter for terminal output during development. The projects array defines five browser configurations, including two mobile device emulations, ensuring your application works across the full range of user devices.

Step 3: Write Your First End-to-End Test

With the configuration in place, create your first test file. Playwright tests follow a straightforward structure: navigate to a page, interact with elements, and assert expected outcomes. Create a file called tests/todo.spec.ts that tests a TodoMVC application.

import { test, expect } from '@playwright/test';

test.describe('TodoMVC Application', () => {
 test.beforeEach(async ({ page }) => {
 await page.goto('/');
 });

 test('should add a new todo item', async ({ page }) => {
 const input = page.getByPlaceholder('What needs to be done?');
 await input.fill('Buy groceries');
 await input.press('Enter');

 const todoItem = page.getByTestId('todo-title');
 await expect(todoItem).toHaveText('Buy groceries');
 });

 test('should mark a todo as completed', async ({ page }) => {
 const input = page.getByPlaceholder('What needs to be done?');
 await input.fill('Learn Playwright');
 await input.press('Enter');

 const checkbox = page.getByRole('checkbox', { name: 'Toggle Todo' });
 await checkbox.check();

 const todoItem = page.locator('.todo-list li');
 await expect(todoItem).toHaveClass(/completed/);
 });

 test('should filter active todos', async ({ page }) => {
 const input = page.getByPlaceholder('What needs to be done?');

 await input.fill('Task 1');
 await input.press('Enter');
 await input.fill('Task 2');
 await input.press('Enter');

 // Complete the first task
 const firstCheckbox = page.getByRole('checkbox').first();
 await firstCheckbox.check();

 // Filter to active only
 await page.getByRole('link', { name: 'Active' }).click();

 const visibleTodos = page.getByTestId('todo-title');
 await expect(visibleTodos).toHaveCount(1);
 await expect(visibleTodos).toHaveText('Task 2');
 });

 test('should delete a todo item', async ({ page }) => {
 const input = page.getByPlaceholder('What needs to be done?');
 await input.fill('Temporary task');
 await input.press('Enter');

 const todoItem = page.locator('.todo-list li');
 await todoItem.hover();
 await todoItem.getByRole('button', { name: 'Delete' }).click();

 await expect(page.getByTestId('todo-title')).toHaveCount(0);
 });
});

Run this test suite with npx playwright test. By default, Playwright runs tests in headless mode across all configured browser projects. To see the browser while tests execute, add the --headed flag. For development, use npx playwright test --project=chromium to run tests in a single browser and speed up the feedback loop.

Notice the use of different locator strategies in this test. Playwright recommends using user-facing locators like getByRole, getByPlaceholder, and getByTestId because they are resilient to implementation changes. Unlike CSS selectors or XPath, these locators match how users actually interact with your application. The getByTestId locator maps to the data-testid attribute by default, which can be configured in your playwright config if your project uses a different convention.

Step 4: Master Playwright Locators and Selectors

Choosing the right locator strategy is the difference between a stable test suite and one that breaks with every UI update. Playwright provides a hierarchy of locator methods, each designed for specific use cases. Understanding when to use each one saves hours of debugging flaky tests.

👁 Step 4: Master Playwright Locators and Selectors
Locator MethodExampleBest ForStability
getByRolepage.getByRole(‘button’, { name: ‘Submit’ })Interactive elementsHigh
getByTextpage.getByText(‘Welcome back’)Static text contentHigh
getByLabelpage.getByLabel(‘Email address’)Form fieldsHigh
getByPlaceholderpage.getByPlaceholder(‘Enter email’)Input placeholdersMedium
getByTestIdpage.getByTestId(‘submit-btn’)Custom test hooksVery High
locator (CSS)page.locator(‘.btn-primary’)CSS class matchingLow
locator (XPath)page.locator(‘//div[@class=”card”]’)Complex DOM traversalLow

The golden rule is to use semantic locators first. Start with getByRole because it mirrors how assistive technologies identify elements, which means your tests validate accessibility at the same time. Fall back to getByTestId when no semantic locator fits. Reserve CSS and XPath selectors for edge cases where no other option works, such as third-party components you cannot modify.

Playwright also supports chaining locators with filter and locator methods. For example, page.getByRole('listitem').filter({ hasText: 'Product A' }).getByRole('button') first finds all list items, narrows to the one containing specific text, then selects a button within it. This pattern avoids brittle selectors that depend on exact DOM position.

Use the built-in code generator to discover the best locators for your application. Run npx playwright codegen https://your-app.com to launch a browser with the Playwright Inspector. As you interact with the page, the inspector generates locator code in real time, always choosing the most resilient selector strategy available.

Step 5: Implement the Page Object Model Pattern

As your test suite grows beyond a handful of files, you need an organizational pattern that prevents duplication and makes tests easier to maintain. The Page Object Model (POM) encapsulates page-specific logic into reusable classes, so when the UI changes, you update one file instead of dozens of tests.

Create a pages directory and add a page object for the TodoMVC application.

// pages/todo.page.ts
import { type Locator, type Page, expect } from '@playwright/test';

export class TodoPage {
 readonly page: Page;
 readonly newTodoInput: Locator;
 readonly todoItems: Locator;
 readonly todoCount: Locator;
 readonly clearCompletedButton: Locator;

 constructor(page: Page) {
 this.page = page;
 this.newTodoInput = page.getByPlaceholder('What needs to be done?');
 this.todoItems = page.getByTestId('todo-title');
 this.todoCount = page.getByTestId('todo-count');
 this.clearCompletedButton = page.getByRole('button', {
 name: 'Clear completed',
 });
 }

 async goto() {
 await this.page.goto('/');
 }

 async addTodo(text: string) {
 await this.newTodoInput.fill(text);
 await this.newTodoInput.press('Enter');
 }

 async addMultipleTodos(items: string[]) {
 for (const item of items) {
 await this.addTodo(item);
 }
 }

 async toggleTodo(index: number) {
 const checkbox = this.page.getByRole('checkbox').nth(index);
 await checkbox.check();
 }

 async deleteTodo(index: number) {
 const item = this.page.locator('.todo-list li').nth(index);
 await item.hover();
 await item.getByRole('button', { name: 'Delete' }).click();
 }

 async filterBy(filter: 'All' | 'Active' | 'Completed') {
 await this.page.getByRole('link', { name: filter }).click();
 }

 async expectTodoCount(count: number) {
 await expect(this.todoItems).toHaveCount(count);
 }

 async expectTodoTexts(texts: string[]) {
 await expect(this.todoItems).toHaveText(texts);
 }
}

Now refactor your tests to use this page object. The test file becomes significantly cleaner and more readable.

// tests/todo-pom.spec.ts
import { test } from '@playwright/test';
import { TodoPage } from '../pages/todo.page';

test.describe('TodoMVC with Page Object Model', () => {
 let todoPage: TodoPage;

 test.beforeEach(async ({ page }) => {
 todoPage = new TodoPage(page);
 await todoPage.goto();
 });

 test('should manage multiple todos', async () => {
 await todoPage.addMultipleTodos([
 'Write tests',
 'Review PR',
 'Deploy to staging',
 ]);
 await todoPage.expectTodoCount(3);

 await todoPage.toggleTodo(0);
 await todoPage.filterBy('Active');
 await todoPage.expectTodoCount(2);

 await todoPage.filterBy('Completed');
 await todoPage.expectTodoCount(1);
 });

 test('should delete completed todos', async () => {
 await todoPage.addMultipleTodos(['Task A', 'Task B']);
 await todoPage.toggleTodo(0);
 await todoPage.deleteTodo(0);
 await todoPage.expectTodoCount(1);
 await todoPage.expectTodoTexts(['Task B']);
 });
});

The Page Object Model pays off as your application grows. When a developer changes the placeholder text of the input field, you update one line in todo.page.ts instead of searching through every test file. This pattern also makes tests self-documenting: todoPage.addTodo('Write tests') is immediately understandable without reading the underlying implementation.

Step 6: Test API Endpoints Alongside UI Tests

Playwright is not limited to browser automation. Its built-in APIRequestContext lets you test REST APIs directly within the same test framework, eliminating the need for separate tools like Postman or Supertest. This is especially useful for testing the full stack: seed data via API, verify results in the UI.

// tests/api.spec.ts
import { test, expect } from '@playwright/test';

test.describe('API Testing', () => {
 const API_URL = 'https://jsonplaceholder.typicode.com';

 test('GET /posts should return 100 posts', async ({ request }) => {
 const response = await request.get(`${API_URL}/posts`);

 expect(response.ok()).toBeTruthy();
 expect(response.status()).toBe(200);

 const posts = await response.json();
 expect(posts).toHaveLength(100);
 expect(posts[0]).toHaveProperty('title');
 expect(posts[0]).toHaveProperty('body');
 expect(posts[0]).toHaveProperty('userId');
 });

 test('POST /posts should create a new post', async ({ request }) => {
 const newPost = {
 title: 'Playwright API Testing',
 body: 'Testing REST APIs with Playwright',
 userId: 1,
 };

 const response = await request.post(`${API_URL}/posts`, {
 data: newPost,
 });

 expect(response.ok()).toBeTruthy();
 expect(response.status()).toBe(201);

 const created = await response.json();
 expect(created.title).toBe(newPost.title);
 expect(created.id).toBeDefined();
 });

 test('PUT /posts/1 should update a post', async ({ request }) => {
 const updatedData = {
 title: 'Updated Title',
 body: 'Updated body content',
 userId: 1,
 };

 const response = await request.put(`${API_URL}/posts/1`, {
 data: updatedData,
 });

 expect(response.ok()).toBeTruthy();
 const updated = await response.json();
 expect(updated.title).toBe('Updated Title');
 });

 test('DELETE /posts/1 should remove a post', async ({ request }) => {
 const response = await request.delete(`${API_URL}/posts/1`);
 expect(response.ok()).toBeTruthy();
 expect(response.status()).toBe(200);
 });
});

A powerful pattern combines API and UI testing in a single test. Use the API to set up test data quickly (bypassing the UI), then verify the UI displays that data correctly. This approach is faster than creating data through the UI and reduces test flakiness caused by complex setup steps. For authenticated APIs, configure the extraHTTPHeaders option in your playwright config to include authorization tokens automatically.

Step 7: Handle Authentication and Test Fixtures

Most real-world applications require authentication. Logging in through the UI for every test is slow and creates a bottleneck. Playwright solves this with storage state: log in once, save the authentication state, and reuse it across all tests that need an authenticated session.

👁 Step 7: Handle Authentication and Test Fixtures
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.auth/user.json');

setup('authenticate', async ({ page }) => {
 // Navigate to login page
 await page.goto('/login');

 // Fill in credentials
 await page.getByLabel('Email').fill('[email protected]');
 await page.getByLabel('Password').fill('securepassword123');
 await page.getByRole('button', { name: 'Sign in' }).click();

 // Wait for successful login
 await page.waitForURL('/dashboard');
 await expect(page.getByText('Welcome back')).toBeVisible();

 // Save authentication state
 await page.context().storageState({ path: authFile });
});

To use this setup across your test projects, add a setup dependency in your Playwright configuration.

// Add to playwright.config.ts projects array:
{
 name: 'setup',
 testMatch: /.*.setup.ts/,
},
{
 name: 'chromium',
 use: {
 ...devices['Desktop Chrome'],
 storageState: '.auth/user.json',
 },
 dependencies: ['setup'],
},

With this pattern, the authentication setup runs once before all tests. Every subsequent test starts with an already-authenticated session loaded from the saved storage state file. This approach cuts test execution time significantly in large suites. For applications with multiple user roles (admin, editor, viewer), create separate setup files and storage state files for each role, then assign them to different test projects.

Step 8: Debug Tests with Trace Viewer and UI Mode

When tests fail, you need more than a stack trace to understand what went wrong. Playwright’s Trace Viewer captures a complete timeline of every action, network request, console log, and DOM snapshot during test execution, giving you a time-travel debugging experience.

Enable tracing in your configuration by setting trace: 'on-first-retry' (which you already configured in Step 2). When a test fails and retries, Playwright captures a .zip trace file in the test-results directory. Open it with npx playwright show-trace test-results/path/to/trace.zip.

The Trace Viewer shows a timeline at the top of the window with every Playwright action. Click any action to see the DOM snapshot at that exact moment, the network requests in flight, and the console output. The network details panel (reorganized in Playwright 1.58) automatically formats JSON responses for readability, making API debugging straightforward.

For interactive debugging during development, use UI Mode by running npx playwright test --ui. This launches a graphical interface where you can select specific tests, watch them execute in real time, and inspect results with the built-in trace viewer. UI Mode in 2026 includes a system theme option that follows your OS dark or light mode preference, plus Cmd/Ctrl+F search inside code editors for navigating large test files. The Timeline and Speedboard tools provide visibility into slow tests, helping you identify performance bottlenecks in your test suite.

Another powerful debugging tool is the page.pause() method. Insert it anywhere in your test code to halt execution and open the Playwright Inspector. From there, you can step through actions one at a time, inspect locators, and even run arbitrary locator queries against the current page state. Remove page.pause() before committing your code.

Step 9: Add Visual Regression Testing

Visual regression testing catches CSS changes, layout shifts, and rendering bugs that functional tests miss entirely. Playwright has built-in screenshot comparison that works without third-party plugins. On the first run, Playwright saves a baseline screenshot. On subsequent runs, it compares the current screenshot against the baseline and fails if they differ beyond a configurable threshold.

// tests/visual.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Visual Regression Tests', () => {
 test('homepage should match baseline', async ({ page }) => {
 await page.goto('/');
 await expect(page).toHaveScreenshot('homepage.png', {
 maxDiffPixelRatio: 0.01,
 });
 });

 test('todo list with items should match baseline', async ({ page }) => {
 await page.goto('/');

 const input = page.getByPlaceholder('What needs to be done?');
 await input.fill('Visual test item');
 await input.press('Enter');

 // Wait for animations to complete
 await page.waitForTimeout(500);

 await expect(page).toHaveScreenshot('todo-with-items.png', {
 maxDiffPixelRatio: 0.01,
 animations: 'disabled',
 });
 });

 test('specific component should match baseline', async ({ page }) => {
 await page.goto('/');

 const input = page.getByPlaceholder('What needs to be done?');
 await input.fill('Component test');
 await input.press('Enter');

 const footer = page.locator('.footer');
 await expect(footer).toHaveScreenshot('todo-footer.png');
 });
});

Run these tests the first time with npx playwright test tests/visual.spec.ts --update-snapshots to generate baseline images. These baselines are saved in a tests/visual.spec.ts-snapshots directory and should be committed to version control. Each browser project generates its own set of baselines because rendering differs across engines.

The maxDiffPixelRatio option controls how much difference is tolerated. A value of 0.01 means up to 1% of pixels can differ, which accounts for sub-pixel rendering differences across operating systems. Set animations: 'disabled' to freeze CSS animations and transitions before taking screenshots, preventing false failures caused by animation timing. For dynamic content like timestamps or avatars, use the mask option to exclude specific elements from comparison.

Step 10: Set Up Network Mocking and Interception

Testing against live APIs introduces network latency, rate limits, and data inconsistency. Playwright’s route interception lets you mock API responses, simulate errors, and test edge cases that are difficult to reproduce with real backends.

👁 Step 10: Set Up Network Mocking and Interception
// tests/network.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Network Mocking', () => {
 test('should display mocked API data', async ({ page }) => {
 // Intercept API calls and return mock data
 await page.route('**/api/todos', async (route) => {
 await route.fulfill({
 status: 200,
 contentType: 'application/json',
 body: JSON.stringify([
 { id: 1, title: 'Mocked Todo 1', completed: false },
 { id: 2, title: 'Mocked Todo 2', completed: true },
 { id: 3, title: 'Mocked Todo 3', completed: false },
 ]),
 });
 });

 await page.goto('/');
 // Verify the UI renders the mocked data
 const items = page.getByTestId('todo-title');
 await expect(items).toHaveCount(3);
 });

 test('should handle API errors gracefully', async ({ page }) => {
 await page.route('**/api/todos', async (route) => {
 await route.fulfill({
 status: 500,
 contentType: 'application/json',
 body: JSON.stringify({ error: 'Internal Server Error' }),
 });
 });

 await page.goto('/');
 const errorMsg = page.getByText('Something went wrong');
 await expect(errorMsg).toBeVisible();
 });

 test('should handle slow network conditions', async ({ page }) => {
 await page.route('**/api/todos', async (route) => {
 // Simulate 3-second delay
 await new Promise((resolve) => setTimeout(resolve, 3000));
 await route.continue();
 });

 await page.goto('/');
 const spinner = page.getByRole('progressbar');
 await expect(spinner).toBeVisible();
 });

 test('should monitor network requests', async ({ page }) => {
 const requests: string[] = [];

 page.on('request', (request) => {
 if (request.url().includes('/api/')) {
 requests.push(`${request.method()} ${request.url()}`);
 }
 });

 await page.goto('/');
 // Perform actions that trigger API calls
 console.log('Captured API requests:', requests);
 });
});

Network mocking is essential for testing error states, loading indicators, and edge cases like empty responses or malformed data. The route.continue() method lets the request proceed to the actual server, which is useful when you only want to observe or modify certain requests rather than mock them entirely. You can also use route.abort() to simulate network failures and verify your application handles offline scenarios correctly.

Step 11: Configure Parallel Execution and Sharding

Large test suites can take minutes to run sequentially. Playwright supports two levels of parallelism: running test files in parallel across worker processes, and sharding test suites across multiple CI machines. Both strategies can be combined for maximum throughput.

Your configuration already enables fullyParallel: true, which runs individual tests within a file concurrently. By default, Playwright uses half the available CPU cores as workers. You can control this with the workers option in your config or the --workers CLI flag.

For CI environments with multiple machines, use sharding to split the test suite.

# Shard across 4 CI machines
# Machine 1: npx playwright test --shard=1/4
# Machine 2: npx playwright test --shard=2/4
# Machine 3: npx playwright test --shard=3/4
# Machine 4: npx playwright test --shard=4/4

# GitHub Actions example with matrix strategy:
# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]

jobs:
 test:
 timeout-minutes: 30
 runs-on: ubuntu-latest
 strategy:
 fail-fast: false
 matrix:
 shard: [1/4, 2/4, 3/4, 4/4]
 steps:
 - uses: actions/checkout@v4
 - uses: actions/setup-node@v4
 with:
 node-version: 22
 - run: npm ci
 - run: npx playwright install --with-deps
 - run: npx playwright test --shard=${{ matrix.shard }}
 - uses: actions/upload-artifact@v4
 if: ${{ !cancelled() }}
 with:
 name: playwright-report-${{ strategy.job-index }}
 path: playwright-report/
 retention-days: 14

This GitHub Actions workflow runs your test suite across four parallel machines, each executing one-quarter of the total tests. The fail-fast: false option ensures all shards complete even if one fails, giving you the full picture of failures. Test reports from each shard are uploaded as artifacts and can be merged for a unified view. With sharding enabled, a 20-minute test suite running on a single machine can finish in under 5 minutes spread across four machines.

When tests share state or depend on execution order, mark them with test.describe.serial to force sequential execution within that describe block. Use this sparingly, as serial tests cannot benefit from parallel execution and will slow down your overall suite.

Step 12: Integrate with CI/CD Pipelines

Running Playwright tests in continuous integration requires specific configuration to handle browser installation, environment differences, and artifact management. The Playwright CI documentation covers all major CI providers, but the most common setup uses GitHub Actions with the official Docker image.

For projects not using GitHub Actions, here are configurations for other popular CI platforms.

CI PlatformBrowser Install CommandDocker Image AvailableParallel SupportArtifact Upload
GitHub Actionsnpx playwright install –with-depsYesMatrix strategyactions/upload-artifact
GitLab CInpx playwright install –with-depsYesparallel keywordBuilt-in artifacts
Jenkinsnpx playwright install –with-depsYesPipeline stagesarchiveArtifacts
CircleCInpx playwright install –with-depsYesparallelism keystore_artifacts
Azure Pipelinesnpx playwright install –with-depsYesStrategy matrixPublishPipelineArtifact

In all CI environments, set the CI environment variable to true (most CI platforms do this automatically). Your Playwright configuration already references process.env.CI to enable retries and limit workers in CI environments. The --with-deps flag in the install command installs system-level dependencies (like shared libraries) required by the browser binaries, which are not present on minimal CI images.

Store your HTML test report as a CI artifact so developers can download and review it after the pipeline completes. The HTML report includes screenshots of failures, trace files, and detailed timing information for each test. For teams using Slack or Microsoft Teams, configure a notification step that triggers when tests fail, including a link to the report artifact.

Step 13: Build a Complete Working Test Suite

Now let us bring everything together into a complete, production-ready project. This final step combines all the patterns from the previous steps into a single cohesive test suite with proper project structure, configuration, and utility functions.

👁 Step 13: Build a Complete Working Test Suite
// tests/e2e/full-workflow.spec.ts
import { test, expect } from '@playwright/test';
import { TodoPage } from '../../pages/todo.page';

test.describe('Complete TodoMVC Workflow', () => {
 let todoPage: TodoPage;

 test.beforeEach(async ({ page }) => {
 todoPage = new TodoPage(page);
 await todoPage.goto();
 });

 test('full CRUD workflow', async ({ page }) => {
 // Create
 await todoPage.addMultipleTodos([
 'Write documentation',
 'Review pull request',
 'Deploy to production',
 'Monitor error rates',
 ]);
 await todoPage.expectTodoCount(4);

 // Read/verify
 await todoPage.expectTodoTexts([
 'Write documentation',
 'Review pull request',
 'Deploy to production',
 'Monitor error rates',
 ]);

 // Update (toggle complete)
 await todoPage.toggleTodo(0);
 await todoPage.toggleTodo(2);

 // Filter and verify
 await todoPage.filterBy('Active');
 await todoPage.expectTodoCount(2);
 await todoPage.expectTodoTexts([
 'Review pull request',
 'Monitor error rates',
 ]);

 await todoPage.filterBy('Completed');
 await todoPage.expectTodoCount(2);

 // Delete
 await todoPage.filterBy('All');
 await todoPage.deleteTodo(3);
 await todoPage.expectTodoCount(3);

 // Visual check
 await expect(page).toHaveScreenshot('workflow-final-state.png', {
 maxDiffPixelRatio: 0.02,
 animations: 'disabled',
 });
 });

 test('keyboard navigation workflow', async ({ page }) => {
 await todoPage.addTodo('Keyboard test');

 // Tab to the checkbox and toggle with Space
 await page.keyboard.press('Tab');
 await page.keyboard.press('Space');

 const item = page.locator('.todo-list li');
 await expect(item).toHaveClass(/completed/);

 // Verify accessibility
 await expect(page.getByRole('checkbox')).toBeChecked();
 });

 test('performance: adding 50 items', async ({ page }) => {
 const start = Date.now();

 for (let i = 0; i < 50; i++) {
 await todoPage.addTodo(`Performance test item ${i + 1}`);
 }

 const duration = Date.now() - start;
 console.log(`Adding 50 items took ${duration}ms`);

 await todoPage.expectTodoCount(50);
 expect(duration).toBeLessThan(30000); // Should finish in 30 seconds
 });
});

Run the complete suite with npx playwright test and open the HTML report with npx playwright show-report. The report provides a detailed breakdown of every test, including execution time per browser, screenshots on failure, and trace files for retried tests. Commit the baseline screenshots, your page objects, configuration, and CI workflow to version control.

Common Pitfalls and How to Avoid Them

Even experienced developers run into issues when building Playwright test suites. Here are the most common pitfalls and their solutions, drawn from production projects and community reports.

Pitfall 1: Using hard-coded waits instead of auto-waiting. Playwright automatically waits for elements to be visible, enabled, and stable before interacting with them. Calling page.waitForTimeout(5000) is almost always wrong. Instead, use await expect(element).toBeVisible() or await page.waitForLoadState('networkidle') to wait for specific conditions.

Pitfall 2: Testing implementation details instead of user behavior. Avoid selectors like .btn-primary.mt-4.px-6 that rely on CSS utility classes. These break when developers refactor styles. Use getByRole('button', { name: 'Submit' }) instead, which tests what the user sees.

Pitfall 3: Not isolating test data. Tests that share state create race conditions in parallel execution. Each test should create its own data and clean up after itself. Use test.beforeEach to set up fresh state, and avoid relying on data created by other tests.

Pitfall 4: Running all browsers during development. Use --project=chromium during local development and reserve full cross-browser runs for CI. Running all five browser projects locally wastes time and slows down the development feedback loop.

Pitfall 5: Ignoring the test report. The HTML report contains screenshots, traces, and timing data that reveal patterns in test failures. Make reviewing the report a habit, not just something you do when a test breaks. The reporter documentation covers all available formats and customization options.

Pitfall 6: Not using baseURL. Hardcoding the full URL in every page.goto() call makes tests fragile and hard to run against different environments. Set baseURL in your configuration and use relative paths in tests. Override it per environment with the BASE_URL environment variable.

Pitfall 7: Committing authentication credentials. Never hardcode passwords or API keys in test files. Use environment variables or a .env file (excluded from version control) for sensitive values. Configure your CI provider to inject these values securely.

Troubleshooting Guide

When things go wrong, use this reference to diagnose and fix common Playwright issues quickly.

IssueSymptomsSolution
Browser not foundError: Executable doesn’t existRun npx playwright install to download browsers
Test timeoutTest exceeded 30000ms timeoutIncrease timeout in config or add test.setTimeout(60000)
Element not foundLocator resolved to 0 elementsCheck selector with npx playwright codegen, verify element exists
Flaky tests in CITests pass locally, fail in CIAdd retries, use trace: 'on-first-retry', check CI resource limits
Screenshot mismatchVisual test fails on different OSGenerate baselines per OS or use Docker for consistent rendering
Port conflictEADDRINUSE error on webServerKill the process using the port or change the port in config
Slow testsSuite takes 10+ minutesEnable fullyParallel, use sharding, reduce unnecessary waits
Auth state expired401 errors after first test fileRegenerate storage state in global setup, check token TTL
CI dependency missingMissing system library errorUse npx playwright install --with-deps or official Docker image
Network mock not workingRoute handler not interceptingSet up routes before page.goto(), check URL pattern matches

Browser installation fails behind a corporate proxy. Set the HTTPS_PROXY and HTTP_PROXY environment variables before running npx playwright install. If your proxy requires authentication, use the format http://user:password@proxy-host:port. Some organizations block downloads from the default CDN, so you can also set PLAYWRIGHT_DOWNLOAD_HOST to point to an internal mirror.

Tests pass individually but fail when run together. This usually indicates shared mutable state. Check if tests modify global variables, database records, or browser storage without cleanup. Use test.describe.configure({ mode: 'serial' }) as a temporary fix while you identify and isolate the shared state.

WebKit tests fail on Linux. WebKit on Linux uses a different rendering engine than Safari on macOS, so visual tests may produce different baselines. Run WebKit visual tests only on macOS CI runners, or use the --project flag to skip WebKit during local development on Linux. For functional tests, WebKit on Linux is reliable.

Advanced Tips for Production Test Suites

Once your basic test suite is running, these advanced techniques will make it faster, more reliable, and easier to maintain in production environments.

Custom fixtures for shared setup logic. Extend Playwright’s test fixture system to inject commonly used objects like authenticated pages, seeded databases, or mock servers. Create a fixtures.ts file that exports a custom test function with your fixtures pre-configured. This eliminates boilerplate beforeEach blocks across test files.

Tag-based test filtering. Use the @tag annotation in test titles (like test('login flow @smoke', ...)) to create logical test groups. Run only smoke tests with npx playwright test --grep @smoke or exclude slow tests with --grep-invert @slow. This lets you run fast feedback tests on every commit and full regression suites on a schedule.

Global setup and teardown. Use globalSetup and globalTeardown config options for one-time operations like starting a test database, seeding data, or launching a development server. The built-in webServer config option automatically starts your application before tests and stops it afterward.

Playwright Test Agents for AI-assisted testing. Introduced in Playwright 1.56, Test Agents include planner, generator, and healer loops that can automatically generate tests from user flows and repair broken selectors. While still an evolving feature, Test Agents can bootstrap initial test coverage for applications without existing tests.

Component testing. Playwright supports testing individual UI components in isolation using the experimental component testing API. This is useful for design systems and shared component libraries where you want to verify rendering across browsers without spinning up a full application. Configure it with the @playwright/experimental-ct-react (or Vue, Svelte) package.

Custom reporters. Build custom reporters to integrate with your team’s tools. A reporter is a class that implements lifecycle hooks like onTestBegin, onTestEnd, and onEnd. Use this to send results to Slack, update dashboards, or create custom HTML reports with your company’s branding.

Related Coverage

Playwright Performance Benchmarks

Understanding how Playwright performs compared to alternatives helps justify the investment in migrating your test suite. These benchmarks reflect real-world testing scenarios, not synthetic microbenchmarks.

MetricPlaywrightCypressSelenium
Test startup time~1.5s~4s~3s
Single test execution~200ms~350ms~500ms
Parallel executionBuilt-in, per-filePaid (Cypress Cloud)Selenium Grid
Browser supportChromium, Firefox, WebKitChromium, Firefox, WebKit (experimental)All major browsers
Auto-waitingBuilt-in, all actionsBuilt-in, assertionsManual waits required
API testingBuilt-incy.requestNot included
Trace viewerBuilt-in, timeline-basedDashboard (paid)Not included
Mobile emulationBuilt-in device profilesViewport onlyAppium required

Playwright’s architecture gives it a performance advantage in parallel execution scenarios. Because each test runs in an isolated browser context (not a full browser instance), the overhead per test is minimal. A suite of 200 tests that takes 15 minutes on Cypress can often complete in under 5 minutes on Playwright with parallel execution enabled, without any paid cloud service. The exact speedup depends on your hardware, test complexity, and how many workers your machine can support.

Frequently Asked Questions

What is Playwright and why should I use it instead of Selenium?

Playwright is an open-source end-to-end testing framework developed by Microsoft. It provides a single API for testing across Chromium, Firefox, and WebKit browsers. Unlike Selenium, Playwright includes built-in auto-waiting, trace viewing, and parallel execution without requiring additional tools or infrastructure. It is faster to set up, produces more stable tests, and includes modern features like network interception and visual regression testing out of the box.

Can I use Playwright with JavaScript instead of TypeScript?

Yes. While this tutorial uses TypeScript, Playwright fully supports plain JavaScript. When you run npm init playwright, select JavaScript instead of TypeScript during the setup wizard. All the concepts, APIs, and patterns in this tutorial work identically with JavaScript files. TypeScript is recommended because it catches common errors at compile time and improves IDE autocompletion.

How do I test applications that require login?

Use Playwright’s storage state feature as described in Step 7. Log in once in a setup file, save the authenticated state to a JSON file, and configure your test projects to load that state automatically. This approach logs in once per test run instead of once per test, saving significant execution time. For applications with multiple user roles, create separate storage state files for each role.

Does Playwright support mobile app testing?

Playwright supports mobile web testing through device emulation. It can simulate specific devices like the Pixel 7 or iPhone 14, including screen size, user agent, touch events, and geolocation. For native mobile app testing (iOS and Android apps), Playwright has an alpha mobile support feature as of 2026, but most teams use Appium or Detox for native app testing. Playwright’s mobile emulation is best suited for responsive web applications.

How do I handle flaky tests in Playwright?

First, identify the root cause using trace files. Common causes include race conditions, network timing, and shared test state. Use Playwright’s built-in retry mechanism (retries: 2 in config) as a safety net, but do not rely on it to mask real issues. Enable trace: 'on-first-retry' to capture debugging data when retries occur. If a test is consistently flaky, mark it with test.fixme() and fix the underlying issue before re-enabling it.

Can I run Playwright tests in Docker?

Yes. Microsoft provides official Docker images at mcr.microsoft.com/playwright with all browser dependencies pre-installed. Use these images in your CI pipeline to ensure consistent test environments. The images are tagged with the Playwright version (e.g., mcr.microsoft.com/playwright:v1.59.0-noble), so you can pin the exact version matching your project. This eliminates the most common CI issue: missing system dependencies.

How much does Playwright cost?

Playwright is completely free and open source under the Apache 2.0 license. Unlike Cypress, which charges for cloud-based parallel execution and advanced dashboard features, Playwright’s parallel execution, trace viewer, and all reporting features are included at no cost. The only costs are your own CI infrastructure (compute time for running tests) and developer time for writing and maintaining tests.

What is the difference between Playwright and the @playwright/test package?

The playwright package is the core browser automation library, which can be used with any test runner (Jest, Mocha, etc.). The @playwright/test package is the full test runner built on top of the core library, adding features like parallel execution, fixtures, retries, reporters, and assertions. For new projects, always use @playwright/test because it provides a complete testing solution. The npm init playwright command installs @playwright/test by default.

👁 Sofia Lindström

Sofia Lindström

Editor-in-Chief

Sofia Lindström is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles
👁 Tech Insider
Tech
Insider

Tech Insider delivers in-depth coverage of the technologies shaping the future: AI, cybersecurity, cloud computing, hardware, and the trends that matter.

Company

Explore

Categories

© 2026 Tech Insider Media AB. All rights reserved.