Learn Playwright in 10 DaysDay 2: Test Structure Basics
books.chapter 2Learn Playwright in 10 Days

Day 2: Test Structure Basics

What You'll Learn Today

  • The test() function and test.describe() for grouping
  • Hooks: beforeAll, beforeEach, afterEach, afterAll
  • Basic assertions with expect()
  • Controlling tests with test.only(), test.skip(), test.fixme()
  • Annotations and tags
  • playwright.config.ts configuration
  • Running tests from the CLI
  • Test file naming conventions

Test File Naming Conventions

Playwright automatically discovers test files matching the following patterns:

Pattern Example
*.spec.ts login.spec.ts
*.spec.js login.spec.js
*.test.ts login.test.ts
*.test.js login.test.js

A recommended directory structure looks like this:

tests/
β”œβ”€β”€ auth/
β”‚   β”œβ”€β”€ login.spec.ts
β”‚   └── register.spec.ts
β”œβ”€β”€ dashboard/
β”‚   └── overview.spec.ts
└── settings/
    └── profile.spec.ts

The test() Function

Tests in Playwright are defined using the test() function. This is equivalent to it() in other frameworks like Jest or Mocha.

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

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

test() Arguments

The callback function receives fixtures as its argument. The most commonly used fixture is page.

test('using the page fixture', async ({ page }) => {
  // page represents a single browser tab
  await page.goto('https://example.com');
});

test('using multiple fixtures', async ({ page, context, browser }) => {
  // context: a browser context (like an incognito window)
  // browser: the browser instance itself
});

Grouping Tests with test.describe()

Use test.describe() to logically group related tests together.

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

test.describe('Login page', () => {

  test('can log in with valid credentials', async ({ page }) => {
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'user@example.com');
    await page.fill('[data-testid="password"]', 'password123');
    await page.click('[data-testid="submit"]');
    await expect(page).toHaveURL('/dashboard');
  });

  test('shows error with wrong password', async ({ page }) => {
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'user@example.com');
    await page.fill('[data-testid="password"]', 'wrong');
    await page.click('[data-testid="submit"]');
    await expect(page.locator('.error')).toBeVisible();
  });
});

Nesting Describe Blocks

test.describe('User management', () => {

  test.describe('Login', () => {
    test('can log in with email', async ({ page }) => {
      // ...
    });
  });

  test.describe('Registration', () => {
    test('can create a new user', async ({ page }) => {
      // ...
    });
  });
});

Hooks

Hooks let you run setup and teardown logic before and after tests.

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

test.describe('Todo app', () => {

  test.beforeAll(async () => {
    // Runs once before all tests in this suite
    // Example: initialize database
    console.log('Test suite starting');
  });

  test.beforeEach(async ({ page }) => {
    // Runs before each test
    await page.goto('https://demo.playwright.dev/todomvc');
  });

  test.afterEach(async ({ page }) => {
    // Runs after each test
    // Example: capture screenshot
  });

  test.afterAll(async () => {
    // Runs once after all tests in this suite
    // Example: clean up test data
    console.log('Test suite finished');
  });

  test('can add a todo', async ({ page }) => {
    await page.locator('.new-todo').fill('Buy milk');
    await page.locator('.new-todo').press('Enter');
    await expect(page.locator('.todo-list li')).toHaveCount(1);
  });
});

Hook Execution Order

flowchart TB
    subgraph Lifecycle["Test Lifecycle"]
        BA["beforeAll()\nRuns once"]
        BE1["beforeEach()\nRuns every time"]
        T1["test('Test 1')"]
        AE1["afterEach()\nRuns every time"]
        BE2["beforeEach()\nRuns every time"]
        T2["test('Test 2')"]
        AE2["afterEach()\nRuns every time"]
        AA["afterAll()\nRuns once"]
    end
    BA --> BE1 --> T1 --> AE1 --> BE2 --> T2 --> AE2 --> AA
    style BA fill:#8b5cf6,color:#fff
    style AA fill:#8b5cf6,color:#fff
    style BE1 fill:#3b82f6,color:#fff
    style BE2 fill:#3b82f6,color:#fff
    style AE1 fill:#f59e0b,color:#fff
    style AE2 fill:#f59e0b,color:#fff
    style T1 fill:#22c55e,color:#fff
    style T2 fill:#22c55e,color:#fff
Hook When It Runs Available Fixtures Common Use Cases
test.beforeAll Once before the suite None (or worker scope) DB setup, server startup
test.beforeEach Before each test page, context, etc. Page navigation, state reset
test.afterEach After each test page, context, etc. Screenshot capture
test.afterAll Once after the suite None (or worker scope) Cleanup

Note: The page fixture is not available in beforeAll and afterAll. Only worker-level fixtures (like browser) can be used there.


Basic Assertions with expect()

Playwright's expect() has built-in auto-retry. It waits until the condition is met or the timeout expires.

Page Assertions

// Title verification
await expect(page).toHaveTitle('My App');
await expect(page).toHaveTitle(/My App/);

// URL verification
await expect(page).toHaveURL('https://example.com/dashboard');
await expect(page).toHaveURL(/dashboard/);

Locator Assertions

const button = page.locator('[data-testid="submit"]');

// Visibility and state
await expect(button).toBeVisible();
await expect(button).toBeHidden();
await expect(button).toBeEnabled();
await expect(button).toBeDisabled();

// Text verification
await expect(button).toHaveText('Submit');
await expect(button).toContainText('Submit');

// Attribute and CSS verification
await expect(button).toHaveAttribute('type', 'submit');
await expect(button).toHaveClass(/primary/);
await expect(button).toHaveCSS('color', 'rgb(255, 0, 0)');

// Input value verification
await expect(page.locator('input')).toHaveValue('test@example.com');

// Element count verification
await expect(page.locator('.todo-item')).toHaveCount(3);

// Checkbox state verification
await expect(page.locator('input[type="checkbox"]')).toBeChecked();

Negating Assertions

Use not to negate any assertion.

await expect(button).not.toBeDisabled();
await expect(page.locator('.error')).not.toBeVisible();

Generic Assertions

You can also use expect() with plain values, without auto-retry.

const count = await page.locator('.item').count();
expect(count).toBe(5);
expect(count).toBeGreaterThan(0);

const text = await page.locator('h1').textContent();
expect(text).toContain('Welcome');

Key Assertions Reference

Assertion Target Description
toHaveTitle() Page Verify page title
toHaveURL() Page Verify URL
toBeVisible() Locator Element is visible
toBeHidden() Locator Element is hidden
toBeEnabled() Locator Element is enabled
toBeDisabled() Locator Element is disabled
toHaveText() Locator Exact text match
toContainText() Locator Partial text match
toHaveValue() Locator Input value match
toHaveCount() Locator Element count match
toHaveAttribute() Locator Attribute match
toBeChecked() Locator Checkbox is checked

Controlling Tests: only, skip, fixme

test.only() - Run a Specific Test

Use this during development to focus on a single test.

test.only('only this test runs', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page).toHaveTitle(/Example/);
});

test('this test is skipped', async ({ page }) => {
  // ...
});

test.skip() - Skip a Test

Temporarily disable a test.

test.skip('temporarily skipped test', async ({ page }) => {
  // This test will not run
});

// Conditional skip
test('does not run on Firefox', async ({ page, browserName }) => {
  test.skip(browserName === 'firefox', 'Not supported on Firefox');
  // ...
});

test.fixme() - Mark a Test for Future Fix

Mark tests that are known to be broken and need fixing.

test.fixme('enable after bug fix', async ({ page }) => {
  // This test will not run
  // Indicates that a fix is needed
});

When to Use Each

flowchart LR
    subgraph Only["test.only()"]
        O["Temporary focus\nduring development"]
    end
    subgraph Skip["test.skip()"]
        S["Environment-specific\nor temporary disable"]
    end
    subgraph Fixme["test.fixme()"]
        F["Known bug,\nfix planned"]
    end
    style Only fill:#3b82f6,color:#fff
    style Skip fill:#f59e0b,color:#fff
    style Fixme fill:#ef4444,color:#fff

Warning: Never commit test.only() to your repository. Use forbidOnly: true in CI to prevent this.


Annotations and Tags

Tagging Tests

Add tags to test names for filtering during execution.

test('can log in @smoke', async ({ page }) => {
  // Run with: --grep @smoke
});

test('can list all products @regression', async ({ page }) => {
  // Run with: --grep @regression
});

test('heavy processing test @slow', async ({ page }) => {
  // Run with: --grep @slow
});

test.describe.configure()

Configure the execution mode of a describe block.

test.describe('sequential tests', () => {
  test.describe.configure({ mode: 'serial' });

  test('step 1: create user', async ({ page }) => {
    // ...
  });

  test('step 2: log in as user', async ({ page }) => {
    // ...
  });
});
Mode Description
parallel Run tests in parallel (default)
serial Run tests sequentially. If one fails, the rest are skipped

test.slow()

Triples the timeout for a test.

test('loading large dataset', async ({ page }) => {
  test.slow();
  // Timeout is tripled
  await page.goto('/large-data');
  await expect(page.locator('.data-table')).toBeVisible();
});

playwright.config.ts Configuration

The behavior of Playwright is controlled by playwright.config.ts in your project root.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  // Directory containing test files
  testDir: './tests',

  // File matching pattern
  testMatch: '**/*.spec.ts',

  // Maximum time per test (milliseconds)
  timeout: 30000,

  // Timeout for expect() assertions
  expect: {
    timeout: 5000,
  },

  // Retry count on failure
  retries: process.env.CI ? 2 : 0,

  // Number of parallel workers
  workers: process.env.CI ? 1 : undefined,

  // Run all tests in parallel
  fullyParallel: true,

  // Fail CI if test.only() is present
  forbidOnly: !!process.env.CI,

  // Reporter configuration
  reporter: [
    ['html', { open: 'never' }],
    ['list'],
  ],

  // Shared settings for all projects
  use: {
    // Base URL for relative navigations
    baseURL: 'http://localhost:3000',

    // Trace recording (on first retry only)
    trace: 'on-first-retry',

    // Screenshot (on failure only)
    screenshot: 'only-on-failure',

    // Video recording
    video: 'retain-on-failure',
  },

  // Browser-specific project configurations
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],

  // Start local dev server before tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Key Configuration Areas

flowchart TB
    subgraph Config["playwright.config.ts"]
        subgraph TestExec["Test Execution"]
            TD["testDir\nTest directory"]
            TO["timeout\nTimeout"]
            RT["retries\nRetry count"]
            WK["workers\nParallelism"]
        end
        subgraph UseBlock["use (Shared Settings)"]
            BU["baseURL"]
            TR["trace"]
            SS["screenshot"]
        end
        subgraph Proj["projects (Browsers)"]
            CR["Chromium"]
            FF["Firefox"]
            WB["WebKit"]
        end
        subgraph Rep["reporter"]
            RP["html / list / json"]
        end
    end
    style TestExec fill:#3b82f6,color:#fff
    style UseBlock fill:#22c55e,color:#fff
    style Proj fill:#8b5cf6,color:#fff
    style Rep fill:#f59e0b,color:#fff

Running Tests from the CLI

Basic Commands

# Run all tests
npx playwright test

# Run a specific file
npx playwright test tests/login.spec.ts

# Run a specific directory
npx playwright test tests/auth/

# Filter by test name (regex)
npx playwright test --grep "login"

# Exclude specific tests
npx playwright test --grep-invert "slow"

Selecting Projects (Browsers)

# Chromium only
npx playwright test --project=chromium

# Multiple browsers
npx playwright test --project=chromium --project=firefox

Debugging and Development Options

# Show browser during execution (headed mode)
npx playwright test --headed

# Launch with debugger (step-by-step execution)
npx playwright test --debug

# Launch UI mode (visual test explorer)
npx playwright test --ui

# Re-run only previously failed tests
npx playwright test --last-failed

Reports and Output

# Open the HTML report
npx playwright show-report

# Specify reporter at runtime
npx playwright test --reporter=list
npx playwright test --reporter=dot
npx playwright test --reporter=json

CLI Options Reference

Option Description Example
--headed Show browser window npx playwright test --headed
--debug Launch debugger npx playwright test --debug
--ui Launch UI mode npx playwright test --ui
--project Select browser --project=chromium
--grep Filter by name --grep "login"
--grep-invert Exclude by name --grep-invert "slow"
--workers Set parallelism --workers=4
--retries Set retry count --retries=2
--reporter Set reporter --reporter=html
--last-failed Rerun failures --last-failed

Practical Example: Putting It All Together

Here is a complete test file that combines everything covered today.

// tests/todo.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Todo app', () => {

  test.beforeEach(async ({ page }) => {
    await page.goto('https://demo.playwright.dev/todomvc');
  });

  test.describe('Adding todos', () => {

    test('can add a new todo @smoke', async ({ page }) => {
      const input = page.locator('.new-todo');
      await input.fill('Buy milk');
      await input.press('Enter');

      const items = page.locator('.todo-list li');
      await expect(items).toHaveCount(1);
      await expect(items.first()).toHaveText('Buy milk');
    });

    test('can add multiple todos', async ({ page }) => {
      const input = page.locator('.new-todo');

      await input.fill('Buy milk');
      await input.press('Enter');
      await input.fill('Buy bread');
      await input.press('Enter');
      await input.fill('Buy eggs');
      await input.press('Enter');

      await expect(page.locator('.todo-list li')).toHaveCount(3);
    });
  });

  test.describe('Completing todos', () => {

    test.beforeEach(async ({ page }) => {
      // Add two todos before each test
      const input = page.locator('.new-todo');
      await input.fill('Buy milk');
      await input.press('Enter');
      await input.fill('Buy bread');
      await input.press('Enter');
    });

    test('can mark a todo as completed', async ({ page }) => {
      const firstTodo = page.locator('.todo-list li').first();
      await firstTodo.locator('.toggle').check();
      await expect(firstTodo).toHaveClass(/completed/);
    });

    test.skip('can delete a completed todo', async ({ page }) => {
      // TODO: implement delete test later
    });
  });
});

Summary

Concept Description
test() Defines a test case
test.describe() Groups related tests together
test.beforeEach Runs shared setup before each test
test.beforeAll Runs once before the entire suite
expect() Auto-retrying assertion function
test.only() Run only a specific test
test.skip() Temporarily skip a test
test.fixme() Mark a test as needing a fix
playwright.config.ts Central configuration file
npx playwright test Run tests from the CLI

Key Takeaways

  1. Use test.beforeEach to set up preconditions and maintain test isolation
  2. expect() auto-retries, naturally handling asynchronous UI changes
  3. The projects field in playwright.config.ts enables cross-browser testing in a single run
  4. Use forbidOnly: true in CI to prevent accidental test.only() commits
  5. Combine tags (@smoke, @regression) with --grep for flexible test execution

Practice Exercises

Exercise 1: Basic

Create a test file with the following requirements:

  • Use test.describe() to create a "Homepage" group
  • Use test.beforeEach to navigate to the root page (/)
  • Test 1: Verify the page title contains "Welcome"
  • Test 2: Verify the navigation bar is visible

Exercise 2: Intermediate

Create a playwright.config.ts with the following settings:

  • Test directory: ./e2e
  • Timeout: 60 seconds
  • Base URL: http://localhost:8080
  • Projects: Chromium and Firefox
  • Retries: 3 in CI environment
  • HTML reporter

Challenge Exercise

Create a test file with the following requirements:

  • Target the TodoMVC app (https://demo.playwright.dev/todomvc)
  • A @smoke tagged test: add one todo and verify it appears
  • A @regression tagged test: add 5 todos, complete 3, and verify the remaining count shows "2 items left"
  • Use test.describe.configure({ mode: 'serial' }) to guarantee execution order

Reference Links


Next Up: In Day 3, we'll explore Locators and DOM Interaction. You'll learn Playwright's locator strategies and how to build robust element selectors.