As automation engineers, we constantly strive for cleaner, more maintainable, and highly efficient test suites. Repetitive setup code, complex beforeEach
hooks, and duplicated login logic can quickly turn a promising test framework into a tangled mess. This is where Playwright's custom fixtures shine, offering a powerful and elegant solution to encapsulate setup and teardown logic, share state, and create a truly modular test architecture.
If you're looking to elevate your Playwright test automation, understanding and leveraging custom fixtures is an absolute must. Let's dive in!
What are Playwright Fixtures?
At its core, a Playwright fixture is a way to set up the environment for a test, providing it with everything it needs and nothing more. You've already encountered them: page
, browser
, context
, request
, browserName
– these are all built-in Playwright fixtures. When you write async ({ page }) => { ... }
, you're telling Playwright to "fix up" a page
object and provide it to your test.
Why are fixtures superior to traditional beforeEach
/afterEach
hooks?
Encapsulation: Setup and teardown logic are kept together in one place, making it easier to understand and maintain.
Reusability: Define a fixture once, use it across multiple test files. No more copying-pasting common helper functions.
On-Demand: Playwright only runs fixtures that a test explicitly requests, optimizing execution time.
Composability: Fixtures can depend on other fixtures, allowing you to build complex test environments incrementally.
Isolation: Each test gets a fresh, isolated environment (by default), preventing test interdependencies and flakiness.
Creating Your First Custom Fixture: The loggedInPage
Example
Let's imagine a common scenario: many of your tests require a user to be logged into the application. Repeating the login steps in every test is inefficient and brittle. This is a perfect use case for a custom fixture.
First, let's create a dedicated file for our custom fixtures, conventionally named fixtures/my-fixtures.ts
(or .js
):
fixtures/my-fixtures.ts
import { test as base } from '@playwright/test';
// Declare the types of your fixtures.
// This provides type safety and autocompletion for your custom fixture.
type MyFixtures = {
loggedInPage: Page; // Our custom fixture will provide a Playwright Page object
};
// Extend the base Playwright test object.
// The first generic parameter {} is for worker-scoped fixtures (we'll cover this later).
// The second generic parameter MyFixtures declares our test-scoped fixtures.
export const test = base.extend<MyFixtures>({
// Define our custom 'loggedInPage' fixture
loggedInPage: async ({ page }, use) => {
// --- Setup Logic (runs BEFORE the test) ---
console.log('--- Setting up loggedInPage fixture ---');
// Perform login steps
await page.goto('https://www.example.com/login'); // Replace with your login URL
await page.fill('#username', 'testuser');
await page.fill('#password', 'Test@123');
await page.click('#login-button');
// You might add an assertion here to ensure login was successful
await page.waitForURL('**/dashboard'); // Wait for the dashboard page after login
// Use the fixture value in the test.
// Whatever value you pass to 'use()' will be available to the test.
await use(page);
// --- Teardown Logic (runs AFTER the test) ---
console.log('--- Tearing down loggedInPage fixture ---');
// For a 'page' fixture, usually Playwright handles closing the page/context.
// But if you opened a new browser context or created temporary data, you'd clean it up here.
// Example: logging out (though often not strictly necessary for test isolation with fresh contexts)
// await page.click('#logout-button');
},
});
// Re-export Playwright's expect for convenience when using this custom test object
export { expect } from '@playwright/test';
Now, instead of importing test
from @playwright/test
in your spec files, you'll import it from your custom fixture file:
tests/dashboard.spec.ts
import { test, expect } from '../fixtures/my-fixtures'; // Import your extended test
test('should display dashboard content after login', async ({ loggedInPage }) => {
// 'loggedInPage' is already logged in, thanks to our fixture!
await expect(loggedInPage.locator('.welcome-message')).toHaveText('Welcome, testuser!');
await expect(loggedInPage.locator('nav.dashboard-menu')).toBeVisible();
});
test('should navigate to settings from logged-in page', async ({ loggedInPage }) => {
await loggedInPage.click('a[href="/settings"]');
await expect(loggedInPage).toHaveURL('**/settings');
await expect(loggedInPage.locator('h1')).toHaveText('User Settings');
});
// You can still use built-in fixtures alongside your custom ones
test('should work with a fresh page without login', async ({ page }) => {
await page.goto('https://www.example.com/public-page');
await expect(page.locator('h1')).toHaveText('Public Information');
});
When you run npx playwright test
, Playwright will:
See that
dashboard.spec.ts
importstest
frommy-fixtures.ts
.For the first test, it sees
loggedInPage
requested. It executes theloggedInPage
fixture's setup logic (login steps).It then runs the test, providing the
page
object that was logged in.After the test, it executes the
loggedInPage
fixture's teardown logic (if any).For the third test, it sees
page
requested. It uses Playwright's defaultpage
fixture setup.
Advanced Fixture Concepts
1. Fixture Scopes: test
vs. worker
Fixtures can have different scopes, dictating how often their setup and teardown logic runs:
'test'
(Default): The fixture is set up before each test that uses it and torn down after that test finishes. This ensures complete isolation between tests. Ideal for state specific to one test (e.g., a specificpage
instance, a unique temporary user account).'worker'
: The fixture is set up once per worker process (Playwright runs tests in parallel using workers) before any tests in that worker run, and torn down after all tests in that worker have completed. Ideal for expensive resources that can be shared across multiple tests (e.g., a database connection pool, an API client, a mock server).
Example: Worker-Scoped apiClient
Fixture
Let's create a worker
-scoped fixture for an API client, useful if many tests interact with the same API.
fixtures/my-fixtures.ts
(updated)
import { test as base, Page } from '@playwright/test';
import { APIClient } from './api-client'; // Assuming you have an APIClient class
// Declare types for both test-scoped and worker-scoped fixtures
type MyTestFixtures = {
loggedInPage: Page;
};
type MyWorkerFixtures = {
apiClient: APIClient;
};
export const test = base.extend<MyTestFixtures, MyWorkerFixtures>({
// Worker-scoped fixture for API client
apiClient: [async ({}, use) => {
// --- Setup Logic (runs ONCE per worker) ---
console.log('--- Setting up apiClient (worker scope) ---');
const client = new APIClient('https://api.example.com'); // Initialize your API client
await client.authenticate('admin', 'secret'); // Or perform global API setup
await use(client); // Provide the authenticated API client to tests
// --- Teardown Logic (runs ONCE per worker after all tests) ---
console.log('--- Tearing down apiClient (worker scope) ---');
// Disconnect API client, clean up global resources
await client.disconnect();
}, { scope: 'worker' }], // Specify the 'worker' scope here
// Your existing test-scoped loggedInPage fixture
loggedInPage: async ({ page, apiClient }, use) => { // loggedInPage can depend on apiClient!
console.log('--- Setting up loggedInPage fixture ---');
// Example of using apiClient from within loggedInPage fixture
const userCredentials = await apiClient.getUserCredentials('testuser');
await page.goto('https://www.example.com/login');
await page.fill('#username', userCredentials.username);
await page.fill('#password', userCredentials.password);
await page.click('#login-button');
await page.waitForURL('**/dashboard');
await use(page);
console.log('--- Tearing down loggedInPage fixture ---');
},
});
export { expect } from '@playwright/test';
Now, your apiClient
will only be initialized and torn down once per worker, saving significant time if you have many API-dependent tests.
2. Auto-Fixtures (auto: true
)
Sometimes you want a fixture to run for every test that uses your extended test object, without explicitly declaring it in each test function. This is where auto: true
comes in handy.
Use cases for auto: true
:
Global logging setup/teardown.
Starting/stopping a mock server for all tests.
Ensuring a clean state (e.g., clearing browser storage) before every test.
Example: Clearing Local Storage Automatically
fixtures/my-fixtures.ts
(updated)
import { test as base, Page } from '@playwright/test';
// ... (MyTestFixtures, MyWorkerFixtures types and apiClient fixture from above)
export const test = base.extend<MyTestFixtures, MyWorkerFixtures>({
// ... (apiClient and loggedInPage fixtures)
// Auto-fixture to clear local storage before each test
clearLocalStorage: [async ({ page }, use) => {
console.log('Clearing local storage before test...');
await page.evaluate(() => localStorage.clear());
await use(); // The 'use' function is called without a value if the fixture itself doesn't provide one.
console.log('Local storage clean up complete.');
}, { auto: true }], // This fixture will run automatically for all tests
});
export { expect } from '@playwright/test';
Now, every test that imports test
from my-fixtures.ts
will automatically have its local storage cleared before execution, ensuring a clean state.
3. Overriding Built-in and Custom Fixtures
Playwright allows you to override existing fixtures, including built-in ones. This is incredibly powerful for customising behavior.
Example: Overriding page
to Automatically Navigate to baseURL
You might want every page
instance to automatically navigate to your baseURL
defined in playwright.config.ts
.
fixtures/my-fixtures.ts
(updated)
import { test as base, Page } from '@playwright/test';
// ... (MyTestFixtures, MyWorkerFixtures types)
export const test = base.extend<MyTestFixtures, MyWorkerFixtures>({
// Override the built-in 'page' fixture
page: async ({ page, baseURL }, use) => {
if (baseURL) {
await page.goto(baseURL); // Automatically go to baseURL
}
await use(page); // Pass the configured page to the test
},
// ... (Other custom fixtures like loggedInPage, apiClient, clearLocalStorage)
});
export { expect } from '@playwright/test';
Now, in any test that uses { page }
, it will automatically navigate to your baseURL
before the test code executes, reducing boilerplate.
4. Parameterizing Fixtures with Option Fixtures
Sometimes, you need to configure a fixture based on specific test requirements or global settings. Playwright provides "option fixtures" for this.
Example: user
Fixture with Configurable role
Let's create a user
fixture that provides user data, and we want to configure the user's role.
fixtures/my-fixtures.ts
(updated)
import { test as base, Page } from '@playwright/test';
type UserRole = 'admin' | 'editor' | 'viewer';
type MyTestFixtures = {
// Our new custom user fixture
user: { name: string; email: string; role: UserRole; };
loggedInPage: Page;
};
type MyWorkerFixtures = {
apiClient: APIClient;
};
// Define an "option fixture" for the user role
// The value of this option can be overridden in playwright.config.ts or per test file
type MyOptionFixtures = {
userRole: UserRole;
};
export const test = base.extend<MyTestFixtures, MyWorkerFixtures, MyOptionFixtures>({
// Define the option fixture with a default value
userRole: ['viewer', { option: true }],
// The 'user' fixture depends on the 'userRole' option fixture
user: async ({ userRole }, use) => {
let userData: { name: string; email: string; role: UserRole; };
switch (userRole) {
case 'admin':
userData = { name: 'Admin User', email: 'admin@example.com', role: 'admin' };
break;
case 'editor':
userData = { name: 'Editor User', email: 'editor@example.com', role: 'editor' };
break;
case 'viewer':
default:
userData = { name: 'Viewer User', email: 'viewer@example.com', role: 'viewer' };
break;
}
await use(userData);
},
// ... (Other custom fixtures like loggedInPage, apiClient, clearLocalStorage)
// Ensure loggedInPage now uses the 'user' fixture for credentials
loggedInPage: async ({ page, apiClient, user }, use) => {
console.log(`--- Setting up loggedInPage for ${user.role} user ---`);
// Example: You might use user.email and user.password (if stored in user fixture) for login
// Or simulate API login with apiClient based on user.role
await page.goto('https://www.example.com/login');
await page.fill('#username', user.email); // Assuming email is username
await page.fill('#password', 'shared-password'); // Or get from user fixture
await page.click('#login-button');
await page.waitForURL('**/dashboard');
await use(page);
console.log('--- Tearing down loggedInPage fixture ---');
},
});
export { expect } from '@playwright/test';
Now, in your tests, you can easily switch user roles:
tests/user-roles.spec.ts
import { test, expect } from '../fixtures/my-fixtures';
// This test will use the default 'viewer' role
test('viewer user should see dashboard with limited options', async ({ loggedInPage, user }) => {
expect(user.role).toBe('viewer');
await expect(loggedInPage.locator('.admin-panel')).not.toBeVisible();
});
// This test will override the 'userRole' option to 'admin'
test.describe('Admin user tests', () => {
// Override the userRole for all tests in this describe block
test.use({ userRole: 'admin' });
test('admin user should see admin panel', async ({ loggedInPage, user }) => {
expect(user.role).toBe('admin');
await expect(loggedInPage.locator('.admin-panel')).toBeVisible();
});
test('admin user can create new items', async ({ loggedInPage }) => {
await loggedInPage.click('button.create-new-item');
// ... test item creation
});
});
// You can also override the option in playwright.config.ts for entire projects:
// In playwright.config.ts:
// projects: [
// {
// name: 'admin-tests',
// use: { userRole: 'admin' },
// },
// {
// name: 'viewer-tests',
// use: { userRole: 'viewer' },
// },
// ]
Best Practices for Custom Fixtures
DRY (Don't Repeat Yourself): If you find yourself writing the same setup code more than twice, consider a fixture.
Single Responsibility: Each fixture should ideally have one clear purpose.
Type Safety: Always declare the types for your custom fixtures using TypeScript to benefit from autocompletion and error checking.
Granularity: Create smaller, focused fixtures that can be composed, rather than one giant "god" fixture.
Dependency Management: Leverage fixture dependencies effectively. If
FixtureB
needsFixtureA
, simply includeFixtureA
inFixtureB
's parameters.Clear Naming: Give your fixtures descriptive names.
Scope Wisely: Choose
test
orworker
scope based on whether the resource needs to be isolated per test or shared across tests in a worker.Prioritize Teardown: Ensure your teardown logic is robust, especially for external resources (e.g., database connections, temporary files).
Version Control: Store your custom fixture files in a well-organized directory (e.g.,
fixtures/
) within your test project.
Conclusion
Playwright custom fixtures are more than just a way to manage setup and teardown; they are a fundamental building block for a scalable, maintainable, and readable test automation framework. By mastering their use, you can:
Reduce boilerplate code and improve test readability.
Enhance test isolation and reduce flakiness.
Optimize test execution speed by sharing expensive resources.
Empower your team to write more effective and consistent tests.
Start by identifying repetitive setup tasks in your existing test suite and gradually refactor them into custom fixtures. You'll quickly see the immense value they bring to your Playwright automation journey.
Happy coding and even happier testing!
0 comments:
Post a Comment