Open your test files and count how many times you see this:
await page.goto('/');
await page.getByLabel('Email').fill('demo@nimbus.app');
await page.getByLabel('Password').fill('secure123');
await page.getByRole('button', { name: 'Sign in' }).click();
In a real project, thirty times. When the login form changes, you edit thirty files.
A developer once added a "Remember me" checkbox to our login page. Fifteen minutes of work for him. It took me most of a day to fix the tests, because those login steps were copied everywhere.
You already know { page } is a fixture: something Playwright prepares for your test and passes in. You can write your own.
import { test as base } from '@playwright/test';
export const test = base.extend<{ loggedInPage: Page }>({
loggedInPage: async ({ page }, use) => {
await page.goto('/');
await page.getByLabel('Email').fill('demo@nimbus.app');
await page.getByLabel('Password').fill('secure123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('welcome-message')).toBeVisible();
await use(page); // the test runs here
// anything after use() runs as cleanup
},
});
The use(page) line is the important part. Everything above it runs before your test. Then your test runs. Then everything below it runs, even if the test failed. That is where cleanup goes.
Note the assertion before use. Always check that your setup worked. Without it, a broken login makes thirty tests fail with confusing errors about missing elements. With it, the failure says "welcome message not visible" and you know instantly where to look.
Your tests now say what they are about:
test('shows the contact list', async ({ loggedInPage }) => {
await expect(loggedInPage.getByTestId('contact-row')).toHaveCount(8);
});
One rule: do not use loggedInPage in the test that checks login itself. That test must show every step, because the steps are what it tests.
Remember this: fixtures give a test what it needs and clean up after it, even when the test fails.
Reference: Fixtures.