Creating page objects manually in every test file gets old fast. You write new LoginPage(page) in 20 test files. Playwright has a better way: custom fixtures. Define your page objects once, and they are available as test parameters everywhere. This is the professional way to do POM in Playwright.
You already use fixtures without knowing it. The page parameter in your tests? That is a fixture. The browser and context parameters? Also fixtures. Playwright creates them before your test and tears them down after. Custom fixtures let you add your own parameters -- like page objects.
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';
import { TransferPage } from '../pages/transfer.page';
// Define the types for your custom fixtures
type PageFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
transferPage: TransferPage;
};
// Extend the base test with your page objects
export const test = base.extend<PageFixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
dashboardPage: async ({ page }, use) => {
const dashboardPage = new DashboardPage(page);
await use(dashboardPage);
},
transferPage: async ({ page }, use) => {
const transferPage = new TransferPage(page);
await use(transferPage);
},
});
// Re-export expect so tests import everything from one place
export { expect } from '@playwright/test';// Import test and expect from YOUR fixture file, not @playwright/test
import { test, expect } from '../fixtures/pages.fixture';
test.describe('Banking with Fixtures', () => {
test('successful login', async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login('john@test.com', 'password123');
await dashboardPage.expectLoaded();
});
test('transfer money', async ({ loginPage, dashboardPage, transferPage }) => {
await loginPage.goto();
await loginPage.login('john@test.com', 'password123');
await dashboardPage.clickTransfer();
await transferPage.transferMoney('9876543210', '500');
await transferPage.expectSuccess();
});
test('logout works', async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login('john@test.com', 'password123');
await dashboardPage.logout();
await expect(loginPage.loginButton).toBeVisible();
});
});The most common mistake: importing test from '@playwright/test' instead of your fixture file. If you import from @playwright/test, your custom fixtures are not available. Always import from your fixture file.
Fixtures can do setup work before the test runs. Want every test to start logged in? Create a fixture that logs in before passing the page to the test.
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';
type AuthFixtures = {
authenticatedPage: DashboardPage;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// Setup: login before the test
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('john@test.com', 'password123');
// Create dashboard page and pass it to the test
const dashboardPage = new DashboardPage(page);
await dashboardPage.expectLoaded();
await use(dashboardPage);
// Teardown: logout after the test (optional)
await dashboardPage.logout();
},
});
export { expect } from '@playwright/test';import { test, expect } from '../fixtures/auth.fixture';
// Every test starts already logged in!
test('dashboard shows balance', async ({ authenticatedPage }) => {
const balance = await authenticatedPage.getBalance();
expect(balance).toMatch(/\$[\d,]+\.\d{2}/);
});
test('dashboard shows transactions', async ({ authenticatedPage }) => {
const count = await authenticatedPage.getTransactionCount();
expect(count).toBeGreaterThan(0);
});Fixtures are lazy by default. If a test destructures { loginPage, dashboardPage } but only uses loginPage, Playwright does not create dashboardPage. No wasted work. This makes it safe to define many fixtures without performance concerns.
Key Point: Fixtures are the professional way to inject page objects into tests. Define once in a fixture file, use everywhere. Import test from your fixture file, not from @playwright/test. Fixtures can include setup and teardown logic.
Q: How do you use Playwright fixtures with page objects?
A: I create a fixture file that extends the base test with my page object types. Each page object is defined as a fixture using the async ({ page }, use) pattern. Tests import test and expect from my fixture file instead of @playwright/test. This gives tests direct access to page objects as parameters -- { loginPage, dashboardPage, transferPage } -- without manually creating them. Fixtures are lazy, so unused page objects are not created. I also use fixtures with setup logic for authentication -- the fixture logs in before the test starts, so every test begins on the dashboard.
Key Point: Custom fixtures inject page objects as test parameters. Define once, use everywhere. Import test from your fixture file. Fixtures are lazy and support setup/teardown.