15 Days of Playwright - Day 7
Tired of repetitive test setup code? Learn how Playwright's Fixtures and Hooks help you organize your tests, share logic, and create a scalable testing architecture.


Jhonatas Matos
You've learned how to make tests fast with authentication and reliable with waits. But as your test suite grows, you might find yourself repeating the same setup code over and over again. How do you keep your tests DRY (Don't Repeat Yourself) and maintainable?
Playwright's answer to this is Fixtures and Hooks. They are the backbone of a well-organized test suite, allowing you to encapsulate setup and teardown logic in one place.
Understanding the Built-in Fixtures
You've been using Fixtures since Day 1 without even realizing it! The page
and context
objects passed to your tests are actually Fixtures provided by Playwright.
// This 'page' is a Fixture!
test('my test', async ({ page }) => {
await page.goto('/');
// ...
});
Playwright's built-in fixtures handle all the heavy lifting:
page
: Provides a new, isolated page for each test.context
: Provides a new browser context for each test.browser
: Provides the browser instance.browserName
: Provides the name of the browser (e.g., 'chromium').
This built-in setup is why your tests are already isolated and can run in parallel by default.
Test Hooks: test.beforeEach
, test.afterEach
Hooks allow you to run code at specific stages of the test lifecycle, perfect for common setup and teardown tasks.
Common Use Case: Global Setup for a Test File
// tests/dashboard.spec.js
import { test, expect } from '@playwright/test';
test.describe('Dashboard Suite', () => {
test.beforeEach(async ({ page }) => {
// This runs BEFORE each test in this describe block
console.log('Logging in before test...');
await page.goto('/login');
await page.fill('#username', 'testuser');
await page.fill('#password', 'password123');
await page.click('button:has-text("Sign in")');
await expect(page).toHaveURL(/dashboard/);
});
test.afterEach(async ({ page }) => {
// This runs AFTER each test in this describe block
console.log('Logging out after test...');
await page.click('#logout-button');
});
test('should display welcome message', async ({ page }) => {
// No login code needed here!
await expect(page.locator('.welcome-msg')).toContainText('Hi testuser');
});
test('should have a summary widget', async ({ page }) => {
// Already logged in here too!
await expect(page.locator('.summary-widget')).toBeVisible();
});
});
Other Useful Hooks:
test.beforeAll
/test.afterAll
: Run once before/after all tests in the suite. Useful for expensive, one-time setup.test.describe.configure({ mode: 'serial' })
: Forces tests in a describe block to run in order, sharing their state.
Fixture & Hook Execution Flow
Understanding the execution order is critical for proper test architecture. The following diagram illustrates Playwright's test lifecycle management:
Key Execution Stages:
- Fixture Initialization: Dependencies are resolved and initialized
- Global/BeforeAll Hooks: One-time setup operations
- BeforeEach Hooks: Pre-test setup for each individual test
- Test Execution: Actual test implementation and assertions
- AfterEach Hooks: Post-test cleanup
- AfterAll Hooks: Final teardown operations
- Fixture Teardown: Resource cleanup and disposal
This structured approach ensures proper test isolation and reliable cleanup regardless of test outcome.
Diagram: Test execution flow showing fixture initialization, hook execution, and cleanup phases
Execution Order Details
Phase 1: Setup (Top-Down)
- Fixtures initialize in dependency order (depth-first)
beforeAll
hooks execute once per describe blockbeforeEach
hooks run before each individual test
Phase 2: Test Execution
- Test body executes with all fixtures available
- Assertions validate expected behavior
- Test marked as passed/failed based on results
Phase 3: Teardown (Bottom-Up)
afterEach
hooks run regardless of test outcomeafterAll
hooks execute once after all tests complete- Fixtures teardown in reverse initialization order
Error Handling Characteristics
- Hook failures fail the associated test(s)
- Fixture initialization failures prevent test execution
- Teardown always executes, even after test failures
- Failed fixtures are properly disposed during teardown
Creating Custom Fixtures
This is where Playwright's true power for organization shines. You can extend the test
object with your own fixtures to create a custom testing environment tailored to your app.
Hooks are great, but what if you need that login logic in multiple test files? Do you copy-paste the beforeEach
? Never.
Let's create a loggedInPage
fixture that automatically handles authentication:
// fixtures.js
import { test as baseTest } from '@playwright/test';
import fs from 'fs';
// Extend the base test function with our custom fixtures
export const test = baseTest.extend({
// Define a new fixture called "loggedInPage"
loggedInPage: async ({ page }, use) => {
// SETUP: This part runs before the test that uses this fixture
const storageState = JSON.parse(fs.readFileSync('auth.json', 'utf-8'));
// Inject the saved authentication state into the page's context
await page.context().addInitScript((storage) => {
if (window.localStorage) {
for (const [key, value] of Object.entries(storage.localStorage || {}))
window.localStorage.setItem(key, value);
}
}, storageState);
// You can also set cookies
await page.context().addCookies(storageState.cookies);
// Navigate to the app already logged in
await page.goto('/dashboard');
// The 'use' function passes the prepared 'page' to the test
await use(page);
// TEARDOWN: (Optional) This part runs after the test is done
// e.g., cleanup specific data created during the test
},
});
// Now export 'expect' to use with our custom test fixture
export const expect = test.expect;
Using the Custom Fixture in a Test:
Now, your tests become incredibly clean and focused.
// tests/secure-operation.spec.js
import { test, expect } from '../fixtures.js'; // Import our custom test fixture
// We use 'loggedInPage' instead of the standard 'page'
test('perform secure action', async ({ loggedInPage }) => {
// The test starts already authenticated on the dashboard!
await loggedInPage.click('#secure-button');
await expect(loggedInPage.locator('.success-toast')).toBeVisible();
});
test('view user profile', async ({ loggedInPage }) => {
// Already logged in here too!
await loggedInPage.goto('/profile');
await expect(loggedInPage.locator('.user-email')).toContainText('user@example.com');
});
Global Setup and Teardown
For operations that need to happen once for the entire test run (like seeding a database or getting an auth token for all tests), use globalSetup
and globalTeardown
in your playwright.config.js
.
global-setup.js
:
// global-setup.js
module.exports = async (config) => {
// This runs once at the start of the test run
const { request } = await require('@playwright/test')._baseTest.newContext();
const response = await request.post('https://api.myapp.com/login', {
data: { username: 'admin', password: 'password' }
});
const body = await response.json();
process.env.AUTH_TOKEN = body.token; // Store token for all tests
};
Configure playwright.config.js
:
// playwright.config.js
module.exports = defineConfig({
// ... other config
globalSetup: require.resolve('./global-setup.js'),
});
Best Practices
- Use Hooks for File-Specific Logic: beforeEach is great for setup needed by all tests in a single file.
- Use Custom Fixtures for Cross-Cutting Concerns: Create fixtures for anything used across multiple test files (like authentication, API clients, or test data).
- Keep Tests Focused: The test itself should only contain actions and assertions related to its specific goal. All setup should be delegated to fixtures and hooks.
- Name Fixtures Descriptively: adminPage, apiClient, testUser are clear names that reveal intent.
Implementation Guidelines
- Keep fixture initialization logic minimal
- Ensure proper teardown for resource cleanup
- Avoid global state mutations between tests
- Use dependency injection pattern for test dependencies
Want to see all these concepts working together in practice? Check out the complete working example on GitHub:
Link to GitHub project
Conclusion
Fixtures and Hooks aren't just "cool Playwright features." They're the key to transforming your tests from a collection of loose scripts into a well-architected, maintainable, and scalable test suite.
- Reduce code repetition (DRY - Don't Repeat Yourself)
- Minimize errors (less code = fewer bugs)
- Make your tests clearer (only what matters stays visible)
- Make maintenance joyful (change something? update in one place)
It might seem complex at first, but once you create your first custom fixture, you'll never want to go back. It's liberating!
Hope this helps! See you in the next lesson! ☕💜