Anatomy of a Playwright Test
You wrote your first test. But do you know what's happening inside the file? Like a surgeon studying the human body, understanding every organ of a test file is what separates someone who copies code from someone who architects test suites.
📖 The Story: The Spaghetti Test Suite
Once upon a software release, there was a QA engineer named Sam. Sam was fast—the fastest script writer on the team. He could automate 20 test cases in a day. But Sam had a dark secret: his test files were chaos.
Every test file was a single 300-line block with no grouping, no shared setup, and 15 tests all depending on the same login step. When the login step broke, all 15 tests failed with the same error. When Sam went on vacation, no one could understand which tests checked the shopping cart and which checked the checkout flow. The tech lead called it "spaghetti automation"—everything tangled together, nothing isolated.
The team brought in a senior SDET, Priya, who refactored Sam's files using test.describe to group related tests, beforeEach to handle login for each test independently, and clear naming conventions. Suddenly, when one test failed, only that test failed. The test reports became readable. New team members could understand the suite in minutes, not days.
The lesson? How you structure your test file determines whether your suite scales to 500 tests or collapses at 50. Let's learn that structure now.
🎯 Why Anatomy Matters
Organize Large Suites
Group hundreds of tests into logical sections that any engineer can navigate instantly.
Eliminate Duplication
Use hooks to share setup and teardown code across tests—no more copy-pasting login steps.
Isolate Failures
Proper structure ensures one broken test doesn't cascade into 20 false failures.
🌍 Real-World Example: Enterprise Test File
// 1. IMPORTS import { test, expect } from '@playwright/test'; // 2. DESCRIBE BLOCK — Groups related tests test.describe('Login Page', () => { // 3. HOOKS — Shared setup/teardown test.beforeEach(async ({ page }) => { await page.goto('/login'); }); // 4. INDIVIDUAL TESTS test('displays login form', async ({ page }) => { await expect(page.getByLabel('Email')).toBeVisible(); await expect(page.getByLabel('Password')).toBeVisible(); }); test('shows error for invalid credentials', async ({ page }) => { await page.getByLabel('Email').fill('wrong@test.com'); await page.getByLabel('Password').fill('badpassword'); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page.getByText('Invalid credentials')).toBeVisible(); }); test('navigates to dashboard on success', async ({ page }) => { await page.getByLabel('Email').fill('admin@test.com'); await page.getByLabel('Password').fill('password123'); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page).toHaveURL(/dashboard/); }); });
Structure breakdown: Imports at the top → One test.describe wrapping all login tests → A single beforeEach hook navigating to /login before every test → Three independent tests. If the "invalid credentials" test fails, the "navigates to dashboard" test still passes because they each get a fresh page.
🧒 Explain Like I'm 10
Think of a test file like a school building:
- Imports = The delivery truck bringing supplies (tools like
testandexpect). - test.describe = A classroom. All the tests about "Login" sit in the "Login Classroom".
- beforeEach = The bell that rings before every class, reminding everyone to open their books to the same page. It runs before EVERY test in the classroom.
- test() = An individual exam. Each student (test) takes the exam independently. If one student fails, the others keep going.
- afterEach = The cleanup crew that erases the whiteboard after each exam, so the next student starts fresh.
🎓 Professional Explanation
A Playwright test file follows a hierarchical structure. At the top level, you import the test API. Below that, you organize tests using test.describe() blocks, which can be nested. Hooks (beforeEach, afterEach, beforeAll, afterAll) execute at defined points in the test lifecycle. Each test() function is an isolated execution unit that receives fixture parameters via destructuring.
The critical architectural principle is fixture-based dependency injection. Unlike Selenium where you manually construct and manage WebDriver instances, Playwright injects a fresh page object into each test. This means no test can accidentally pollute another test's browser state, even if they run in the same file.
🫀 The Complete File Anatomy
import { test, expect } from '@playwright/test';test.describe('Feature Name', () => { ... });test.beforeEach(async ({ page }) => { ... });test('should do something', async ({ page }) => { ... });test.skip('broken feature'); test.only('focus this');await expect(page).toHaveTitle(/Home/);🏢 test.describe: Grouping Tests
import { test, expect } from '@playwright/test'; // Top-level describe test.describe('Shopping Cart', () => { // Nested describe for even more specific grouping test.describe('Adding Items', () => { test('can add a single item', async ({ page }) => { // ... test code }); test('can add multiple items', async ({ page }) => { // ... test code }); }); test.describe('Removing Items', () => { test('can remove an item', async ({ page }) => { // ... test code }); }); });
In your test report, this produces: "Shopping Cart > Adding Items > can add a single item". This hierarchy makes reports scannable at a glance—critical when your suite has 500+ tests.
🪝 Hooks: Setup and Teardown
| Hook | When It Runs | Scope | Use Case |
|---|---|---|---|
test.beforeAll |
Once before all tests in the group | Worker process | Database seeding, starting a test server |
test.beforeEach |
Before every single test | Test isolation | Navigating to a page, resetting form state |
test.afterEach |
After every single test | Cleanup | Clearing cookies, logging test completion |
test.afterAll |
Once after all tests in the group | Worker process | Dropping test data, stopping the test server |
In Playwright, beforeEach is strongly recommended over beforeAll for browser interactions. Why? Because beforeAll runs in the worker scope, not the test scope. The { page } fixture is NOT available in beforeAll—you'd have to manually create a browser context, which defeats Playwright's isolation model. Use beforeEach for all page-level setup. Reserve beforeAll for non-browser setup like API calls or database operations.
import { test, expect } from '@playwright/test'; test.describe('User Profile', () => { // Runs before EVERY test — fresh navigation each time test.beforeEach(async ({ page }) => { await page.goto('/profile'); }); test('displays user name', async ({ page }) => { await expect(page.getByText('John Doe')).toBeVisible(); }); test('can update email', async ({ page }) => { await page.getByLabel('Email').fill('new@email.com'); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Email updated')).toBeVisible(); }); // Runs after EVERY test — optional cleanup test.afterEach(async ({ page }) => { await page.evaluate(() => localStorage.clear()); }); });
Key insight: Both tests start with a fresh page at /profile because beforeEach runs before each one. The email change in the second test does NOT affect the first test—Playwright gives each test a clean browser page.
🔄 Test Execution Lifecycle
test.beforeAll(async () => { ... }){ page } fixture.test.beforeEach(async ({ page }) => { ... }){ page } fixture. This is where page navigation and per-test setup belong.test('does something', async ({ page }) => { ... })test.afterEach(async ({ page }) => { ... })test.afterAll(async () => { ... })🏷️ Test Annotations
| Annotation | Effect | When to Use |
|---|---|---|
test.skip() |
Test is skipped entirely | Feature is broken, known bug, not yet implemented |
test.only() |
Only this test runs; all others are skipped | Debugging a single test locally (never commit only!) |
test.fixme() |
Test is skipped and marked as needing repair | Test is broken but you don't have time to fix it now |
test.slow() |
Triples the test timeout | Test involves large file uploads, long API calls, or heavy computation |
test.fail() |
Expect the test to fail; pass if it fails | Validating that a bug exists (regression marker) |
// Skip unconditionally test.skip('payment gateway is down', async ({ page }) => { // This test will not run }); // Skip conditionally (skip only on WebKit) test('CSS grid layout', async ({ page, browserName }) => { test.skip(browserName === 'webkit', 'CSS Grid not supported in WebKit'); // ... test code }); // Mark as slow (triples the default 30s timeout to 90s) test.slow('large file upload', async ({ page }) => { // ... long-running test }); // Expect this test to fail (will PASS if it fails) test.fail('login with expired token', async ({ page }) => { // This should fail — and if it does, the test passes });
Anti-pattern warning: Never commit test.only() to version control. It silently skips all other tests in the entire suite, which means CI runs only one test while appearing to pass. Use -g flag (npx playwright test -g "test name") instead for local filtering.
⚠️ Common Mistakes
Mistake 1: Using beforeAll for Page Interactions
// ❌ 'page' fixture is NOT available in beforeAll test.beforeAll(async ({ page }) => { await page.goto('/login'); await page.fill('#email', 'admin@test.com'); });
What goes wrong: beforeAll runs in the worker scope, where the page fixture doesn't exist. Playwright will throw an error. Fix: Move page interactions to beforeEach, or use test.use({ storageState: 'auth.json' }) for authentication (covered in Lesson 1.5).
Mistake 2: Sharing State Between Tests
test('step 1: login', async ({ page }) => { await page.goto('/login'); await page.fill('#email', 'admin@test.com'); await page.click('#submit'); }); test('step 2: view dashboard', async ({ page }) => { // ❌ This gets a FRESH page — login state from step 1 is gone! await page.goto('/dashboard'); });
What goes wrong: Each test gets a brand-new browser page. The login from "step 1" does NOT carry over to "step 2". Fix: Each test should be independent. Use test.use({ storageState: 'auth.json' }) to inject login state.
Mistake 3: Deeply Nested Describes
test.describe('App', () => { test.describe('User', () => { test.describe('Settings', () => { test.describe('Profile', () => { test.describe('Avatar', () => { // 5 levels deep — too much nesting! }); }); }); }); });
Fix: Limit nesting to 2-3 levels max. Use separate files instead: user-settings-profile.spec.ts is cleaner than 5 nested describes.
✅ Best Practices
- One describe per feature. Keep groups focused:
test.describe('Login Page'), nottest.describe('All Tests'). - Use beforeEach, not beforeAll, for page setup. This ensures test isolation and gives you access to the
pagefixture. - Keep tests independent. No test should depend on another test's side effects.
- Limit describe nesting to 2-3 levels. Split into separate files for deeper organization.
- Never commit
test.only(). Use the-gflag for local filtering. - Name tests by expected behavior.
'shows error for invalid email'>'error test'. - Use afterEach for targeted cleanup only. Playwright's fixtures handle most cleanup automatically.
🏗️ Senior Engineer Deep Dive
Fixture Scoping and Worker Processes
- Worker scope:
beforeAll/afterAllrun once per worker process. If you have 4 workers and 20 tests,beforeAllruns 4 times (once per worker). State persists across tests in the same worker—but not across workers. - Test scope:
beforeEach/afterEachand the{ page }fixture run per test. Each test gets a fresh page, fresh context. Total isolation.
Serial Mode: When You Must Share State
test.describe.configure({ mode: 'serial' }); test('step 1: create item', async ({ page }) => { // Runs first }); test('step 2: edit item', async ({ page }) => { // Runs second, but NOTE: gets a FRESH page! // Serial mode only guarantees ORDER, not shared state });
Critical misconception: Serial mode guarantees execution order, NOT shared browser state. Each test still gets a fresh { page }. If you need to share browser state between tests, you must manage it manually.
Enterprise Pattern: Custom Fixtures over Hooks
import { test as base } from '@playwright/test'; type MyFixtures = { loggedInPage: Page; }; export const test = base.extend<MyFixtures>({ loggedInPage: async ({ page }, use) => { await page.goto('/login'); await page.fill('#email', 'admin@test.com'); await page.click('#submit'); await use(page); }, });
Why this is superior to hooks: Custom fixtures are reusable across files (just import the extended test), composable (fixtures can depend on other fixtures), and self-contained (setup + teardown in one place).
💼 Interview Questions
test.describe in Playwright?Show answer
test.describe groups related tests together into a named suite. It improves test report readability, allows you to share hooks across multiple tests, and enables logical organization of large test suites.{ page } fixture inside test.beforeAll?Show answer
beforeAll runs in the worker scope, which is shared across multiple tests. The page fixture belongs to the test scope—each test gets its own isolated page. Providing a single page in beforeAll would break isolation. For page-level setup, always use beforeEach.test.describe blocks?Show answer
beforeEach hooks execute from outermost to innermost. afterEach hooks execute from innermost to outermost. This mirrors the construction/destruction pattern—build from the foundation up, tear down from the top down.test.describe.configure({ mode: 'serial' }) share browser state between tests?Show answer
{ page } fixture with a clean browser context. If a test fails, subsequent tests in the serial group are skipped. To share browser state, you must use storageState or manually manage a shared browserContext.Show answer
test.fixme() for the 5 failing tests—skips them and marks them as needing repair. Use test.slow() for the 2 long-running tests—triples their timeout. Never use test.skip() for known bugs (doesn't convey intent) and never increase the global timeout (masks slow tests).🏋️ Practical Exercises
Exercise: Structure a Test File with Describe and Hooks
- Create a new file called
exercise-anatomy.spec.ts. - Create a
test.describe('Navigation')block. - Add a
beforeEachhook that navigates tohttps://playwright.dev. - Write two tests: one that checks the page title, and one that checks the "Get started" link is visible.
- Run the tests and verify both pass. Notice how
beforeEachhandles navigation for both.
Challenge: Annotations and Nested Describes
- Create a test file with a top-level
test.describe('E2E Flow'). - Inside it, create two nested describes:
'Positive Cases'and'Negative Cases'. - In Positive Cases, write 2 passing tests on the TodoMVC demo app.
- In Negative Cases, write 1 test marked with
test.fixme(). - Add one test marked with
test.slow()that adds multiple todo items.
🧩 Mini Project: Structured Test Suite
🎯 Project: Build a Multi-Feature Test Suite
- Create the file structure:
tests/todomvc/add-todo.spec.ts,edit-todo.spec.ts,filter-todo.spec.ts - In each file: Use
test.describe+beforeEachto navigate to the TodoMVC app. - Write at least 2 tests per file (6+ total).
- Annotate: Mark one test with
test.slow()and one withtest.fixme(). - Run:
npx playwright test todomvc/
🧠 Quiz: Test Your Knowledge
test.describe() do?test.beforeAlltest.beforeEachtest.setuptest.init{ page } NOT available in test.beforeAll?beforeAll runs in worker scope where test-scoped fixtures don't existtest.skip() do?test.only()?beforeEach hooks execute in nested describes?test.slow() do?test.describe.configure({ mode: 'serial' }) guarantee?test.describe?beforeEach hooks in enterprise suites?❓ Frequently Asked Questions
test.describe block?Show answer
beforeEach across multiple tests, you need a describe block. Most teams use describe blocks for all tests for consistency.beforeEach hook fails?Show answer
beforeEach fails, the test is marked as failed and does NOT execute. The afterEach hook still runs for cleanup. Subsequent tests in the describe block are NOT affected—they each get their own beforeEach execution.test.skip inside a test body?Show answer
test.skip() conditionally inside a test. Useful for runtime conditions like environment variables, browser type, or feature flags.Show answer
test.use({ storageState: 'auth.json' }) at the describe block level. This injects authentication cookies and localStorage into every test's fresh browser context. Covered in detail in Lesson 1.5.📌 Summary
🎯 Key Takeaways
- test.describe groups related tests; use it for every feature.
- beforeEach is for page-level setup. beforeAll is for non-browser setup only.
- { page } is a test-scoped fixture—NOT available in
beforeAll, does NOT carry state between tests. - Hooks execute: beforeAll → [beforeEach → test → afterEach] → afterAll.
- Annotations (
skip,only,fixme,slow,fail) control test behavior without modifying logic. - Never commit test.only()—use
-gflag instead. - Limit nesting to 2-3 levels; split into separate files beyond that.
- Custom fixtures are the enterprise-grade alternative to hooks—reusable, composable, type-safe.
🔗 Related Articles
1.3 Your First Test
Write and run your first Playwright test with assertions and the test runner.
1.5 Playwright Config File
Configure browsers, timeouts, parallelism, and authentication.
2.1 CSS Selectors & Locators
Master Playwright's locator strategies for finding elements reliably.
3.1 Navigation
Deep dive into page.goto(), waiting strategies, and URL handling.
