Playwright fixtures is very useful and a kind of confusing tool at the same time. If you are a beginner and you open the documentation, it quickly becomes overwhelming. What is it for? How do I use it? If you feel the same, stick with me. I will show you how to use Playwright fixtures with a very simple demonstration, without running Playwright tests at all. We will just print different messages to the console using the Playwright framework. And that way you will understand how fixtures work.
Let's dive in.
What Are Playwright Test Fixtures?
Think of fixtures as functions that run some code for you before a test or after a test. If you've used beforeEach and afterEach hooks, you already get the idea. Fixtures do the same thing, but with some extra features that make them better than just hooks.
First, fixtures only run when a test actually calls them. If a test doesn't reference a fixture, that fixture never executes (except auto-fixtures). Second, fixtures can pass values directly into your tests. Instead of setting up shared variables in a beforeEach, the fixture hands data to the test through a callback. Third, fixtures can depend on other fixtures, and you can scope them to run once per worker instead of once per test.
The official Playwright docs cover all of this, but let's build up to it step by step.
Creating Your First Playwright Fixture
Let me start with the setup we all know. We have a beforeEach and two tests. The beforeEach is a hook that runs before every test.
import { test } from '@playwright/test';test.beforeEach(async () => { console.log('Hello World');});test('test one', async () => { console.log('Where is my candy?');});test('test two', async () => { console.log('I am alive');});
If I run the first test, we see "Hello World", "Where is my candy?". If I run the second test, "Hello World", "I am alive". Simple.
Why am I showing this to you? Because test fixtures are exactly the same thing. The difference is that fixtures have some cool features that make the functionality a bit better than just beforeEach hooks. So let's refactor this code and replace the beforeEach with a fixture.
Create a new file called fixture.ts. You can call the fixture file however you want and put it in any folder that makes sense in your project.
import { test as base } from '@playwright/test';type MyFixtures = { helloWorld: string;};export const test = base.extend<MyFixtures>({ helloWorld: async ({}, use) => { console.log('Hello World'); await use(); console.log('Goodbye'); },});
Now update your test file to import test from your fixture file instead of from @playwright/test. And we can safely remove the beforeEach.
import { test } from './fixture';test('test one', async ({ helloWorld }) => { console.log('Where is my candy?');});
Let me break down the syntax. You import the original test object under a different name, base. Then base has a method called extend. Inside the extend object, you create your fixtures. Each fixture is an async function that receives two arguments: an object of dependencies (empty {} when there are none) and a use callback.
TypeScript will complain about types, so you need to define a type for your fixtures and pass it as a type parameter to extend. For this demo I'm using string, but it can be anything.
The use callback is the dividing line. Everything before it is your setup. Everything after it is your teardown. And then we export this modified test function from the fixture file.
Fixture Execution Order: Before and After Use
This is the concept that makes fixtures click. When you run the test above, here's what you see in the console.
Hello WorldWhere is my candy?Goodbye
Everything before await use() runs before the test. Everything after await use() runs after the test. One fixture replaces both beforeEach and afterEach.
Why? Because the use callback is where Playwright hands control to your test. Your fixture sets things up, calls use, Playwright runs the test, and then your fixture continues with the cleanup code. Setup and teardown live in a single function. The related code stays together.
If you've ever dealt with timeout issues during setup, having everything in one place makes debugging simpler.
How to Pass Values from Fixtures to Tests
Now let's say your fixture is not just a dummy console.log. It's something more useful, like a function that returns a result you want to use inside your test. How to use it?
Whatever you pass into the use callback becomes available inside your test. It can be a primitive, an object, an instance of a class, a result of a function call. It literally can be anything.
helloWorld: async ({}, use) => { const myWorld = 'Hello World'; await use(myWorld);},
And the test uses it like this.
test('test one', async ({ helloWorld }) => { console.log(helloWorld); console.log('Where is my candy?');});
The output:
Hello WorldWhere is my candy?
But if I swap the two console.log lines. I get "Where is my candy?" first and "Hello World" second. We drive execution not by the order in the fixture. The fixture still returns the value, but we call it later in the test. With beforeEach, you don't have that kind of control.
Fixture Dependencies in Playwright
Since fixtures are objects of key-value pairs, you can create multiple fixtures separated by commas. And in Playwright, you can create dependencies between them. One fixture can call another fixture.
Let's add a second fixture called greatDay that depends on helloWorld.
type MyFixtures = { helloWorld: string; greatDay: string;};export const test = base.extend<MyFixtures>({ helloWorld: async ({}, use) => { const myWorld = 'Hello World'; await use(myWorld); console.log('Goodbye'); }, greatDay: async ({ helloWorld }, use) => { const myDay = helloWorld + ', what a great day'; await use(myDay); },});
To create the dependency, I take the helloWorld fixture and pass it as an argument into the greatDay fixture. Now I can use it inside the body.
test('test one', async ({ greatDay }) => { console.log(greatDay);});// Output: "Hello World, what a great day"
The greatDay fixture called the helloWorld fixture, got the result, concatenated some text, and passed it down into our test. The teardown order is reversed. First the greatDay cleanup runs, then helloWorld's "Goodbye" prints. That's how cascading teardown works.
And by the way, you can pass as many fixtures into your fixture as you want. You can also pass built-in Playwright fixtures like page and request into your custom fixtures.
myFixture: async ({ page, request }, use) => { await page.goto('https://example.com'); await use(page);},
So you can navigate to a page, or you can call the request fixture and send some API calls inside of your custom fixture. You can combine several fixtures inside of a single fixture. I think you see where this is going :)
Worker-Scoped Fixtures for Parallel Testing
Now let me show you one last, very useful example: worker-scoped fixtures.
What is a worker? Think about it as a single executor of your tests. When you run your tests in parallel, Playwright triggers several workers to execute your tests. Separating your tests by worker scope is very useful to avoid concurrency issues. For example, when you are trying to use the same account in parallel tests, you may have problems because the same account may stumble on the same test data, making your tests flaky.
Worker-scoped fixtures solve this. They run once when a worker starts and stay active for every test that worker executes.
type WorkerFixture = { cupOfCoffee: string;};export const test = base.extend<MyFixtures, WorkerFixture>({ // ...test-scoped fixtures cupOfCoffee: [async ({}, use, workerInfo) => { const cup = 'Cup of coffee number ' + workerInfo.workerIndex; await use(cup); }, { scope: 'worker' }],});
The syntax is a bit different. Who created this syntax? You wrap the fixture in an array. The first element is your async function, and the second element is { scope: 'worker' }. Worker-scoped fixtures also have a third parameter, workerInfo, and you can call workerInfo.workerIndex to get the index of the current worker.
If you run two tests in parallel across two workers, you see:
Cup of coffee number 0Cup of coffee number 1
Zero and one represent the worker's index. Each worker used an individual thread.
How can you use it? Using this worker index, you can pull a test account by its index. Let's say you save an object with test accounts inside your project, and based on the index, you pull the right account to run your test. That way you avoid concurrency issues. Or you can use the worker index to randomly create a unique account.
In the Playwright API Testing Mastery course, I use this feature to create an access token only once per worker. That way, we avoid hitting the auth endpoint for every test. If the test fails, Playwright recreates a new worker, which triggers a new token creation. The fresh token then gets used for the remaining tests.
Automatic Fixtures and Reading the Docs
One more fixture type worth knowing about. Automatic fixtures use { auto: true } instead of { scope: 'worker' } and run for every test, even if you didn't call them in your test. The syntax follows the same array pattern.
Now, after this explanation, when you go back and read through the Playwright fixture docs, it should not be that overwhelming. You can look into automatic fixtures, how to combine multiple fixtures when they're saved in different files, and so on.
Don't Overuse Playwright Fixtures
One of the things I want you to avoid. Don't use fixtures as page objects. I think this is a use case where fixtures are misused.
Use fixtures for high-level test setup. Authentication, test data creation, environment configuration, and API setup. The stuff that needs to happen before your test starts, and clean up after it finishes.
When you create too many fixtures, like 20 or 30 fixtures, it becomes hard to manage. So don't make fixtures everything. You still can use regular TypeScript functions. You still can use regular TypeScript classes with methods. Don't do everything with fixtures, OK? Don't overuse and over-abuse this stuff.
For locator strategies and page interactions, classes and functions remain the right tools. If you want to build a solid approach to test automation, check out Learn Playwright Automation for a structured learning path.
Final Thoughts
Fixtures are set up and teardown functions with extra power. On-demand execution, value passing, dependency chaining, and worker scoping. Start with a simple fixture that replaces a beforeEach, and add complexity only when your tests need it.
Microsoft Playwright is growing in popularity in the market very quickly and soon will be a mainstream framework. Get the new skills at Bondar Academy with the Playwright UI Testing Mastery program. Start from scratch and become an expert to increase your value on the market!
Frequently Asked Questions
What is the difference between Playwright fixtures and beforeEach hooks?
Fixtures only run when a test requests them, while beforeEach runs before every test. Fixtures also combine setup and teardown in a single function and can pass values directly to your tests.
How do you create a custom fixture in Playwright?
Import test as base from @playwright/test, define a type for your fixtures, and call base.extend(). Each fixture is an async function that receives dependencies and a use callback.
What is a worker-scoped fixture in Playwright?
A worker-scoped fixture runs once per worker process instead of once per test. You define it by wrapping the fixture in an array with { scope: 'worker' } as the second element. It's useful for shared setup in parallel test runs.
Can Playwright fixtures depend on other fixtures?
Yes. Pass the fixture name as an argument in the dependency object. Playwright resolves the chain automatically and runs them in the correct order.
Should I use Playwright fixtures instead of page objects?
No. Fixtures are best for test setup and teardown, like authentication and data creation. Page objects and regular TypeScript classes are still the right choice for organizing page interactions and assertions.
What does the use callback do in a Playwright fixture?
The use callback separates setup from teardown. Code before await use() runs before the test, code after runs after. Whatever value you pass to use(value) becomes available in the test.
