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

  1. getByRole with 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.
  2. getByLabel for form fields.
  3. getByTestId for anything genuinely hard to address.
  4. CSS, reluctantly.
  5. 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.only in a merged branch. forbidOnly: !!process.env.CI in 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.