How to Write Playwright Tests: A Complete Beginner Guide

How to Write Playwright Tests: A Complete Beginner Guide

Playwright has quickly become the go-to tool for end-to-end browser testing. It's fast, reliable, and supports all major browsers out of the box. If you're new to Playwright, this guide walks you through everything you need to get your first tests running — from installation to writing meaningful assertions.

What Is Playwright?

Playwright is an open-source browser automation library developed by Microsoft. It lets you write tests that control a real browser — clicking buttons, filling forms, navigating pages — and assert that your app behaves correctly.

Key capabilities:

  • Tests run in Chromium, Firefox, and WebKit (Safari engine)
  • Supports JavaScript, TypeScript, Python, Java, and .NET
  • Built-in support for async/await
  • Auto-waiting — no sleep() calls needed
  • Parallel test execution out of the box

Installation

Start a new project or add Playwright to an existing one:

npm init playwright@latest

This scaffolds your project with a config file, an example test, and installs browser binaries. The interactive prompt asks which browsers to install and whether you want a GitHub Actions workflow.

For an existing project:

npm install --save-dev @playwright/test
npx playwright install

Your First Test

Create tests/homepage.spec.ts:

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

test('homepage has correct title', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page).toHaveTitle(/Example Domain/);
});

Run it:

npx playwright test

Playwright opens a browser, navigates to the URL, checks the title, and closes. If the assertion passes, you get a green checkmark.

Understanding the Page Object

Every Playwright test receives a page object. This is your handle to the browser tab. Common methods:

Method What it does
page.goto(url) Navigate to a URL
page.click(selector) Click an element
page.fill(selector, value) Type into an input
page.screenshot() Take a screenshot
page.waitForSelector(selector) Wait for element to appear

Selectors: Finding Elements

Playwright offers several ways to locate elements. The recommended approach is using semantic locators — they match how users interact with your app, making tests more resilient.

By role (recommended):

await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');

By label:

await page.getByLabel('Password').fill('secret123');

By text:

await page.getByText('Welcome back').waitFor();

By test ID:

// Add data-testid="submit-btn" to your HTML
await page.getByTestId('submit-btn').click();

CSS selectors (fallback):

await page.locator('.submit-button').click();
await page.locator('#email-input').fill('user@example.com');

Prefer role and label selectors over CSS whenever possible. They're less brittle when markup changes.

Writing Assertions

Playwright's expect API has built-in retry logic — it keeps checking the assertion until it passes or the timeout expires.

Page-level assertions:

await expect(page).toHaveTitle('My App');
await expect(page).toHaveURL(/\/dashboard/);

Element assertions:

const button = page.getByRole('button', { name: 'Save' });
await expect(button).toBeVisible();
await expect(button).toBeEnabled();
await expect(button).toHaveText('Save');

Text content:

const heading = page.getByRole('heading', { level: 1 });
await expect(heading).toContainText('Welcome');

Form values:

const input = page.getByLabel('Username');
await expect(input).toHaveValue('john_doe');

Handling Authentication

Most real-world tests require a logged-in user. Logging in before every test is slow. Playwright solves this with storageState — save auth cookies once and reuse them.

Save authentication state:

// auth.setup.ts
import { test as setup } from '@playwright/test';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Log in' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: '.auth/user.json' });
});

Reuse in tests:

// playwright.config.ts
export default {
  use: {
    storageState: '.auth/user.json',
  },
};

All tests now start authenticated, without running the login flow each time.

Handling Forms and User Flows

Here's a complete test for a signup form:

test('user can sign up', async ({ page }) => {
  await page.goto('/signup');

  await page.getByLabel('Full name').fill('Jane Smith');
  await page.getByLabel('Email').fill('jane@example.com');
  await page.getByLabel('Password').fill('Str0ngP@ss!');
  await page.getByLabel('Confirm password').fill('Str0ngP@ss!');
  await page.getByRole('button', { name: 'Create account' }).click();

  await expect(page).toHaveURL('/onboarding');
  await expect(page.getByText('Welcome, Jane!')).toBeVisible();
});

Notice there are no sleep() or waitFor() calls — Playwright auto-waits for elements to be ready before interacting.

Grouping Tests with describe

Group related tests and share setup with beforeEach:

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

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

  test('can update display name', async ({ page }) => {
    await page.getByLabel('Display name').fill('New Name');
    await page.getByRole('button', { name: 'Save changes' }).click();
    await expect(page.getByText('Changes saved')).toBeVisible();
  });

  test('can change email', async ({ page }) => {
    await page.getByLabel('Email').fill('new@example.com');
    await page.getByRole('button', { name: 'Save changes' }).click();
    await expect(page.getByText('Verification email sent')).toBeVisible();
  });
});

Running Tests

# Run all tests
npx playwright <span class="hljs-built_in">test

<span class="hljs-comment"># Run a specific file
npx playwright <span class="hljs-built_in">test tests/auth.spec.ts

<span class="hljs-comment"># Run with a specific browser
npx playwright <span class="hljs-built_in">test --project=firefox

<span class="hljs-comment"># Run in headed mode (watch the browser)
npx playwright <span class="hljs-built_in">test --headed

<span class="hljs-comment"># Run in UI mode (interactive debugger)
npx playwright <span class="hljs-built_in">test --ui

<span class="hljs-comment"># Debug a specific test
npx playwright <span class="hljs-built_in">test --debug tests/homepage.spec.ts

Viewing Test Results

After a test run, Playwright generates an HTML report:

npx playwright show-report

The report shows pass/fail status, screenshots on failure, traces you can step through, and video recordings if enabled.

Common Mistakes to Avoid

Don't use fixed waits:

// Bad
await page.waitForTimeout(3000);

// Good
await expect(page.getByText('Loading complete')).toBeVisible();

Don't chain selectors recklessly:

// Fragile
await page.locator('div > div > button:nth-child(2)').click();

// Resilient
await page.getByRole('button', { name: 'Delete account' }).click();

Don't ignore flaky tests: Flaky tests are almost always symptoms of race conditions or poor selectors. Fix them instead of skipping them.

What to Test Next

Once you're comfortable with the basics:

  • API testing — use page.request or request fixture to test backend endpoints
  • Component testing — use @playwright/experimental-ct-react to mount React/Vue components in isolation
  • Visual testing — use expect(page).toHaveScreenshot() for pixel-level comparisons
  • Performance — measure load times and Core Web Vitals

Pair Playwright With Continuous Monitoring

Writing tests is the first step. Running them continuously — on every deploy and on a schedule — is what catches regressions before users do.

HelpMeTest runs your Playwright tests 24/7, alerts you when something breaks, and gives your team a dashboard to track test history. Free plan supports up to 10 tests with 5-minute monitoring intervals.

Summary

Playwright is approachable for beginners and powerful enough for complex test suites. The key principles:

  1. Use semantic selectors (role, label, text) over CSS
  2. Let Playwright auto-wait instead of using sleep()
  3. Save auth state once; reuse it everywhere
  4. Group tests with describe and beforeEach
  5. Run with --ui or --debug when diagnosing failures

Start with one test covering your most critical user flow. Add coverage incrementally from there.

Read more