Introduction
By day 89 of your Playwright journey, you've written test suites that repeat the same setup steps over and over. A login here, a navigation there, maybe a database reset before each test. Hooks: beforeEach and afterEach for Playwright developers are your lever to eliminate that repetition and keep tests DRY without sacrificing clarity. This article shows you how to use them effectively in production test suites, when to reach for fixtures instead, and the scoping rules that catch most teams off guard.
Understanding beforeEach: Shared setup steps
The beforeEach hook runs once before each test in a describe block. It's the natural place to handle navigation, authentication, or other preconditions that every test in your suite needs.
Consider a scenario where you're testing an e-commerce checkout flow. Every test needs to land on the cart page with at least one item in it. Without beforeEach, you'd write this setup in ten different tests:
import { test, expect } from '@playwright/test';
// Repetitive approach – avoid this
test('should apply discount code', async ({ page }) => {
await page.goto('https://shop.example.com/cart');
await page.fill('[data-test="qty-input"]', '2');
await page.click('[data-test="update-cart"]');
await page.waitForLoadState('networkidle');
// Now the actual test
await page.fill('[data-test="coupon"]', 'SAVE10');
await page.click('[data-test="apply-coupon"]');
expect(await page.locator('[data-test="discount"]').textContent()).toContain('$10');
});
test('should proceed to payment', async ({ page }) => {
await page.goto('https://shop.example.com/cart');
await page.fill('[data-test="qty-input"]', '2');
await page.click('[data-test="update-cart"]');
await page.waitForLoadState('networkidle');
// Now the actual test
await page.click('[data-test="checkout-btn"]');
expect(page).toHaveURL(/payment/);
});
With beforeEach, the setup lives in one place:
import { test, expect } from '@playwright/test';
test.describe('Checkout flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://shop.example.com/cart');
await page.fill('[data-test="qty-input"]', '2');
await page.click('[data-test="update-cart"]');
await page.waitForLoadState('networkidle');
});
test('should apply discount code', async ({ page }) => {
await page.fill('[data-test="coupon"]', 'SAVE10');
await page.click('[data-test="apply-coupon"]');
expect(await page.locator('[data-test="discount"]').textContent()).toContain('$10');
});
test('should proceed to payment', async ({ page }) => {
await page.click('[data-test="checkout-btn"]');
expect(page).toHaveURL(/payment/);
});
});
Each test now focuses on what it's actually testing. The setup is isolated, easier to update, and less prone to copy-paste errors. If you need to change how the cart is populated—say, switching from the UI to an API call—you update it in one place and all tests benefit.
When to use beforeEach and when to prefer fixtures
Here's where teams often stumble: beforeEach hooks are powerful, but they're not always the best choice. Playwright fixtures are often better for reusable setup because they're composable, scoped more precisely, and make test intent clearer.
Use beforeEach when:
- You need side effects that affect the page state directly (navigation, form fills, clicks).
- The setup is tightly coupled to the tests in that describe block.
- You want to reset state between every test in a group.
Use fixtures when:
- You're creating reusable objects or contexts (an authenticated page, a database client, API test helpers).
- You want to pass data between the setup and the test without relying on page state.
- You need dependency injection or fine-grained control over when setup runs.
Here's a fixture-based approach for the same checkout scenario:
import { test as base, expect } from '@playwright/test';
type CartFixture = {
cartPage: any;
};
const test = base.extend({
cartPage: async ({ page }, use) => {
// Setup: navigate and populate cart
await page.goto('https://shop.example.com/cart');
await page.fill('[data-test="qty-input"]', '2');
await page.click('[data-test="update-cart"]');
await page.waitForLoadState('networkidle');
// Provide the page to the test
await use(page);
// Cleanup (if needed)
// e.g., await page.goto('about:blank');
},
});
test('should apply discount code', async ({ cartPage }) => {
await cartPage.fill('[data-test="coupon"]', 'SAVE10');
await cartPage.click('[data-test="apply-coupon"]');
expect(await cartPage.locator('[data-test="discount"]').textContent()).toContain('$10');
});
test('should proceed to payment', async ({ cartPage }) => {
await cartPage.click('[data-test="checkout-btn"]');
expect(cartPage).toHaveURL(/payment/);
});
The fixture approach makes the dependency explicit in the test signature. A developer reading async ({ cartPage }) immediately knows that a cart is already set up. With hooks, it's less obvious until you scroll up to find the beforeEach block. That said, for simple navigation or small setup blocks, beforeEach is often clearer and less boilerplate.
afterEach for reliable cleanup
What goes up must come down. afterEach runs after each test completes, even if the test fails. It's essential for cleanup: clearing test data, logging, screenshots for failures, or resetting shared state.
A common pattern in teams running against shared staging environments is to clean up test records after each run:
import { test, expect } from '@playwright/test';
test.describe('User management', () => {
let userId: string;
test.beforeEach(async ({ page }) => {
await page.goto('https://app.example.com/admin/users');
await page.click('[data-test="create-user"]');
await page.fill('[data-test="email"]', `user-${Date.now()}@test.local`);
userId = await page.locator('[data-test="user-id"]').textContent();
});
test.afterEach(async ({ request }) => {
// Clean up the user we created
await request.delete(`https://api.example.com/users/${userId}`, {
headers: { Authorization: 'Bearer test-token' },
});
});
test('should edit user email', async ({ page }) => {
await page.fill('[data-test="email"]', 'newemail@test.local');
await page.click('[data-test="save"]');
expect(page).toHaveURL(/\/admin\/users\/\d+/);
});
test('should delete user', async ({ page }) => {
await page.click('[data-test="delete-user"]');
await page.click('[data-test="confirm-delete"]');
expect(await page.locator('[data-test="success"]')).toBeVisible();
});
});
In this example, every test creates a user as setup and deletes it afterward. Notice that afterEach doesn't depend on the test outcome—it always runs. This is critical for preventing test pollution. Without cleanup, your staging database becomes a graveyard of orphaned test records.
Another practical use: capturing screenshots on failure for debugging. Combine afterEach with testInfo to store evidence:
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== 'passed') {
const screenshot = await page.screenshot({ path: `failure-${testInfo.title}.png` });
testInfo.attachments.push({
name: 'screenshot',
path: `failure-${testInfo.title}.png`,
contentType: 'image/png',
});
}
});
Describe blocks and hook scoping
Hooks are scoped to the describe block they're declared in. This is powerful—you can have different setups for different test groups—but it's also where subtle bugs hide.
Consider this structure:
import { test, expect } from '@playwright/test';
test.describe('Admin dashboard', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.fill('[data-test="email"]', 'admin@example.com');
await page.fill('[data-test="password"]', 'secure-pwd');
await page.click('[data-test="submit"]');
await page.waitForURL(/dashboard/);
});
test.describe('Reports section', () => {
test.beforeEach(async ({ page }) => {
// This runs AFTER the outer beforeEach
await page.click('[data-test="nav-reports"]');
});
test('should load report data', async ({ page }) => {
expect(await page.locator('[data-test="report-count"]').textContent()).toMatch(/\d+/);
});
});
test.describe('Settings section', () => {
test.beforeEach(async ({ page }) => {
await page.click('[data-test="nav-settings"]');
});
test('should update admin password', async ({ page }) => {
await page.fill('[data-test="new-password"]', 'newpwd123');
await page.click('[data-test="save"]');
expect(page).toHaveURL(/settings\?saved=true/);
});
});
});
Hook execution order matters: all outer beforeEach hooks run first, then inner ones. So in the "Reports section" test, the flow is: log in (outer), then navigate to reports (inner). This nesting is intentional and useful—it lets you build up complex state progressively.
However, afterEach hooks run in reverse order (innermost first). This is important if you have interdependencies. Generally, you'll want outer hooks to clean up global state and inner hooks to clean up local state:
test.describe('Admin dashboard', () => {
test.beforeEach(async ({ page }) => {
// Global setup: login
await page.goto('https://app.example.com/login');
await page.fill('[data-test="email"]', 'admin@example.com');
await page.fill('[data-test="password"]', 'secure-pwd');
await page.click('[data-test="submit"]');
});
test.afterEach(async ({ page }) => {
// Global cleanup: logout
await page.click('[data-test="logout"]');
});
test.describe('Reports section', () => {
test.beforeEach(async ({ page }) => {
await page.click('[data-test="nav-reports"]');
});
test.afterEach(async ({ page }) => {
// Local cleanup: return to dashboard
await page.click('[data-test="home"]');
});
test('should load report data', async ({ page }) => {
expect(await page.locator('[data-test="report-count"]').textContent()).toMatch(/\d+/);
});
});
});
Common mistakes
- Assuming global beforeEach runs for all tests: Hooks are scoped to their describe block and nested children. A hook at the root level runs for all tests, but moving it inside a describe block changes its scope. Check your nesting.
- Heavy setup in beforeEach: If beforeEach takes 10 seconds, every test takes at least 10 seconds. Consider parallelization, API-based setup, or fixtures if the overhead becomes painful.
- Not handling flaky setup: If a beforeEach step fails (network timeout, element not found), the test is marked as failed even though it never ran. Add retries or assertions to beforeEach steps that are prone to flakiness.
- Mixing test data concerns: If tests need different preconditions, don't try to force them all through one beforeEach. Use multiple describe blocks or parameterized tests instead.
- Forgetting afterEach cleanup: Leaving test data behind pollutes future test runs and creates hidden interdependencies. Always clean up what you create.
Key takeaways
beforeEacheliminates repetitive setup. Use it when all tests in a group need the same preconditions.- Fixtures are often better than hooks for complex or reusable setup; they make dependencies explicit and are easier to compose.
afterEachis non-negotiable for cleanup. It runs even if a test fails, making it ideal for deleting test data or capturing failure artifacts.- Hooks are scoped to describe blocks. Outer hooks run first, inner hooks run second; afterEach runs in reverse order.
- Keep beforeEach blocks lean. Heavy setup slows down every test; consider parallelization or API shortcuts.
- Test isolation is worth the effort. Proper cleanup prevents test pollution and makes debugging easier.






Comments
Loading comments…