test.beforeEach runs setup before every test in its scope, so login code written once inside a describe block serves every test inside it without touching the tests outside. test.beforeAll runs once for the whole group, not once per test: an auth token it creates is shared across every test in that file, which causes interference when those tests run in parallel. This article covers the scoping rules that make describe blocks useful, the execution order of nested hooks, when test.beforeAll is appropriate versus dangerous, and the test.only behavior that silently breaks CI when committed.

The Basic Test

Before adding structure, a single test looks like this:

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

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

Simple. But if you have 10 tests that all start with the same login steps, that's 10 duplicated blocks. When the login flow changes, you update 10 places instead of one.

test.beforeEach — Run Before Every Test

beforeEach runs setup code before each test in its scope. The most common use: navigate to a page, or log in.

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

test.beforeEach(async ({ page }) => {
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'user@test.com');
  await page.fill('[data-testid="password"]', 'ValidPass1');
  await page.click('[data-testid="submit"]');
  await page.waitForURL('/dashboard');
});

test('dashboard shows user name', async ({ page }) => {
  // Already logged in and on /dashboard
  await expect(page.getByTestId('user-name')).toContainText('Test User');
});

test('user can access settings', async ({ page }) => {
  // Also already logged in
  await page.getByTestId('settings-link').click();
  await expect(page).toHaveURL('/settings');
});

Each test starts with the user already logged in and on the dashboard. No duplication.

test.afterEach — Clean Up After Every Test

afterEach runs after each test, regardless of whether it passed or failed. Use it for cleanup that must happen after every test.

test.afterEach(async ({ page }, testInfo) => {
  // Take a screenshot on failure (Playwright can also do this via config)
  if (testInfo.status !== testInfo.expectedStatus) {
    await page.screenshot({ path: `screenshots/${testInfo.title}.png` });
  }
});

Or for API cleanup:

let createdUserId: number;

test.beforeEach(async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { email: 'temp@test.com', password: 'Pass1' },
  });
  const body = await response.json();
  createdUserId = body.id;
});

test.afterEach(async ({ request }) => {
  // Delete the user created during setup
  await request.delete(`/api/users/${createdUserId}`);
});

test.describe creates a named group of tests. Useful for:
  • Grouping tests by feature or page
  • Applying beforeEach/afterEach only to a subset of tests
  • Nesting related test scenarios

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

test.describe('Login page', () => {
  test('shows email and password fields', async ({ page }) => {
    await page.goto('/login');
    await expect(page.getByTestId('email')).toBeVisible();
    await expect(page.getByTestId('password')).toBeVisible();
  });

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

test.describe('Dashboard', () => {
  test.beforeEach(async ({ page }) => {
    // Login before each dashboard test
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'user@test.com');
    await page.fill('[data-testid="password"]', 'ValidPass1');
    await page.click('[data-testid="submit"]');
    await page.waitForURL('/dashboard');
  });

  test('shows user name', async ({ page }) => {
    await expect(page.getByTestId('user-name')).toBeVisible();
  });

  test('shows recent orders', async ({ page }) => {
    await expect(page.getByTestId('orders-section')).toBeVisible();
  });
});

The login beforeEach only applies to the Dashboard describe block — Login page tests are unaffected.

test.beforeAll and test.afterAll

beforeAll runs once before all tests in its scope (not before each). afterAll runs once after all.

Use case: expensive setup that only needs to happen once — like creating a test user via API, seeding a database, or starting a server.

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

let authToken: string;

test.beforeAll(async () => {
  // Create auth token once for all tests in this file
  const ctx = await playwrightRequest.newContext();
  const response = await ctx.post('/api/auth/login', {
    data: { email: 'admin@test.com', password: 'AdminPass1' },
  });
  const body = await response.json();
  authToken = body.token;
  await ctx.dispose();
});

test('admin can view all users', async ({ request }) => {
  const response = await request.get('/api/admin/users', {
    headers: { Authorization: `Bearer ${authToken}` },
  });
  expect(response.status()).toBe(200);
});

test('admin can delete a user', async ({ request }) => {
  // Also uses authToken — shared across all tests
});

Important: beforeAll and afterAll run in a shared context. Changes to shared state persist between tests — which can cause flaky tests if you're not careful. Prefer beforeEach for most setups.

Nested describe Blocks

You can nest describe blocks to create a hierarchy:

test.describe('Checkout flow', () => {
  test.beforeEach(async ({ page }) => {
    await loginAsUser(page);
    await addItemToCart(page, 'product-123');
  });

  test.describe('with valid card', () => {
    test.beforeEach(async ({ page }) => {
      await fillShippingAddress(page);
      await fillValidCard(page, '4242 4242 4242 4242');
    });

    test('completes purchase', async ({ page }) => {
      await page.getByTestId('place-order').click();
      await expect(page.getByTestId('order-confirmation')).toBeVisible();
    });

    test('sends confirmation email', async ({ page }) => {
      // ...
    });
  });

  test.describe('with invalid card', () => {
    test('shows error message', async ({ page }) => {
      await fillValidCard(page, '0000 0000 0000 0000');
      await page.getByTestId('place-order').click();
      await expect(page.getByTestId('payment-error')).toBeVisible();
    });
  });
});

Hook execution order for a nested test:

1. Outer beforeEach (login + add to cart)

2. Inner beforeEach (fill shipping + fill card)

3. The test itself

4. Inner afterEach (if any)

5. Outer afterEach (if any)

test.skip and test.only

Two modifiers useful during development:

// Skip this test (marks it as skipped, not failed)
test.skip('feature not implemented yet', async ({ page }) => {
  // ...
});

// Only run this test (ignores all others in the file)
test.only('debugging this specific case', async ({ page }) => {
  // ...
});

Warning: Never commit test.only to the main branch — it makes your entire CI suite fail by only running one test.

Conditional skip — useful for environment-specific tests:

test('admin panel', async ({ page }) => {
  test.skip(process.env.ENV === 'production', 'Skipped in production');
  // ...
});

How Tests Are Named in Reports

The test name shown in reports combines the describe label and the test label:

test.describe('Login page', () => {
  test('shows error on wrong password', async ({ page }) => { ... });
});
// Reports as: "Login page > shows error on wrong password"

Write descriptive names — you'll thank yourself when a CI run shows 3 failures and you need to understand them without opening the code.

Full File Structure Example

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

test.describe('User authentication', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.navigate();
  });

  test.describe('Valid credentials', () => {
    test('redirects to dashboard', async ({ page }) => {
      await loginPage.login('user@test.com', 'ValidPass1');
      await expect(page).toHaveURL('/dashboard');
    });

    test('sets auth cookie', async ({ page }) => {
      await loginPage.login('user@test.com', 'ValidPass1');
      const cookies = await page.context().cookies();
      expect(cookies.some(c => c.name === 'auth_token')).toBe(true);
    });
  });

  test.describe('Invalid credentials', () => {
    test('wrong password shows error', async () => {
      await loginPage.login('user@test.com', 'WrongPass');
      await expect(loginPage.errorMessage).toBeVisible();
    });

    test('empty email shows validation error', async () => {
      await loginPage.login('', 'ValidPass1');
      await expect(loginPage.emailError).toContainText('required');
    });
  });
});

Summary

| Hook | When it runs | Use for |

|------|-------------|---------|

| test.beforeEach | Before every test in scope | Navigation, login, resetting state |

| test.afterEach | After every test in scope | Cleanup, screenshots on failure |

| test.beforeAll | Once before all tests in scope | Expensive one-time setup |

| test.afterAll | Once after all tests in scope | One-time teardown |

| test.describe | (grouping, not a hook) | Organizing tests, scoping hooks |

Start with test.beforeEach for most setups. Add test.describe to group related tests. Use test.beforeAll only when setup is genuinely expensive and safe to share across tests.

→ See also: Playwright Fixtures Explained: From Built-in to Custom | Test Isolation: Why Each Playwright Test Should Be Stateless | Test Automation Best Practices That Actually Matter