Series Navigation
← Part 1: What Is Playwright and Why It Beats Everything Else
→ Part 3: Locators — The Right Way to Find Elements
The Anatomy of a Playwright Test File
Before writing tests, understand the three things every Playwright test file imports:
import { test, expect } from '@playwright/test';
That's it. Two imports. Everything you need.
test— defines a test case and its async functionexpect— makes assertions about what you see on the page
The page object comes as a parameter inside each test — Playwright creates a fresh browser page for every test automatically.
Your First Real Test
Create tests/homepage.spec.ts:
import { test, expect } from '@playwright/test';
test('homepage loads and shows the correct title', async ({ page }) => {
// Navigate to a URL
await page.goto('https://playwright.dev');
// Assert the page title
await expect(page).toHaveTitle(/Playwright/);
});
Run it:
npx playwright test tests/homepage.spec.ts --headed
--headed shows the browser so you can watch it happen. You'll see a browser window open, navigate to playwright.dev, and the test will pass.
Understanding async/await
Every Playwright action returns a Promise — this is how JavaScript handles things that take time (like network requests). The await keyword pauses execution until the action completes.
// Without await — WRONG, test will fail unpredictably
page.goto('https://example.com');
page.click('button'); // might fire before navigation completes
// With await — CORRECT
await page.goto('https://example.com');
await page.click('button'); // waits for navigation first
Always await Playwright actions. This is the most common mistake beginners make.
The Browser, Context and Page Model
Playwright has three layers:
Browser
└── BrowserContext (like an incognito window — isolated cookies/storage)
└── Page (a single tab)
When you use { page } in a test, Playwright has already created a fresh browser context and page for you. Each test gets its own isolated context — no cookie or session leakage between tests.
You can also create them manually when you need more control:
test('two users simultaneously', async ({ browser }) => {
// Create two isolated sessions
const userA = await browser.newContext();
const userB = await browser.newContext();
const pageA = await userA.newPage();
const pageB = await userB.newPage();
await pageA.goto('https://example.com');
await pageB.goto('https://example.com');
// Both pages run independently with separate cookies/storage
await userA.close();
await userB.close();
});
Navigation
// Navigate to an absolute URL
await page.goto('https://example.com');
// Navigate to a relative URL (uses baseURL from playwright.config.ts)
await page.goto('/login');
// Navigate and wait for network to be idle
await page.goto('/dashboard', { waitUntil: 'networkidle' });
// Go back and forward
await page.goBack();
await page.goForward();
// Reload the page
await page.reload();
The waitUntil option controls when goto resolves:
| Value | Meaning |
|---|---|
commit |
Navigation started (fastest) |
domcontentloaded |
HTML parsed, DOM ready |
load |
All resources loaded (default) |
networkidle |
No network activity for 500ms (slowest) |
In most cases the default load is what you want.
Interacting With Elements
// Click an element
await page.click('button');
// Type into an input
await page.fill('#email', 'user@example.com');
// Press a key
await page.press('#search', 'Enter');
// Select a dropdown option
await page.selectOption('select#country', 'US');
// Check/uncheck a checkbox
await page.check('#remember-me');
await page.uncheck('#remember-me');
// Upload a file
await page.setInputFiles('input[type=file]', 'path/to/file.pdf');
// Hover over an element (for tooltips/dropdowns)
await page.hover('.user-menu');
// Right-click
await page.click('.item', { button: 'right' });
// Double-click
await page.dblclick('.cell');
Getting Values From the Page
// Get text content
const heading = await page.textContent('h1');
console.log(heading); // "Welcome back, Srikanth"
// Get an input's value
const email = await page.inputValue('#email');
// Get an attribute
const href = await page.getAttribute('a.logo', 'href');
// Get inner HTML
const html = await page.innerHTML('.menu');
// Check if element is visible
const isVisible = await page.isVisible('.banner');
// Count elements
const count = await page.locator('.product-card').count();
Waiting — The Key to Reliable Tests
This is where Playwright beats every other framework. Playwright auto-waits before every action.
When you write:
await page.click('#submit');
Playwright internally waits for the button to be:
- Attached to the DOM
- Visible (not
display:noneorvisibility:hidden) - Enabled (not
disabled) - Stable (not moving from an animation)
- Receives pointer events
Only then does it click. This eliminates the majority of flaky tests.
You can also wait explicitly when needed:
// Wait for a specific element to appear
await page.waitForSelector('.success-message');
// Wait for navigation to complete
await page.waitForURL('/dashboard');
// Wait for a network request
await page.waitForResponse('**/api/user');
// Wait for a condition (custom logic)
await page.waitForFunction(() => document.title.includes('Ready'));
// Wait a fixed time (use sparingly — usually a sign of a design problem)
await page.waitForTimeout(500);
Grouping Tests with test.describe
Group related tests together:
import { test, expect } from '@playwright/test';
test.describe('Login page', () => {
test('shows login form', async ({ page }) => {
await page.goto('/login');
await expect(page.locator('form#login')).toBeVisible();
});
test('shows error for wrong password', async ({ page }) => {
await page.goto('/login');
await page.fill('#email', 'user@example.com');
await page.fill('#password', 'wrongpassword');
await page.click('button[type=submit]');
await expect(page.locator('.error-message')).toContainText('Invalid credentials');
});
test('redirects to dashboard after successful login', async ({ page }) => {
await page.goto('/login');
await page.fill('#email', 'user@example.com');
await page.fill('#password', 'correctpassword');
await page.click('button[type=submit]');
await expect(page).toHaveURL('/dashboard');
});
});
Setup and Teardown with Hooks
Run code before/after tests:
test.describe('Shopping cart', () => {
// Runs once before all tests in this describe block
test.beforeAll(async ({ browser }) => {
// e.g. seed the database
});
// Runs before each individual test
test.beforeEach(async ({ page }) => {
await page.goto('/shop');
});
// Runs after each individual test
test.afterEach(async ({ page }) => {
// cleanup if needed
});
// Runs once after all tests
test.afterAll(async () => {
// e.g. clean up database
});
test('adds item to cart', async ({ page }) => {
// page is already at /shop due to beforeEach
await page.click('.product:first-child .add-to-cart');
await expect(page.locator('.cart-count')).toHaveText('1');
});
});
Skipping and Focusing Tests
// Skip a test
test.skip('broken feature', async ({ page }) => { ... });
// Skip conditionally
test('mobile only feature', async ({ page, isMobile }) => {
test.skip(!isMobile, 'Only runs on mobile');
// ...
});
// Run ONLY this test (useful during development — don't commit this)
test.only('the thing I am debugging right now', async ({ page }) => { ... });
// Mark as expected to fail
test.fail('known bug in production', async ({ page }) => { ... });
A Complete Working Example
Let's write a real test against a real site — the Playwright documentation itself:
import { test, expect } from '@playwright/test';
test.describe('Playwright documentation site', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://playwright.dev');
});
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(/Playwright/);
});
test('navigation links are visible', async ({ page }) => {
await expect(page.getByRole('link', { name: 'Docs' })).toBeVisible();
await expect(page.getByRole('link', { name: 'API' })).toBeVisible();
});
test('search works', async ({ page }) => {
await page.getByRole('button', { name: 'Search' }).click();
await page.getByPlaceholder('Search docs').fill('locator');
await expect(page.locator('.search-results')).toBeVisible();
});
test('navigates to getting started', async ({ page }) => {
await page.getByRole('link', { name: 'Docs' }).click();
await expect(page).toHaveURL(/docs\/intro/);
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
});
Run it:
npx playwright test tests/playwright-docs.spec.ts --project=chromium --headed
Reading Test Results
Running 4 tests using 1 worker
✓ has correct title (834ms)
✓ navigation links are visible (612ms)
✓ search works (1.1s)
✓ navigates to getting started (943ms)
4 passed (4.2s)
On failure you get a clear message:
✗ search works
Error: Locator '.search-results' expected to be visible
Received element is not visible
at tests/playwright-docs.spec.ts:22:48
What's Next
In Part 3 we go deep on locators — the single most important concept in Playwright. The difference between a brittle test that breaks on every UI change and a resilient test that survives refactors comes down entirely to how you find elements. We'll cover getByRole, getByText, getByLabel, getByTestId, and when to use each one.
Discussion
Loading...Leave a Comment
All comments are reviewed before appearing. No links please.