Playwright Tutorial: Complete Guide for Modern Web Automation
Playwright has become one of the strongest tools for modern web automation because it not only allows interacting with the browser, but also incorporates a test runner, assertions, test isolation, parallelization, and debugging tooling into the same stack. The official documentation presents it as an end-to-end testing framework for modern web applications, with support for Chromium, Firefox, and WebKit, local or CI execution, headless or headed mode, and even native mobile emulation.
In other words: with Playwright you don't just build "browser scripts," but a real foundation for maintainable E2E testing. And for most end-to-end testing projects, the documentation itself recommends using @playwright/test instead of the bare playwright library, because Playwright Test already brings the complete execution, reporting, and configuration experience.
In this guide you will learn how to install Playwright tutorial step by step, how to write robust tests, what types of locators to use, how to reuse authentication, how to combine UI and API testing, how to debug failures with Trace Viewer, and how to leave a suite ready for CI. The whole approach is oriented towards technical quality and reducing the classic problem of fragile or flaky tests.
Why Playwright stands out against other options
One of Playwright's most important points is that playwright locators are the center of auto-waiting and retry capability. This means the tool automatically waits for the element to be ready for interaction, instead of forcing you to fill your suite with manual pauses and unnecessary sleeps. Additionally, Playwright performs "actionability" checks before taking actions like click() or fill(): for example, it verifies that the element uniquely exists, is visible, stable, receives events, and is enabled.
This design reduces a large part of the typical fragility in web automation. Added to this are web-first assertions, which wait and retry until the expected state is met, rather than making premature checks. Playwright's official documentation insists precisely on this philosophy: actions on the UI, more declarative expectations, and less manual synchronization.
When to use Playwright
Playwright fits especially well when you need a modern E2E suite with good multi-browser support, visual debugging, CI integration, and a solid experience in playwright typescript or JavaScript. It's also highly useful when you want to combine interface tests with API data preparation or network mocking without leaving the same ecosystem.
If you are looking for a modern stack that is fast to adopt and has less friction than a purely WebDriver approach, Playwright is usually an excellent choice. This assessment is a practical inference based on Playwright packaging runner, assertions, isolation, tooling, and navigation support into a single solution.
Step-by-step Playwright installation
The fastest way to get started with Playwright is to use the official initializer:
npm init playwright@latest
That flow creates the basic project structure, a sample test, and the necessary configuration to start running tests.
npx playwright test
npx playwright show-report
Recommended minimum project structure
A simple and maintainable structure might look like this:
playwright-project/
├─ tests/
│ ├─ auth.setup.ts
│ ├─ home.spec.ts
│ ├─ tasks.spec.ts
├─ page-objects/
│ ├─ LoginPage.ts
│ ├─ DashboardPage.ts
├─ playwright.config.ts
├─ package.json
└─ tsconfig.json
Recommended initial configuration
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: [['html'], ['list']],
use: {
baseURL: 'https://tu-app.com',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
headless: true,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
Your first Playwright test
import { test, expect } from '@playwright/test';
test('homepage shows main heading', async ({ page }) => {
await page.goto('https://example.com');
await expect(page.getByRole('heading')).toBeVisible();
});
Locators: the right way to select elements
Bad approach
await page.click('.btn-primary');
await page.fill('#email-input', 'user@example.com');
await page.click('div.card:nth-child(3) button');
Better approach
await page.getByLabel('Email').fill('user@example.com');
await page.getByRole('button', { name: 'Log in' }).click();
await page.getByTestId('save-task').click();
Web-first assertions: Less flakiness, more stability
In Playwright, the correct way is to lean on assertions that wait until the desired state materializes:
await expect(page.getByRole('status')).toHaveText(/saved/i);
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByTestId('task-item')).toHaveCount(3);
Why you shouldn't use waitForTimeout() except in rare cases
A better written test would look like this:
import { test, expect } from '@playwright/test';
test('user creates a task', async ({ page }) => {
await page.goto('/tasks');
await page.getByRole('button', { name: 'New task' }).click();
await page.getByLabel('Title').fill('Write better Playwright article');
await page.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByRole('listitem').filter({ hasText: 'Write better Playwright article' })
).toBeVisible();
await expect(page.getByRole('status')).toHaveText(/saved/i);
});
Reusable authentication with storageState
Logging in at the beginning of each test is usually a bad idea. The current official recommendation is to create a setup project for playwright auth.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'https://tu-app.com',
trace: 'on-first-retry',
},
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
Authentication test
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD || '');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page).toHaveURL(/dashboard/);
await page.context().storageState({ path: authFile });
});
Fixtures and smart reuse
import { test as base, expect } from '@playwright/test';
type AppFixtures = {
openDashboard: () => Promise;
};
export const test = base.extend({
openDashboard: async ({ page }, use) => {
await use(async () => {
await page.goto('/dashboard');
await expect(
page.getByRole('heading', { name: /dashboard/i })
).toBeVisible();
});
},
});
export { expect };
And then in your spec:
import { test, expect } from './fixtures/app';
test('dashboard displays user menu', async ({ page, openDashboard }) => {
await openDashboard();
await expect(page.getByRole('button', { name: /profile/i })).toBeVisible();
});
When to use Page Object Model
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
constructor(page: Page) {
this.page = page;
this.email = page.getByLabel('Email');
this.password = page.getByLabel('Password');
this.submit = page.getByRole('button', { name: 'Log in' });
}
async login(email: string, password: string) {
await this.email.fill(email);
await this.password.fill(password);
await this.submit.click();
await expect(this.page).toHaveURL(/dashboard/);
}
}
API testing within the same stack
import { test, expect } from '@playwright/test';
test('creates item by API and validates it in UI', async ({ page }) => {
const response = await page.request.post('/api/tasks', {
data: { title: 'Task created by API' },
});
await expect(response).toBeOK();
await page.goto('/tasks');
await expect(page.getByText('Task created by API')).toBeVisible();
});
Network mocking to reduce external dependencies
import { test, expect } from '@playwright/test';
test('shows mocked user profile', async ({ page }) => {
await page.route('**/api/profile', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: 'Néstor',
role: 'Admin',
}),
});
});
await page.goto('/profile');
await expect(page.getByText('Néstor')).toBeVisible();
await expect(page.getByText('Admin')).toBeVisible();
});
Real debugging: UI Mode, reports, and Trace Viewer
npx playwright test --ui
npx playwright show-report
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
}
CI with GitHub Actions
For playwright ci, GitHub Actions is excellent.
name: Playwright Tests
on:
push:
branches: [main, master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
Accessibility in Playwright
import { test, expect } from '@playwright/test';
test('login form is accessible at basic interaction level', async ({ page }) => {
await page.goto('/login');
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: 'Log in' })).toBeEnabled();
});
Playwright vs Selenium vs Cypress
Playwright vs Selenium: Selenium remains a historical reference in automation. WebDriver drives the browser natively. Playwright generally stands out when you are looking for a combination of an integrated runner + multi-browser + powerful tooling in a single stack.
Playwright vs Cypress: Cypress focuses on testing apps that run inside the browser and offers its own app. Playwright uses isolated browser contexts and does not inject code into the same app window.
Best practices for a maintainable Playwright suite
- Use @playwright/test for E2E
- Prioritize getByRole, getByLabel, getByText, and getByTestId
- Avoid waitForTimeout() except in rare cases
- Reuse authentication with storageState
- Use fixtures and, when appropriate, Page Object Model
- Enable trace, screenshots, and video only when they provide value
- Combine UI testing with API testing and mocking
Conclusion
Playwright does not stand out just because it automates browsers. It stands out because it offers a modern way to build a complete E2E suite: integrated runner, robust locators, retrying assertions, context isolation, reusable authentication, mocking, API testing, visual debugging, reporting, and CI execution. All this aligns with the official framework's philosophy and explains why it has become one of the most solid choices for modern web testing.