📖 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

typescript · login.spec.ts
// 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:

  1. Imports = The delivery truck bringing supplies (tools like test and expect).
  2. test.describe = A classroom. All the tests about "Login" sit in the "Login Classroom".
  3. 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.
  4. test() = An individual exam. Each student (test) takes the exam independently. If one student fails, the others keep going.
  5. 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

1. Imports Required dependencies
import { test, expect } from '@playwright/test';
2. Describe Logical grouping
test.describe('Feature Name', () => { ... });
3. Hooks Setup & teardown
test.beforeEach(async ({ page }) => { ... });
4. Tests Individual test cases
test('should do something', async ({ page }) => { ... });
5. Annotations Skip, only, fixme, slow
test.skip('broken feature'); test.only('focus this');
6. Assertions Pass/fail verdicts
await expect(page).toHaveTitle(/Home/);

🏢 test.describe: Grouping Tests

typescript · grouping.spec.ts
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
⚠️ Critical: beforeEach vs beforeAll

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.

typescript · hooks-example.spec.ts
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

1 beforeAll
test.beforeAll(async () => { ... })
Runs once before all tests in the describe block. Shared across tests in the same worker. No access to { page } fixture.
2 beforeEach
test.beforeEach(async ({ page }) => { ... })
Runs before EACH test. Has access to the fresh { page } fixture. This is where page navigation and per-test setup belong.
3 Test Execution
test('does something', async ({ page }) => { ... })
The actual test runs. Assertions are evaluated. If any assertion fails, the test is marked as failed immediately.
4 afterEach
test.afterEach(async ({ page }) => { ... })
Runs after EACH test, even if the test failed. Perfect for cleanup, logging, or capturing additional screenshots on failure.
5 afterAll
test.afterAll(async () => { ... })
Runs once after all tests in the describe block complete. Used for tearing down shared resources like test databases.

🏷️ 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)
typescript · annotations.spec.ts
// 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

typescript · ❌ Wrong
// ❌ '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

typescript · ❌ Fragile
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

typescript · ❌ Hard to read
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

  1. One describe per feature. Keep groups focused: test.describe('Login Page'), not test.describe('All Tests').
  2. Use beforeEach, not beforeAll, for page setup. This ensures test isolation and gives you access to the page fixture.
  3. Keep tests independent. No test should depend on another test's side effects.
  4. Limit describe nesting to 2-3 levels. Split into separate files for deeper organization.
  5. Never commit test.only(). Use the -g flag for local filtering.
  6. Name tests by expected behavior. 'shows error for invalid email' > 'error test'.
  7. 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/afterAll run once per worker process. If you have 4 workers and 20 tests, beforeAll runs 4 times (once per worker). State persists across tests in the same worker—but not across workers.
  • Test scope: beforeEach/afterEach and the { page } fixture run per test. Each test gets a fresh page, fresh context. Total isolation.

Serial Mode: When You Must Share State

typescript
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

typescript · custom-fixtures.ts
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

Beginner
What is the purpose of 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.
Intermediate
Why can't you access the { 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.
Intermediate
In what order do hooks execute when you have nested 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.
Advanced
Does test.describe.configure({ mode: 'serial' }) share browser state between tests?
Show answer
No. Serial mode only guarantees execution order. Each test still receives a fresh { 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.
Scenario-Based
You have 50 tests. 5 fail due to a known bug. 2 need triple the timeout. How do you annotate them?
Show answer
Use 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

🛠️ Hands-On Exercise Beginner

Exercise: Structure a Test File with Describe and Hooks

  1. Create a new file called exercise-anatomy.spec.ts.
  2. Create a test.describe('Navigation') block.
  3. Add a beforeEach hook that navigates to https://playwright.dev.
  4. Write two tests: one that checks the page title, and one that checks the "Get started" link is visible.
  5. Run the tests and verify both pass. Notice how beforeEach handles navigation for both.
🔥 Challenge Intermediate

Challenge: Annotations and Nested Describes

  1. Create a test file with a top-level test.describe('E2E Flow').
  2. Inside it, create two nested describes: 'Positive Cases' and 'Negative Cases'.
  3. In Positive Cases, write 2 passing tests on the TodoMVC demo app.
  4. In Negative Cases, write 1 test marked with test.fixme().
  5. Add one test marked with test.slow() that adds multiple todo items.

🧩 Mini Project: Structured Test Suite

🎯 Project: Build a Multi-Feature Test Suite

  1. Create the file structure: tests/todomvc/add-todo.spec.ts, edit-todo.spec.ts, filter-todo.spec.ts
  2. In each file: Use test.describe + beforeEach to navigate to the TodoMVC app.
  3. Write at least 2 tests per file (6+ total).
  4. Annotate: Mark one test with test.slow() and one with test.fixme().
  5. Run: npx playwright test todomvc/

🧠 Quiz: Test Your Knowledge

1. What does test.describe() do?
Creates a new browser instance
Groups related tests into a named suite
Defines a custom fixture
Configures the test runner
2. Which hook runs before every single test?
test.beforeAll
test.beforeEach
test.setup
test.init
3. Why is { page } NOT available in test.beforeAll?
It's a bug in Playwright
beforeAll runs in worker scope where test-scoped fixtures don't exist
You need to import it separately
It requires a special config flag
4. What does test.skip() do?
Deletes the test from the file
Runs the test but doesn't report results
Skips the test and marks it as skipped in the report
Makes the test run last
5. What is the anti-pattern with test.only()?
It makes tests run slower
If committed to CI, it silently skips all other tests
It breaks the test.describe grouping
It disables auto-retry
6. In what order do beforeEach hooks execute in nested describes?
Outermost to innermost
Innermost to outermost
Random order
In parallel
7. What does test.slow() do?
Adds a 1-second delay between actions
Disables parallel execution
Triples the test timeout
Shows the browser in headed mode
8. What does test.describe.configure({ mode: 'serial' }) guarantee?
Tests share the same browser page
Tests run sequentially; if one fails, the rest are skipped
Tests run in the same worker
Tests share the same browser context
9. What is the recommended max nesting depth for test.describe?
No limit
10 levels
2-3 levels
1 level only
10. Why are custom fixtures preferred over beforeEach hooks in enterprise suites?
They run faster
They don't require async/await
They are reusable across files, composable, and combine setup + teardown
They bypass worker isolation

❓ Frequently Asked Questions

Can I have tests outside of a test.describe block?
Show answer
Yes. Tests defined at the top level of a file are perfectly valid. However, if you want to share hooks like beforeEach across multiple tests, you need a describe block. Most teams use describe blocks for all tests for consistency.
What happens if a beforeEach hook fails?
Show answer
If 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.
Can I use test.skip inside a test body?
Show answer
Yes. You can call test.skip() conditionally inside a test. Useful for runtime conditions like environment variables, browser type, or feature flags.
How do I share authentication across all tests in a file?
Show answer
Use 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 -g flag 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.