Fixtures are the better alternative to beforeEach/afterEach. They're composable, on-demand, and automatically cleaned up. Once you understand them, you won't go back to setup hooks.
What fixtures are
use() is the yield point — setup before, teardown after
A fixture is a function that prepares something for a test and cleans it up after. The split is at await use(value) — everything before is setup, everything after is teardown. When you request a fixture in a test's argument list, Playwright runs it automatically and passes the value.
Built-in fixtures you already use: page, context, browser, request. They all work this way — Playwright creates them before the test and cleans up after.
Why fixtures beat beforeEach
With beforeEach, setup and teardown are in separate blocks that have to share state through outer variables. The test has no way to know what was set up without reading all the hooks. Fixtures solve this: setup, value, and teardown are all in one place, and the test explicitly declares what it needs in its argument list.
Another advantage: fixtures are on-demand. If a test doesn't need loggedInPage, Playwright doesn't create it. With beforeEach, it runs for every test whether or not the test needs what it sets up.
ts
// ❌ З beforeEach — setup розкиданий по хукахlet ordersPage: OrdersPage
test.beforeEach(async ({ page }) => {
ordersPage = new OrdersPage(page)
await ordersPage.goto()
await ordersPage.seedTestOrders()
})
test.afterEach(async () => {
await ordersPage.cleanup()
})
test('filter by status', async () => {
await ordersPage.filterByStatus('pending')
// ...
})
// ✅ З fixtures — все разом, явноconsttest = base.extend<{ ordersPage: OrdersPage }>({
ordersPage: async ({ page }, use) => {
const ordersPage = new OrdersPage(page)
await ordersPage.goto()
await ordersPage.seedTestOrders()
await use(ordersPage) // <-- тут тест запускаєтьсяawait ordersPage.cleanup()
},
})
test('filter by status', async ({ ordersPage }) => {
await ordersPage.filterByStatus('pending')
// ...
})
Create custom fixtures
Use test.extend() to add fixtures. Define types for your fixtures, then implement each one. Export the extended test and expect — your test files import from this instead of @playwright/test.
ts
// fixtures/index.tsimport { test as base, expect } from'@playwright/test'import { OrdersPage } from'../pages/orders-page'import { LoginPage } from'../pages/login-page'type Fixtures = {
ordersPage: OrdersPage
loggedInPage: Page
}
exportconsttest = base.extend<Fixtures>({
// Фікстура що залежить від page
ordersPage: async ({ page }, use) => {
const orders = new OrdersPage(page)
await orders.goto()
await use(orders)
// Нічого не прибираємо — кожен тест отримує свій ізольований page
},
// Фікстура що залежить від іншої фікстури
loggedInPage: async ({ page }, use) => {
await page.goto('/login')
await page.getByLabel('Email').fill('admin@example.com')
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL('/dashboard')
await use(page)
// page прибирається автоматично вбудованою фікстурою
},
})
export { expect }
ts
// tests/orders.spec.tsimport { test, expect } from'../fixtures'// Тест явно оголошує що йому потрібноtest('create order', async ({ ordersPage }) => {
await ordersPage.createOrder({ item: 'Laptop', quantity: 1 })
awaitexpect(ordersPage.orderList).toContainText('Laptop')
})
// Можна запитувати кілька фікстур одночасноtest('admin views all orders', async ({ loggedInPage, ordersPage }) => {
awaitexpect(ordersPage.orderList).toBeVisible()
})
Override built-in fixtures
You can override the built-in page fixture to add behavior that applies to every test — like auto-navigating to the app's base URL, or listening for console errors.
ts
// Перевизначити page — auto-navigate і збирати console errorsexportconsttest = base.extend({
page: async ({ baseURL, page }, use) => {
const consoleErrors: string[] = []
page.on('console', msg => {
if (msg.type() === 'error') consoleErrors.push(msg.text())
})
await page.goto(baseURL!)
await use(page)
// Після тесту — перевірити що не було JS помилокif (consoleErrors.length > 0) {
console.warn('Console errors during test:', consoleErrors)
}
},
})
Worker-scoped fixtures — share between tests
Worker-scoped fixtures are created once per worker process and shared across all tests in that worker — ideal for expensive DB connections or login sessions
By default, fixtures are test-scoped — created fresh for each test. Worker-scoped fixtures are created once per parallel worker and shared across all tests in that worker. Use this for expensive operations like database connections or browser-level authentication.
ts
// Авторизація один раз на worker, а не на кожний тестexportconsttest = base.extend({
workerAuthState: [
async ({ browser }, use) => {
// Це виконується один раз на workerconst page = await browser.newPage({ storageState: undefined })
await page.goto('/login')
await page.getByLabel('Email').fill(`worker${test.info().parallelIndex}@test.com`)
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL('/dashboard')
const state = await page.context().storageState()
await page.close()
await use(state) // всі тести у worker'і отримують цей стан
},
{ scope: 'worker' }, // <-- ключовий параметр
],
})