Test Structure and Page Objects
One recorded test is easy. Twenty tests that share a login form, a product list and a set of test accounts need structure, or every UI change becomes twenty edits. This is the script enhancement step: naming, grouping, and moving shared pieces out of the tests.
Naming
Files
One spec file per screen or flow: login.spec.ts, cart.spec.ts, checkout.spec.ts. Not one giant file, and not one file per test.
Tests
Name the test after the behaviour and the expected outcome. Read the describe and the test name together as a sentence:
test.describe('Login', () => {
test('valid user lands on the products page', ...);
test('locked out user sees an error', ...);
test('empty form shows a required error', ...);
});
In the report this shows as Login › valid user lands on the products page. Anyone can read that. Compare test1, test2.
Grouping and Hooks
test.describe('Login', () => {
test.beforeEach(async ({ loginPage }) => {
await loginPage.goto();
});
test('valid user lands on the products page', async ({ loginPage, page }) => {
// ...
});
});
| Hook | Runs | Use for |
|---|---|---|
beforeEach | Before every test in the group | Navigation, small setup |
afterEach | After every test | Cleanup of data the test created |
beforeAll / afterAll | Once per worker for the group | Expensive setup shared by all tests. Careful: state leaks between tests |
Keep hooks short. A beforeEach that logs in, creates data and navigates three screens deep hides what each test actually depends on.
Steps
For a long test, test.step groups actions in the report and the trace, like a Transaction Controller:
await test.step('add two items', async () => {
await inventoryPage.addToCart('Sauce Labs Backpack');
await inventoryPage.addToCart('Sauce Labs Bike Light');
});
Page Objects
A page object is a class that owns the locators and the actions for one screen. Tests call the actions and assert on the result. When the screen changes, you change the class, not the tests.
playwright/sample/tests/pages/LoginPage.ts:
import { expect, type Locator, type Page } from '@playwright/test';
export class LoginPage {
readonly username: Locator;
readonly password: Locator;
readonly loginButton: Locator;
readonly error: Locator;
constructor(readonly page: Page) {
this.username = page.getByPlaceholder('Username');
this.password = page.getByPlaceholder('Password');
this.loginButton = page.getByRole('button', { name: 'Login' });
this.error = page.getByTestId('error');
}
async goto() {
await this.page.goto('/');
}
async login(username: string, password: string) {
await this.username.fill(username);
await this.password.fill(password);
await this.loginButton.click();
}
async expectError(message: string) {
await expect(this.error).toContainText(message);
}
}
Rules I follow:
- Locators are fields, built once in the constructor. They are lazy, so this costs nothing.
- Methods are user actions:
login(),addToCart(). NotclickLoginButton(). - Assertions about the screen itself can live in the page object (
expectError). Assertions about the outcome of the flow live in the test, so the test reads as a spec. - No test data inside the class. Usernames and passwords come from the test.
InventoryPage.ts shows the filtering pattern for repeated cards:
item(name: string) {
return this.page.getByTestId('inventory-item').filter({ hasText: name });
}
async addToCart(name: string) {
await this.item(name).getByRole('button', { name: 'Add to cart' }).click();
}
Custom Fixtures
Creating new LoginPage(page) at the top of every test is noise. A fixture hands the test a ready object by name.
playwright/sample/tests/fixtures.ts:
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { InventoryPage } from './pages/InventoryPage';
export const test = base.extend<{ loginPage: LoginPage; inventoryPage: InventoryPage }>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
inventoryPage: async ({ page }, use) => {
const inventoryPage = new InventoryPage(page);
await inventoryPage.goto();
await use(inventoryPage);
},
});
export { expect } from '@playwright/test';
Then every spec imports test and expect from ./fixtures instead of from @playwright/test:
import { test, expect } from './fixtures';
test('adding an item updates the cart badge', async ({ inventoryPage }) => {
await inventoryPage.addToCart('Sauce Labs Backpack');
await expect(inventoryPage.cartBadge).toHaveText('1');
});
The inventoryPage fixture already navigated to the products page. The test is three lines and reads like the requirement.
Tip: Code before
use()is setup, code after it is teardown. A fixture that creates a record via API beforeuse()and deletes it after is the cleanest way to give each test its own data.
Test Data
Keep accounts and inputs in a JSON file, not in the tests:
playwright/sample/tests/data/users.json:
{
"standard": { "username": "standard_user", "password": "secret_sauce" },
"lockedOut": { "username": "locked_out_user", "password": "secret_sauce" }
}
import users from './data/users.json';
await loginPage.login(users.standard.username, users.standard.password);
TypeScript imports JSON directly with resolveJsonModule, which Playwright's default TypeScript setup already enables.
For secrets (real staging passwords), use environment variables and a gitignored .env file instead of JSON. The generated config has a commented dotenv block for exactly this.
A Missing await Is a Silent Bug
// Wrong: the assertion is never awaited, the test passes even if the badge says 9
expect(inventoryPage.cartBadge).toHaveText('1');
// Right
await expect(inventoryPage.cartBadge).toHaveText('1');
Every action and every web-first assertion returns a promise. Without await, the test moves on and finishes before the check runs. TypeScript does not complain. The ESLint rule @typescript-eslint/no-floating-promises does, which is why Section 1 recommends it.
Tips
-
One page object per screen, not per test. If two tests need the same locator, it belongs in a page object.
-
Start without page objects. For the first two or three tests, inline locators are fine. Extract when you see the same locator twice.
-
Fixtures over
beforeEachwhen the setup is "give me a ready object".beforeEachfor "navigate here first". -
Do not build a framework. Base classes, abstract page factories and helper layers on top of Playwright are the UI-test version of over-engineering. The runner already gives you fixtures, projects and reporters.