Every Playwright test needs some form of setup: a browser page to interact with, an authenticated session, test data in a database, or an API client ready to make requests. How you manage that setup determines whether your test suite scales cleanly to hundreds of tests or collapses into a tangled mess of duplicated code and brittle dependencies.
Playwright gives you two complementary mechanisms for test setup and teardown: fixtures and hooks. Understanding when to use each — and how to combine them — is the difference between a test suite that grows effortlessly and one that becomes a maintenance burden.
This playwright fixtures and hooks guide covers both mechanisms from first principles, with real-world TypeScript examples you can adapt for your own projects.
What Are Playwright Fixtures?
If you have used testing frameworks like Jest or Mocha, you are familiar with beforeEach and afterEach for test setup and teardown. Playwright fixtures take a fundamentally different approach. Instead of running setup code before every test regardless of whether the test needs it, fixtures are dependency-injected — a test declares what it needs, and Playwright provides it.
Think of fixtures as named, reusable setup/teardown units that are:
- Lazy — a fixture only runs if a test actually requests it
- Composable — fixtures can depend on other fixtures
- Encapsulated — setup and teardown live together in one place
- Automatically scoped — Playwright handles lifecycle management
- Type-safe — full TypeScript autocomplete and type checking
When you write test('my test', async ({ page }) => { ... }), the page parameter is a fixture. Playwright creates a fresh browser page before your test runs and closes it after the test finishes. You never call browser.newPage() or page.close() manually — the fixture handles both.
import { test, expect } from '@playwright/test'; // 'page' is a built-in fixture — injected automatically test('homepage loads correctly', async ({ page }) => { await page.goto('https://example.com'); await expect(page).toHaveTitle('Example Domain'); }); // You can request multiple fixtures test('API and browser together', async ({ page, request }) => { // 'request' is another built-in fixture for API testing const response = await request.get('/api/status'); await expect(response).toBeOK(); });
Why this matters: In a traditional beforeEach approach, every test pays the setup cost even if it does not need all the resources. Fixtures are lazy — if a test only needs a page, Playwright will not create an API request context. This makes your test suite faster and more resource-efficient.
Built-in Playwright Fixtures
Playwright ships with several built-in fixtures that cover the most common testing needs. Understanding each one is essential before creating custom fixtures.
page
The most commonly used fixture. Provides a fresh, isolated Page instance for each test. Each page runs in its own BrowserContext, meaning cookies, local storage, and session data are isolated between tests.
test('page fixture provides isolated browser page', async ({ page }) => { await page.goto('https://demo.playwright.dev/todomvc'); await page.getByPlaceholder('What needs to be done?').fill('Write tests'); await page.getByPlaceholder('What needs to be done?').press('Enter'); await expect(page.getByTestId('todo-title')).toHaveText('Write tests'); });
context
Provides the BrowserContext instance that the page fixture belongs to. Use this when you need to control context-level settings like cookies, permissions, geolocation, or when you need multiple pages in the same context.
test('grant geolocation permission', async ({ context, page }) => { await context.grantPermissions(['geolocation']); await context.setGeolocation({ latitude: 40.7128, longitude: -74.0060 }); await page.goto('https://example.com/store-locator'); await expect(page.getByText('New York')).toBeVisible(); }); test('multi-page scenario', async ({ context }) => { // Create two pages in the same context (shared cookies/storage) const page1 = await context.newPage(); const page2 = await context.newPage(); await page1.goto('https://example.com/sender'); await page2.goto('https://example.com/receiver'); });
browser
Provides the shared Browser instance for the current worker. This fixture is worker-scoped — the same browser instance is reused across all tests running on the same worker. Use it when you need to create multiple independent browser contexts.
test('two users chatting', async ({ browser }) => { // Create two isolated contexts — different users const aliceContext = await browser.newContext(); const bobContext = await browser.newContext(); const alicePage = await aliceContext.newPage(); const bobPage = await bobContext.newPage(); // Each user has independent cookies, storage, sessions await alicePage.goto('https://chat.example.com'); await bobPage.goto('https://chat.example.com'); await aliceContext.close(); await bobContext.close(); });
browserName
A string fixture that returns the current browser name: 'chromium', 'firefox', or 'webkit'. Useful for conditional test logic when a feature behaves differently across browsers.
test('browser-specific behavior', async ({ page, browserName }) => { test.skip(browserName === 'webkit', 'WebKit does not support this API'); await page.goto('https://example.com/webgpu-demo'); await expect(page.getByText('WebGPU Active')).toBeVisible(); });
request
Provides an APIRequestContext for making HTTP requests without a browser. Perfect for API testing, setting up test data, or verifying server-side state. The request context shares the same baseURL and storageState as the browser context.
test('API creates resource correctly', async ({ request }) => { const response = await request.post('/api/todos', { data: { title: 'Learn fixtures', completed: false } }); await expect(response).toBeOK(); const todo = await response.json(); expect(todo.title).toBe('Learn fixtures'); expect(todo.id).toBeDefined(); });
Custom Fixtures with test.extend()
Built-in fixtures cover the basics, but real-world test suites need custom infrastructure: an authenticated user, a seeded database, a mock API server, or a configured API client. Playwright custom fixtures let you package this setup into reusable, type-safe, dependency-injected units using test.extend().
Basic custom fixture
Here is a simple custom fixture that provides a to-do page — a page that has already navigated to the to-do app and is ready for interaction:
import { test as base, expect } from '@playwright/test'; // Define fixture types type MyFixtures = { todoPage: Page; }; // Extend the base test with custom fixtures export const test = base.extend<MyFixtures>({ todoPage: async ({ page }, use) => { // SETUP: navigate to the app await page.goto('https://demo.playwright.dev/todomvc'); // PROVIDE: hand the page to the test await use(page); // TEARDOWN: runs after the test finishes (automatic cleanup) // In this case, page cleanup is handled by Playwright }, }); export { expect };
import { test, expect } from './fixtures'; test('can add a todo item', async ({ todoPage }) => { // todoPage is already on the TodoMVC app await todoPage.getByPlaceholder('What needs to be done?').fill('Buy groceries'); await todoPage.getByPlaceholder('What needs to be done?').press('Enter'); await expect(todoPage.getByTestId('todo-title')).toContainText('Buy groceries'); });
The pattern is always the same: setup, use, teardown. Code before await use() runs before the test. Code after await use() runs after the test finishes, even if the test fails. This is more reliable than afterEach because the teardown is guaranteed to run.
Authenticated user fixture
One of the most common playwright custom fixtures is an authenticated page. Instead of logging in before every test, you create a fixture that handles authentication once:
import { test as base } from '@playwright/test'; import type { Page } from '@playwright/test'; type AuthFixtures = { authenticatedPage: Page; }; export const test = base.extend<AuthFixtures>({ authenticatedPage: async ({ browser }, use) => { // Create a new context with saved auth state const context = await browser.newContext({ storageState: './auth/admin.json' }); const page = await context.newPage(); await use(page); // Teardown: close the context (and its pages) await context.close(); }, });
Database seed fixture
For tests that need specific data in the database, a seed fixture ensures clean, predictable state:
import { test as base } from '@playwright/test'; import { db } from './helpers/database'; type DbFixtures = { seededDb: { userId: string; projectId: string }; }; export const test = base.extend<DbFixtures>({ seededDb: async ({}, use) => { // Setup: seed test data const user = await db.createUser({ email: 'test@example.com' }); const project = await db.createProject({ ownerId: user.id }); await use({ userId: user.id, projectId: project.id }); // Teardown: clean up test data await db.deleteProject(project.id); await db.deleteUser(user.id); }, });
API client fixture
A reusable API client fixture with pre-configured authentication and base URL:
import { test as base, APIRequestContext } from '@playwright/test'; type ApiFixtures = { apiClient: APIRequestContext; }; export const test = base.extend<ApiFixtures>({ apiClient: async ({ playwright }, use) => { const apiContext = await playwright.request.newContext({ baseURL: 'https://api.example.com', extraHTTPHeaders: { 'Authorization': `Bearer ${process.env.API_TOKEN}`, 'Content-Type': 'application/json', }, }); await use(apiContext); await apiContext.dispose(); }, });
Playwright Hooks: beforeAll, beforeEach, afterAll, afterEach
Playwright hooks are lifecycle callbacks that run at specific points during test execution. If you have used any testing framework before, these will be familiar. Playwright supports four hooks for playwright test setup teardown:
beforeEach
Runs before every individual test in the file. This is the most commonly used hook — ideal for navigation, form resets, or per-test setup that does not warrant a custom fixture.
import { test, expect } from '@playwright/test'; test.beforeEach(async ({ page }) => { // Runs before EVERY test in this file await page.goto('https://demo.playwright.dev/todomvc'); }); test('can add a todo', async ({ page }) => { await page.getByPlaceholder('What needs to be done?').fill('Learn Playwright'); await page.getByPlaceholder('What needs to be done?').press('Enter'); await expect(page.getByTestId('todo-title')).toHaveText('Learn Playwright'); }); test('page starts empty', async ({ page }) => { await expect(page.getByTestId('todo-title')).toHaveCount(0); });
afterEach
Runs after every individual test, regardless of whether the test passed or failed. Use it for cleanup, logging, or capturing debug information on failure.
test.afterEach(async ({ page }, testInfo) => { if (testInfo.status !== testInfo.expectedStatus) { // Capture screenshot on failure for debugging const screenshotPath = `screenshots/${testInfo.title}-failed.png`; await page.screenshot({ path: screenshotPath, fullPage: true }); await testInfo.attach('failure-screenshot', { path: screenshotPath, contentType: 'image/png', }); } });
beforeAll
Runs once before all tests in the file. This hook runs once per worker process. Use it for expensive one-time setup: seeding a database, creating shared test data via API, or starting a mock server.
Important: beforeAll does not have access to the page or context fixtures because those are test-scoped. You can use the browser fixture or create your own context within the hook.
import { test, expect } from '@playwright/test'; let authToken: string; test.beforeAll(async ({ request }) => { // Runs ONCE before all tests in this file // Create test data via API const response = await request.post('/api/auth/login', { data: { email: 'admin@example.com', password: 'secret' } }); const body = await response.json(); authToken = body.token; }); test('can access admin dashboard', async ({ page }) => { await page.setExtraHTTPHeaders({ 'Authorization': `Bearer ${authToken}` }); await page.goto('/admin/dashboard'); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); });
afterAll
Runs once after all tests in the file have completed. Use it to clean up resources created in beforeAll: delete test data, stop mock servers, or close database connections.
let testUserId: string; test.beforeAll(async ({ request }) => { const response = await request.post('/api/users', { data: { name: 'Test User', email: 'test@example.com' } }); const user = await response.json(); testUserId = user.id; }); test.afterAll(async ({ request }) => { // Clean up: delete the test user await request.delete(`/api/users/${testUserId}`); });
Fixtures vs Hooks: When to Use Which
This is where many teams make mistakes. Fixtures and hooks are not interchangeable — each has a clear use case. The following comparison table summarizes the key differences for your playwright fixtures tutorial reference:
| Aspect | Fixtures | Hooks |
|---|---|---|
| Execution | Lazy — only runs if test requests it | Eager — runs for every test regardless |
| Reusability | Shared across files via import | Scoped to single file |
| Composition | Fixtures can depend on other fixtures | No dependency chain |
| Teardown | Guaranteed — runs after use() even on failure | afterEach/afterAll — also runs on failure |
| Type safety | Full TypeScript inference | Standard TS support |
| Simplicity | Requires test.extend() boilerplate | Simple callback — no boilerplate |
| Best for | Reusable infrastructure (auth, DB, API clients) | Simple, file-scoped setup (navigation, reset) |
Rule of thumb: If you will use the same setup in more than one test file, make it a fixture. If the setup is simple and only relevant to one file, use a hook. If in doubt, prefer fixtures — they are more flexible and easier to refactor later. Combine fixtures with the Page Object Model for maximum reusability.
Advanced Fixture Patterns
Once you understand the basics of playwright custom fixtures, you can build sophisticated test infrastructure using advanced patterns. These patterns are what separate hobby projects from production-grade test suites.
Fixture Composition
Fixtures can depend on other fixtures, creating a composition chain. Playwright resolves the dependency graph automatically and ensures fixtures are created in the correct order and torn down in reverse order.
import { test as base } from '@playwright/test'; import type { Page } from '@playwright/test'; type Fixtures = { adminPage: Page; dashboardPage: Page; }; export const test = base.extend<Fixtures>({ // Base fixture: authenticated admin page adminPage: async ({ browser }, use) => { const context = await browser.newContext({ storageState: './auth/admin.json', }); const page = await context.newPage(); await use(page); await context.close(); }, // Composed fixture: depends on adminPage dashboardPage: async ({ adminPage }, use) => { await adminPage.goto('/admin/dashboard'); await adminPage.waitForLoadState('networkidle'); await use(adminPage); }, }); // Usage: dashboardPage automatically creates adminPage first test('dashboard shows metrics', async ({ dashboardPage }) => { await expect(dashboardPage.getByText('Total Users')).toBeVisible(); });
When the test requests dashboardPage, Playwright automatically creates adminPage first (because dashboardPage depends on it), then passes it to the dashboardPage fixture. On teardown, dashboardPage tears down first, then adminPage — reverse order.
Worker-Scoped Fixtures
By default, fixtures are test-scoped — created fresh for every test. Worker-scoped fixtures are created once per worker process and shared across all tests running on that worker. This is essential for expensive resources that do not need per-test isolation.
import { test as base } from '@playwright/test'; type WorkerFixtures = { authToken: string; apiBaseUrl: string; }; export const test = base.extend<{}, WorkerFixtures>({ // The second type parameter marks these as worker-scoped authToken: [async ({ playwright }, use) => { // This runs ONCE per worker, not per test const request = await playwright.request.newContext(); const response = await request.post('https://api.example.com/auth', { data: { email: 'admin@example.com', password: 'secret' } }); const { token } = await response.json(); await use(token); await request.dispose(); }, { scope: 'worker' }], apiBaseUrl: [async ({}, use) => { await use(process.env.API_URL ?? 'https://api.staging.example.com'); }, { scope: 'worker' }], });
Constraint: Worker-scoped fixtures cannot depend on test-scoped fixtures. A worker-scoped fixture lives for the entire worker lifetime, but test-scoped fixtures are created and destroyed for each test. The dependency only works in one direction: test-scoped fixtures can depend on worker-scoped fixtures.
Parameterized Fixtures
Fixtures can have configurable default values that individual test files or test.describe blocks can override. This is powerful for running the same tests with different configurations.
import { test as base } from '@playwright/test'; type OptionsFixtures = { defaultUser: { email: string; role: string }; }; export const test = base.extend<OptionsFixtures>({ // Option fixture with default value defaultUser: [{ email: 'user@example.com', role: 'viewer' }, { option: true }], }); // In a test file: override the default test.describe('admin tests', () => { test.use({ defaultUser: { email: 'admin@example.com', role: 'admin' } }); test('admin can see settings', async ({ page, defaultUser }) => { // defaultUser.role is 'admin' here console.log(`Testing as ${defaultUser.email}`); }); }); test.describe('viewer tests', () => { // Uses the default: role 'viewer' test('viewer cannot see settings', async ({ page, defaultUser }) => { // defaultUser.role is 'viewer' here }); });
Fixture Dependencies and Ordering
When fixtures depend on each other, Playwright builds a dependency graph and resolves it automatically. Here is a real-world example with multiple layers:
import { test as base } from '@playwright/test'; type Fixtures = { dbConnection: DbClient; testData: { userId: string; projectId: string }; authenticatedApi: ApiClient; projectPage: Page; }; export const test = base.extend<Fixtures>({ // Layer 1: database connection dbConnection: async ({}, use) => { const client = await connectToDb(); await use(client); await client.disconnect(); }, // Layer 2: depends on dbConnection testData: async ({ dbConnection }, use) => { const user = await dbConnection.createUser({ name: 'Test' }); const project = await dbConnection.createProject({ owner: user.id }); await use({ userId: user.id, projectId: project.id }); await dbConnection.cleanup([user.id, project.id]); }, // Layer 2: depends on testData for user credentials authenticatedApi: async ({ testData, playwright }, use) => { const api = await playwright.request.newContext({ baseURL: 'https://api.example.com', extraHTTPHeaders: { 'X-User-Id': testData.userId }, }); await use(api); await api.dispose(); }, // Layer 3: depends on testData for navigation projectPage: async ({ page, testData }, use) => { await page.goto(`/projects/${testData.projectId}`); await use(page); }, }); // Playwright resolves: dbConnection → testData → projectPage test('project page shows title', async ({ projectPage }) => { await expect(projectPage.getByRole('heading')).toBeVisible(); });
Automatic Fixtures
Sometimes you want a fixture to run for every test without requiring the test to explicitly request it. Use the auto option for cross-cutting concerns like logging, performance monitoring, or analytics:
export const test = base.extend<{ perfLogger: void }>({ perfLogger: [async ({ page }, use, testInfo) => { const startTime = Date.now(); await use(); const duration = Date.now() - startTime; console.log(`[PERF] ${testInfo.title}: ${duration}ms`); }, { auto: true }], });
Best Practices for Playwright Fixtures and Hooks
After working with Playwright fixtures across dozens of production test suites, these are the patterns that consistently lead to maintainable, fast, and reliable tests. These align closely with the Playwright best practices for 2026. Consider these playwright reusable test patterns as your starting template.
Do
- Use fixtures for anything reusable across files. Authentication, API clients, database connections, and test data factories should always be fixtures, never duplicated
beforeEachblocks. - Keep fixture setup and teardown together. The code before
use()sets up; the code after tears down. This makes the lifecycle obvious and guarantees cleanup runs. - Use worker-scoped fixtures for expensive resources. Database connections, auth tokens, and mock servers that do not need per-test isolation should be worker-scoped to avoid redundant setup.
- Name fixtures descriptively. Names like
authenticatedPage,adminApiClient, andseededDatabasemake tests self-documenting. Avoid generic names likesetupordata. - Use
test.use()for per-file or per-describe configuration. Override fixture defaults withtest.use()instead of creating multiple fixture variants. - Export your extended
testobject from a central file. Create afixtures.tsorbase.tsthat all test files import from. This gives you a single place to add new fixtures.
Do Not
- Do not duplicate fixture logic in hooks. If you have a
beforeEachthat logs in, and a fixture that also logs in, you are doubling work and creating confusion. Pick one approach per concern. - Do not store mutable state in worker-scoped fixtures. If test A modifies data created by a worker-scoped fixture, test B will see the modified state. Each test should be independent.
- Do not over-compose fixtures. If your fixture dependency chain is 5+ layers deep, it becomes hard to debug. Flatten where possible.
- Do not use
beforeAllfor setup that needs per-test isolation. If each test needs a fresh user, use a test-scoped fixture orbeforeEach— notbeforeAll, which shares state across tests. - Do not skip teardown. Always clean up resources in the post-
use()section of fixtures or inafterAll/afterEach. Leaked resources cause flaky tests and CI failures. - Do not use
autofixtures excessively. Every auto fixture runs for every test. If only some tests need it, make it opt-in by requiring explicit declaration in the test signature.
Common Mistakes
- Forgetting
await use()— Without theawait use()call, the fixture never provides its value to the test. Your test will hang or receiveundefined. - Using
pageinbeforeAll— Thepagefixture is test-scoped and not available inbeforeAll. Userequestorbrowserinstead. - Mixing extended test objects — If file A imports
testfromfixtures-a.tsand file B fromfixtures-b.ts, and both extend the base test independently, their fixtures are not compatible. Use a single chain of extensions. - Not typing fixtures — Skipping the TypeScript type parameter on
test.extend<MyFixtures>()loses type safety. Always define the fixture type.
Putting It All Together: Real-World Example
Here is a complete, production-ready fixture setup that combines multiple patterns from this guide. This is representative of what a mature Playwright test suite looks like:
import { test as base } from '@playwright/test'; import type { Page, APIRequestContext } from '@playwright/test'; // Test-scoped fixture types type TestFixtures = { adminPage: Page; userPage: Page; apiClient: APIRequestContext; }; // Worker-scoped fixture types type WorkerFixtures = { adminStorageState: string; userStorageState: string; }; export const test = base.extend<TestFixtures, WorkerFixtures>({ // Worker fixture: authenticate admin ONCE per worker adminStorageState: [async ({ browser }, use) => { const context = await browser.newContext(); const page = await context.newPage(); await page.goto('/login'); await page.getByLabel('Email').fill('admin@example.com'); await page.getByLabel('Password').fill('admin-password'); await page.getByRole('button', { name: 'Sign In' }).click(); await page.waitForURL('/dashboard'); const path = './auth/admin-state.json'; await context.storageState({ path }); await context.close(); await use(path); }, { scope: 'worker' }], // Worker fixture: authenticate regular user ONCE per worker userStorageState: [async ({ browser }, use) => { const context = await browser.newContext(); const page = await context.newPage(); await page.goto('/login'); await page.getByLabel('Email').fill('user@example.com'); await page.getByLabel('Password').fill('user-password'); await page.getByRole('button', { name: 'Sign In' }).click(); await page.waitForURL('/dashboard'); const path = './auth/user-state.json'; await context.storageState({ path }); await context.close(); await use(path); }, { scope: 'worker' }], // Test fixture: admin page (uses worker-scoped auth) adminPage: async ({ browser, adminStorageState }, use) => { const context = await browser.newContext({ storageState: adminStorageState, }); const page = await context.newPage(); await use(page); await context.close(); }, // Test fixture: regular user page userPage: async ({ browser, userStorageState }, use) => { const context = await browser.newContext({ storageState: userStorageState, }); const page = await context.newPage(); await use(page); await context.close(); }, // Test fixture: API client with auth apiClient: async ({ playwright, adminStorageState }, use) => { const context = await playwright.request.newContext({ baseURL: process.env.API_URL ?? 'https://api.example.com', storageState: adminStorageState, }); await use(context); await context.dispose(); }, }); export { expect } from '@playwright/test';
import { test, expect } from '../fixtures/base'; test.describe('Admin Settings', () => { test.beforeEach(async ({ adminPage }) => { await adminPage.goto('/admin/settings'); }); test('can update company name', async ({ adminPage }) => { await adminPage.getByLabel('Company Name').fill('Acme Corp'); await adminPage.getByRole('button', { name: 'Save' }).click(); await expect(adminPage.getByText('Settings saved')).toBeVisible(); }); test('regular user cannot access settings', async ({ userPage }) => { await userPage.goto('/admin/settings'); await expect(userPage.getByText('Access Denied')).toBeVisible(); }); test('API returns settings', async ({ apiClient }) => { const response = await apiClient.get('/api/settings'); await expect(response).toBeOK(); const settings = await response.json(); expect(settings.companyName).toBeDefined(); }); });
Notice how fixtures and hooks work together naturally: worker-scoped fixtures handle expensive authentication once, test-scoped fixtures provide isolated pages, and a simple beforeEach handles per-test navigation. Each concern lives in exactly one place.
Frequently Asked Questions
What is the difference between Playwright fixtures and hooks?
Fixtures are dependency-injected setup/teardown units that are lazy, composable, and automatically scoped. Hooks (beforeAll, beforeEach, afterAll, afterEach) are lifecycle callbacks that run at specific points regardless of whether a test needs them. Fixtures are preferred for reusable infrastructure like authenticated pages, API clients, and database connections. Hooks are better for simple, file-scoped setup like navigating to a URL or clearing a cache.
How do I create a custom fixture in Playwright?
Use test.extend() to create custom fixtures. Define a new test object with your fixture name and a function that sets up the resource, yields it via the use callback, and optionally tears it down afterward. Import the extended test object in your spec files instead of the default one from @playwright/test. See the custom fixtures section above for complete examples.
When should I use beforeAll vs beforeEach in Playwright?
Use beforeEach when you need fresh setup before every individual test — like navigating to a page or resetting form state. Use beforeAll when you need expensive one-time setup shared across all tests in a file — like seeding a database, starting a mock server, or creating test data via API. Remember that beforeAll runs once per worker, so its state is shared across tests in the same file on that worker.
What are worker-scoped fixtures in Playwright?
Worker-scoped fixtures are created once per worker process and shared across all tests on that worker, rather than being recreated for each test. Define them by adding { scope: 'worker' } to your fixture options. They are ideal for expensive resources like database connections, authentication tokens, or mock servers. Worker-scoped fixtures cannot depend on test-scoped fixtures.
Can I use fixtures and hooks together in Playwright?
Yes. A common and recommended pattern is using fixtures for reusable infrastructure (authenticated user, API client, database) and hooks for simple per-file setup (navigating to a specific page, setting a viewport). Avoid duplicating the same setup logic in both — pick one approach per concern. Fixtures are generally preferred because they are more composable and only run when a test actually needs them.
How do I share state between tests using Playwright fixtures?
Use worker-scoped fixtures to share expensive state like database connections or auth tokens across tests. For test data, use beforeAll to create shared data and store it in a variable accessible by all tests in the file. Avoid sharing mutable state between tests — each test should be independent. If tests need shared login state, use storageState to save and restore browser authentication cookies and local storage across tests without re-logging in.
Asim Noaman
Senior QA Automation Engineer & AI Testing Specialist
With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.