A test suite written by one person can get away with anything. A suite written by five people needs conventions, or it turns into five suites in one folder. This is the structure I keep coming back to.
The layout
tests/
e2e/
checkout.spec.ts
login.spec.ts
search.spec.ts
api/
orders.spec.ts
fixtures/
index.ts # the custom test object everyone imports
auth.ts # logged-in page fixtures
data.ts # factories
pages/
checkout.page.ts
login.page.ts
utils/
api-client.ts
dates.ts
playwright.config.ts
The important rule: a spec file imports from fixtures, and nothing else imports from a spec file. Anything shared moves down a layer. It's a boring rule and it prevents the situation where deleting one test breaks four others.
Fixtures do the work page objects used to
Page objects are still useful for locators. But the setup and teardown that used to live in beforeEach blocks is better expressed as a fixture, because fixtures compose and beforeEach blocks don't.
// fixtures/index.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { CheckoutPage } from '../pages/checkout.page';
import { createUser, deleteUser, User } from './data';
type Fixtures = {
loginPage: LoginPage;
checkoutPage: CheckoutPage;
user: User;
authedPage: void;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
checkoutPage: async ({ page }, use) => {
await use(new CheckoutPage(page));
},
user: async ({ request }, use) => {
const user = await createUser(request);
await use(user);
await deleteUser(request, user.id); // always runs
},
authedPage: async ({ page, user, loginPage }, use) => {
await loginPage.goto();
await loginPage.login(user.email, user.password);
await use();
},
});
export { expect } from '@playwright/test';
The test that uses it stays almost empty:
import { test, expect } from '../fixtures';
test('a logged-in user can place an order', async ({ page, checkoutPage, authedPage }) => {
await checkoutPage.goto();
await checkoutPage.addItem('TEST-001');
await checkoutPage.submit();
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
Requesting authedPage is the whole login setup. Requesting user guarantees cleanup. Nobody has to remember either.
Page objects hold locators and small actions, not assertions
export class CheckoutPage {
constructor(private page: Page) {}
readonly submitButton = () => this.page.getByRole('button', { name: 'Place order' });
readonly total = () => this.page.getByTestId('order-total');
async goto() { await this.page.goto('/checkout'); }
async submit() { await this.submitButton().click(); }
}
Keeping expect out of page objects means a failing assertion points at a line in the spec file, where the intent is written down, rather than a line in a shared helper used by twenty tests.
Locators, in order of preference
getByRolewith an accessible name. It's how a user finds the element, and it fails loudly when the accessible name is missing, which is a bug worth knowing about.getByLabelfor form fields.getByTestIdfor anything genuinely hard to address.- CSS, reluctantly.
- XPath, essentially never.
Agreeing this order as a team is worth more than the specific ordering. Mixed strategies in one file are what make a suite hard to read.
Conventions that saved arguments
- One test, one user journey. If the name needs an "and", split it.
- No
test.onlyin a merged branch.forbidOnly: !!process.env.CIin the config enforces it. - Test titles describe user-visible behaviour, not implementation: "shows an error when the card is declined", not "handles 402".
- Everything runs in parallel from day one. Serial mode is an escape hatch that hides state leaks.