← Blog

"Playwright Series #2: Your First Playwright Test — Browsers, Pages and Navigation"

Write your first real Playwright test from scratch. Understand the anatomy of a test file, how browsers and pages work, and why Playwright's auto-waiting makes tests so reliable.

reading now
views
comments

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 function
  • expect — 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:none or visibility: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.

0 / 1000