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
pagefixture is not available inbeforeAllandafterAll. Only worker-level fixtures (likebrowser) 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. UseforbidOnly: truein 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
- Use
test.beforeEachto set up preconditions and maintain test isolation expect()auto-retries, naturally handling asynchronous UI changes- The
projectsfield inplaywright.config.tsenables cross-browser testing in a single run - Use
forbidOnly: truein CI to prevent accidentaltest.only()commits - Combine tags (
@smoke,@regression) with--grepfor 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.beforeEachto 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
@smoketagged test: add one todo and verify it appears - A
@regressiontagged 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
- Playwright Docs - Writing Tests
- Playwright Docs - Test Configuration
- Playwright Docs - Annotations
- Playwright Docs - Command Line
- Playwright Docs - Assertions
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.