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@latestThis 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 installYour 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 testPlaywright 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.tsViewing Test Results
After a test run, Playwright generates an HTML report:
npx playwright show-reportThe 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.requestorrequestfixture to test backend endpoints - Component testing — use
@playwright/experimental-ct-reactto 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:
- Use semantic selectors (role, label, text) over CSS
- Let Playwright auto-wait instead of using
sleep() - Save auth state once; reuse it everywhere
- Group tests with
describeandbeforeEach - Run with
--uior--debugwhen diagnosing failures
Start with one test covering your most critical user flow. Add coverage incrementally from there.