Introduction
In 2026, Playwright stands out as the go-to tool for end-to-end (E2E) testing thanks to its rock-solid stability across Chrome, Firefox, and WebKit, smart auto-waiting, and native support for React, Vue, and Angular components. Unlike Selenium, it eliminates flakiness with powerful async APIs and built-in parallel execution.
This intermediate tutorial walks you through building a robust testing project step by step: from installation to CI/CD integration. You'll master advanced locators, network interception, and custom fixtures. By the end, your tests will be scalable, maintainable, and production-ready. Ideal for devs aiming to slash bugs by 40% on average, as shown in Microsoft benchmarks (Playwright's creators). Ready to turn manual tests into reliable automation? (142 words)
Prerequisites
- Node.js 20+ installed
- Intermediate TypeScript/JavaScript knowledge
- An editor like VS Code with the Playwright extension
- Git for version control
Initialize the Project and Install Playwright
mkdir playwright-tests && cd playwright-tests
npm init -y
npm install -D @playwright/test
npx playwright install
npx playwright test --project=chromiumThese commands set up an npm project, install Playwright as a dev dependency, and download the browser binaries. The final run checks the installation by launching an example test on Chromium. Avoid npm install playwright without -D to keep prod deps clean.
Understanding the Basic Structure
Playwright creates a standard folder structure: tests/ for spec files, playwright.config.ts for global config. Each test follows the AAA pattern (Arrange-Act-Assert) with test.describe for grouping. Use page.goto() to load a page, like the demo TodoMVC app.
Configure Playwright with TypeScript
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',
use: {
baseURL: 'https://demo.playwright.dev/todomvc',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});This config enables parallel execution, CI retries, traces for debugging, and testing across three browsers. baseURL speeds up goto(). Add webServer for local apps. Pitfall: Forget forbidOnly in CI to avoid exclusive tests blocking runs.
Write a Simple TodoMVC Test
import { test, expect } from '@playwright/test';
test('should add two todos', async ({ page }) => {
await page.goto('/');
await page.getByPlaceholder('What needs to be done?').fill('buy some cheese');
await page.getByPlaceholder('What needs to be done?').press('Enter');
await page.getByPlaceholder('What needs to be done?').fill('feed the cat');
await page.getByPlaceholder('What needs to be done?').press('Enter');
await expect(page.getByText('buy some cheese')).toBeVisible();
await expect(page.getByText('feed the cat')).toBeVisible();
});This test adds two todos and checks their visibility. getByPlaceholder is a reliable role-based locator. Playwright auto-waits for actions. Run with npx playwright test. Avoid page.click() without hover for dropdowns.
Advanced Locators and Assertions
Powerful locators: Prefer getByRole, getByText, or chained locator() for robustness. Assertions like expect(locator).toHaveCount(n) handle auto-timeouts. Analogy: Like a GPS rerouting around blocks, Playwright retries queries automatically.
Test with Advanced Locators and Assertions
import { test, expect } from '@playwright/test';
test('should toggle and mark todos complete', async ({ page }) => {
await page.goto('/');
await page.getByPlaceholder('What needs to be done?').fill('test todo');
await page.getByPlaceholder('What needs to be done?').press('Enter');
await page.getByLabel('Toggle todo').first().check();
await expect(page.locator('.todo-list li.completed')).toHaveCount(1);
await page.getByLabel('Toggle todo').first().uncheck();
await expect(page.locator('.todo-list li:not(.completed)')).toContainText('test todo');
});Here, getByLabel targets checkboxes, .completed filters via CSS. toHaveCount and toContainText assert dynamically. Perfect for changing states. Pitfall: Use first() or nth() for ambiguities, not fragile XPath.
Intercept Network Requests to Mock an API
import { test, expect } from '@playwright/test';
test('should mock API response', async ({ page }) => {
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Mock User' }]),
});
});
await page.goto('https://zero.webapp.io/todos');
await expect(page.getByText('Mock User')).toBeVisible();
});page.route intercepts fetches and mocks responses to isolate the frontend. Great for backend-free tests. fulfill simulates success/errors. Note: Use ** glob patterns for wildcards; test offline to validate.
Custom Fixtures for Reusability
Fixtures extend test.extend() for common setups like login. Save 50% on repetitive code.
Create a Login Fixture
import { test as base, expect } from '@playwright/test';
type MyFixtures = {
loggedUser: { username: string };
};
const test = base.extend<MyFixtures>({
loggedUser: async ({ page }, use) => {
await page.goto('https://zero.webapp.io/login');
await page.getByLabel('Username').fill('user');
await page.getByLabel('Password').fill('pass');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByText('Welcome')).toBeVisible();
await use({ username: 'user' });
},
});
test('should create todo as logged user', async ({ page, loggedUser }) => {
await page.goto('https://zero.webapp.io/todos');
await page.getByPlaceholder('What needs to be done?').fill(`Hello ${loggedUser.username}`);
await page.getByPlaceholder('What needs to be done?').press('Enter');
});The loggedUser fixture sets up an auth context for reuse. use() scopes the state. Scales for multi-user tests. Pitfall: Skipping await use() causes session leaks.
Integrate with CI Using GitHub Actions
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30This CI workflow installs deps and browsers, then runs tests on push/PR. Uploads reports for debugging. --with-deps handles Ubuntu system libs. Optimize with matrix for multi-browser runs.
Best Practices
- Always use role-based locators (
getByRole) for UI change resilience. - Enable traces (
trace: 'on') and videos for visual debugging. - Parallelize with
fullyParallel: trueand limit workers in CI. - Mock everything: APIs, storage for isolation.
- Page Object Model for large suites: Encapsulate locators in classes.
Common Errors to Avoid
- Flakiness: Skip
sleep(); rely onexpectpolling. - Timeouts: Bump
expecttimeout only for slow networks, not by default. - Missing browsers: Always run
npx playwright installbefore tests. - Polluted state: Use
test.beforeEachfor resets, not globals.
Next Steps
- Official docs: Playwright Docs
- Advanced video: Testing Library + Playwright
- Check out our Learni courses on automated testing to master Cypress, Vitest, and more.