Without fixtures, every test starts with the same boilerplate: create a LoginPage, create a ProductListPage, create a CartPage. With 30 test files, that is 30 copies of the same setup. Fixtures inject page objects automatically. Your tests just declare what they need.
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
import { ProductListPage } from '../pages/product-list-page';
import { ProductDetailPage } from '../pages/product-detail-page';
import { CartPage } from '../pages/cart-page';
import { CheckoutPage } from '../pages/checkout-page';
import { OrderHistoryPage } from '../pages/order-history-page';
import { testUsers } from '../test-data/users';
type PageFixtures = {
loginPage: LoginPage;
productListPage: ProductListPage;
productDetailPage: ProductDetailPage;
cartPage: CartPage;
checkoutPage: CheckoutPage;
orderHistoryPage: OrderHistoryPage;
};
type AuthFixtures = {
authenticatedPage: LoginPage;
};
export const test = base.extend<PageFixtures & AuthFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
productListPage: async ({ page }, use) => {
await use(new ProductListPage(page));
},
productDetailPage: async ({ page }, use) => {
await use(new ProductDetailPage(page));
},
cartPage: async ({ page }, use) => {
await use(new CartPage(page));
},
checkoutPage: async ({ page }, use) => {
await use(new CheckoutPage(page));
},
orderHistoryPage: async ({ page }, use) => {
await use(new OrderHistoryPage(page));
},
authenticatedPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(testUsers.standard.email, testUsers.standard.password);
await loginPage.expectLoggedIn();
await use(loginPage);
},
});
export { expect };Most tests need the user to be logged in. Instead of repeating the login flow in every test, the authenticatedPage fixture logs in once. Any test that requests authenticatedPage gets a page that is already authenticated.
import { test, expect } from '../../fixtures/test-fixtures';
// authenticatedPage logs in automatically before each test
test.describe('Cart Management', () => {
test('add item to cart', async ({ authenticatedPage, productListPage, productDetailPage, cartPage }) => {
await productListPage.goto();
await productListPage.openProduct(0);
await productDetailPage.addToCart();
await productDetailPage.goToCart();
await expect(cartPage.cartItems).toHaveCount(1);
});
test('empty cart shows message', async ({ authenticatedPage, cartPage }) => {
await cartPage.goto();
await cartPage.expectEmpty();
});
});Logging in through the UI for every test is slow. Playwright supports saving authentication state (cookies, localStorage) after one login and reusing it. This can save 2-3 seconds per test.
import { test as setup } from '@playwright/test';
import { testUsers } from './test-data/users';
const authFile = '.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(testUsers.standard.email);
await page.getByLabel('Password').fill(testUsers.standard.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: authFile });
});projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
],The setup project runs first, logs in once, saves the storage state to .auth/user.json. The chromium project loads that state for every test. One login for the entire test suite. Add .auth/ to .gitignore -- it contains session tokens.
Q: What are Playwright fixtures and how do they improve test maintainability?
A: Fixtures are Playwright's dependency injection system. You extend the base test object with custom fixtures that provide page objects, authenticated sessions, or test data. Tests declare what they need as function parameters and Playwright creates/destroys them automatically. This eliminates setup boilerplate, ensures consistent initialization, and enables powerful patterns like storage state authentication (login once, reuse across all tests). The key benefit: changing how a page object is created means updating one fixture, not every test file.
Key Point: Fixtures eliminate boilerplate by auto-injecting page objects. Use authenticatedPage for pre-logged-in state. For even faster runs, use storage state to login once and reuse the session across all tests.
Key Point: Custom fixtures auto-inject page objects and handle authentication -- use storage state for one-login-per-suite performance