Writing Playwright Tests

This document describes standards for writing Playwright tests: how test files are structured, titled, tagged and commented, and how conditionals and loops are used within tests.

Standard Metadata

  • Area: Playwright
  • Owner: Technical Leadership group
  • Last reviewed: 2026-10-07
  • Next review due from: 2026-12-01
  • Enforcement: ESLint / code review
  • Status: Active

Lint Playwright code with the recommended config from eslint-plugin-playwright, with the extra rules below enabled. This catches many breaches of these standards automatically, before code review.

Extend the recommended config (plugin:playwright/recommended, also available as plugin:playwright/playwright-test, or flat/recommended for flat config), and add these rules:

rules: {
  // Conditional test.skip() is allowed, as described in the test.fixme() standard
  'playwright/no-skipped-test': ['warn', { allowConditional: true }],
  'playwright/require-top-level-describe': 'error',
  'playwright/require-tags': 'error',
  'playwright/no-commented-out-tests': 'warn',
},
Lint rule Enforces
expect-expect Playwright tests must have a strong assertion
no-standalone-expect Playwright assertions must only be made in the test body or hooks (partly: it does not catch assertions in page object functions)
no-conditional-in-test, no-conditional-expect Playwright tests must not use conditionals within the test body
no-wait-for-timeout, no-wait-for-selector Playwright tests should wait for a specific response or element, not a page load state
no-networkidle Playwright tests must not override the default waitUntil option
prefer-hooks-on-top Playwright hooks must be declared above the tests that use them
no-duplicate-hooks Playwright tests should use as few hooks as possible
no-nested-step Group the stages of a Playwright test into named steps with test.step() (partly: it only catches nested steps)
require-top-level-describe * Playwright tests must be wrapped in a describe block
require-tags * Playwright tests must be tagged by regression priority and system area
no-skipped-test, no-commented-out-tests * Known failing Playwright tests must be skipped using test.fixme()

* Not in the recommended config, so added in the rules above.

Playwright projects must use as const objects instead of enums · PW-10.2 · MUST · TEST

Playwright’s TypeScript support uses type stripping, which does not support enum. Instead of the TypeScript standard to use enums over fixed sets of constants, group a fixed set of constants into an as const object, with a type of the same name for its values:

export const MailtrapContentType = {
  HTML: 'html',
  TEXT: 'text',
} as const;

export type MailtrapContentType = typeof MailtrapContentType[keyof typeof MailtrapContentType];

Playwright tests must be wrapped in a describe block which gives a human readable description of the system area under test · PW-10.3 · MUST · TEST

Each test file must contain a test.describe() block where the name describes the system area or system behaviour under test.

Example split by system area:

// tests/e2e-tests/stock-room.spec.ts
test.describe('Stock room', { tag: '@stock-room' }, () => {
  test("Office admins can add a name to each store's stock room", async ({ stockRoomPage }) => { ... });

  test("Office admins can update the capacity of a store's stock room", async ({ stockRoomPage }) => { ... });
});

Where the tests are grouped by system behaviour, the describe block may be named after the PBI title or a generic summary of each of the test cases within the file:

// tests/e2e-tests/shopping-cart.spec.ts
test.describe('Adding over 30 items to the shopping cart #12345', { tag: '@shopping-cart' }, () => {
  test('Users can add up to 30 items into their shopping cart', async ({ cartPage }) => { ... });

  test("Users see the 'max cart size' message when more than 30 items are added", async ({ cartPage }) => { ... });
});

Use nested Playwright describe blocks to scope set-up needed by only some tests · PW-10.4 · SHOULD · TEST

Each test file should use as few describe blocks as possible. Multiple system areas or aspects of system behaviour should be split into different spec files.

Where only some of the tests in a file need additional set-up, such as creating data, use a nested describe block with its own hooks rather than running that set-up before every test. This reduces data creation and therefore speeds up automated test runs.

// Example products.spec.ts where a nested describe block is used to create a product, which is only used in the last 2 test cases
test.describe('Products pages', () => {
  let name: string, quantity: number;

  test.beforeAll('Get access token', async () => { ... });

  test.beforeEach('Navigate to the products page', async ({ loginPage, productsPage }) => { ... });

  test.afterEach('Perform data cleanup', async ({ api }) => { ... });

  test('As a full admin user, I can add a product', async ({ productsPage }) => { ... });

  test('As a full admin, I can search for a product that does not exist', async ({ productsPage }) => { ... });

  test.describe(() => {
    test.beforeEach('Create an Audacia product', async ({ api }) => { ... });

    test('As a full admin, I can search for an existing Audacia product', async ({ productsPage }) => { ... });

    test('As a full admin, I can remove an Audacia product', async ({ productsPage }) => { ... });
  });
});

Playwright test titles must describe a single expected behaviour · PW-10.5 · MUST · TEST

Give each test a clear, descriptive title that states the single behaviour it checks, so that anyone reading the title or a test report can tell what the system should or should not do.

❌ ✔️
'Login test' 'Users can log in with a valid username and password'
'Search works and results can be exported' Two tests: 'Users can search for products by manufacturer' and 'Users can export search results to CSV'

Playwright test titles should include the work item ID · PW-10.6 · SHOULD · TEST

Include the ID of the work item that defines the behaviour under test in the test title, so that a failing test can be traced back to its requirement or user story. Whether to include IDs, and in what format, may be decided per project.

test('Users can search for products by manufacturer #12345', { tag: '@high' }, async ({ productsPage }) => { ... });

Playwright tests must be tagged by regression priority and system area · PW-10.7 · MUST · TEST

Tag every Playwright test with its regression priority (such as high, medium or low) and the system area it covers, using Playwright’s built-in tagging, which does not need an additional package. Tags allow pipeline reports to be filtered and subsets of tests to be run, for example only the high priority tests, or only the tests for the area that has changed.

Tags on a test.describe() block apply to every test inside it, so the system area can be tagged once per describe block:

test.describe('Products', { tag: '@products' }, () => {
  test('Users can search for products by manufacturer', { tag: '@high' }, async ({ productsPage }) => { ... });
});

Run a subset of tests by tag with --grep, for example npx playwright test --grep @high.

Comments in Playwright tests must document business logic · PW-10.8 · MUST · TEST

Any business logic (detailing the how or why) must be commented, ideally with the PBI in which that logic was defined. This focuses on why the test is constructed the way it is, or why a certain assertion is expected, which provides documentation and improves readability.

This example follows best practice because the comment details why the expected behaviour is that the save button is disabled.

test('As an Admin, I should not be able to add a new partner without completing the entire form #118439', async ({ partnersPage }) => {
    // Check the partner cannot be added with only a name, as mobile number and email are also required fields
    await partnersPage.nameInput().fill(Partner.NAME);
    await expect(partnersPage.saveButton()).toBeDisabled();
});

Comments in Playwright tests should not document what each line of code does · PW-10.9 · SHOULD · TEST

Comments should not merely repeat what the code already says. Comments should instead explain broadly what is happening in the test.

❌ The comments repeat what each line already says:

// Fill in the name
await partnersPage.nameInput().fill(Partner.NAME);
// Click save
await partnersPage.saveButton().click();

✔️ Clear code needs no comment. Use comments to explain why (see comments must document business logic), and use test steps to label the stages of a test:

await partnersPage.nameInput().fill(Partner.NAME);
await partnersPage.saveButton().click();

Group the stages of a Playwright test into named steps with test.step() · PW-10.10 · SHOULD · TEST

Where a test has more than one stage, wrap each stage in a test step using test.step(), with a title that describes what the user is doing. Steps appear as named groups in the HTML report, Trace Viewer and UI mode, and a failure shows the step it happened in, so the report reads as the user journey.

  • Name steps after the user’s intent, for example ‘Pay with the saved card’, not after the code.
  • Group calls to page object functions into steps. The page object functions already group the individual actions.
  • Do not wrap single actions, or tests with only one stage, in steps.
  • Do not nest steps.
test('Users can buy a product with a saved card', { tag: '@high' }, async ({ productsPage, basketPage, checkoutPage }) => {
  await test.step('Add the product to the basket', async () => {
    await productsPage.searchForProduct(Product.NAME);
    await productsPage.addToBasket(Product.NAME);
  });

  await test.step('Pay with the saved card', async () => {
    await basketPage.goToCheckout();
    await checkoutPage.payWithSavedCard();
  });

  await test.step('Check the order is confirmed', async () => {
    await expect(checkoutPage.orderConfirmedText()).toBeVisible();
  });
});

Playwright tests must not use conditionals within the test body · PW-10.11 · MUST · TEST

Each conditional will most likely represent a test scenario of its own, and should be in a separate test.

In this example, when a user logs in they are greeted by their username.

❌ Do not use conditionals within the test body:

test('Customers are greeted with "Hello," within the nav bar', async ({ homePage }) => {
  if (Env.USERNAME) {
    await expect(homePage.helloText()).toHaveText('Hello, ' + Env.USERNAME);
  } else {
    await expect(homePage.helloText()).toHaveText('Hello, sign in');
  }
});

When this test passes, stakeholders are left thinking that all customers are greeted with the hello message. By using a conditional in this way, it is impossible to determine which flow of the if... else statement was tested and if the alternative is bug-free.

Create a separate Playwright test for each condition · PW-10.12 · MUST · TEST

To ensure tests do not use conditionals within the test body:

  • Create different tests for each condition
  • Use explicit test names which describe the condition under test
test('Logged out customers are greeted with "Hello, sign in" within the nav bar', async ({ homePage }) => {
    await expect(homePage.helloText()).toHaveText('Hello, sign in');
});

test('Logged in customers are greeted with their username within the nav bar', async ({ homePage }) => {
    await homePage.login();
    await expect(homePage.helloText()).toHaveText('Hello, ' + Env.USERNAME);
});

These examples ensure:

  • The tests can pass or fail independently of each other
  • The test output shows the specific function under test and anybody looking at the tests will know exactly what is expected of the functionality
  • Each test acts as a document or spec for the functionality meaning the tests are easier to maintain, should business logic change

Playwright tests must use loops of definite length · PW-10.13 · MUST · TEST

❌ Avoid potentially infinite loops, as the condition required may never be achieved.

while (someCondition) {
  // Some code here
}

do {
  // Some code here
} while (someCondition)

✔️ Instead, use a loop of definite length.

const maxRetries = 5;

for (let i = 0; i < maxRetries; i++) {
  // Some code here
}

To retry until a condition is met, use expect.poll() or expect().toPass() with a timeout instead of a loop:

// Retries the check until it passes, failing after 10 seconds
await expect(async () => {
  const orderResponse = await api.orders.getOrderById(orderId);
  expect(orderResponse.status()).toBe(200);
}).toPass({ timeout: 10000 });

Loops over Playwright test cases must generate a separate test for each case · PW-10.14 · MUST · TEST

❌ Avoid using a loop within the test body, as this obscures the fact that multiple test cases are being evaluated.

const petTypes: string[] = ['German Shepherd', 'Siberian Husky', 'Leonberger'];

test('Pampering appointments can be booked for the specialist pet types (#364)', async () => {
  petTypes.forEach((petType: string): void => {
    // Some code here
  });
});

✔️ Instead, define the test within the loop, so that each case is evaluated as a separate test. This means the test title will contain the name of the specific property under test and all tests will run, regardless of a failing case.

petTypes.forEach((petType: string) => {
  // Note the test title contains information pertaining to this specific case
  test('Pampering appointments can be booked for the specialist pet type "' + petType + '" (#364)', async () => {
    // Some code here
  });
});

NOTE: Ensure assertions are made within the loop, so that each failure can be recorded separately.

Known failing Playwright tests must be skipped using test.fixme() · PW-10.15 · MUST · TEST

test.fixme() is a special form of skip, used to mark tests that are known to be failing and need fixing, including when a test is blocked by another backlog item. Use the syntax test.fixme(title, testFunction).

It acknowledges that something isn’t working as expected, while avoiding failure noise in test reports. Read more in the Playwright documentation on fixme.

Use test.fixme() rather than commenting a test out or using test.skip(). A commented-out test disappears from test reports. test.skip() means the test is intentionally not run, for example because a feature doesn’t apply to a browser, rather than that it needs fixing.

✔️ The whole test is known to be failing, and an annotation links to the bug that will fix it:

test.fixme('As an admin, I can export customers to CSV', {
  annotation: { type: 'Bug', description: 'https://dev.azure.com/project/_workitems/edit/4567' },
}, async ({ customersPage }) => { ... });

✔️ The test only fails in some cases, so it’s marked from inside the test with a condition and description, and an annotation links to the bug:

test('As an admin, I can upload a profile picture', {
  annotation: { type: 'Bug', description: 'https://dev.azure.com/project/_workitems/edit/4567' },
}, async ({ browserName, profilePage }) => {
  test.fixme(browserName === 'webkit', 'Upload fails on WebKit');
  ...
});

❌ Commented out, so it no longer appears in test reports:

// test('As an admin, I can export customers to CSV', async ({ customersPage }) => { ... });

Playwright helper functions must be defined in the helpers folder · PW-10.16 · MUST · TEST

Do not define helper functions in test files. Put them in the helpers folder, so they can be reused by other tests and the test file only contains tests. Group related helpers into one file, named after the area they cover, rather than creating a file for each function. See the repo structure for the existing helper files.

Helpers are for general utilities, such as generating test data or formatting dates. Interactions with a page belong in its page object, and API calls belong in its endpoint file.

❌ The helper is defined in the test file:

// tests/e2e-tests/customers.spec.ts
const formatDate = (date: Date) => date.toLocaleDateString('en-GB');

test('As an admin, I can see when a customer was created', async ({ customersPage }) => { ... });

✔️ The helper is in a file of related date helpers and imported:

// helpers/dates.ts
export const formatDate = (date: Date) => date.toLocaleDateString('en-GB');
export const addDays = (date: Date, days: number) => new Date(date.getTime() + days * 24 * 60 * 60 * 1000);

// tests/e2e-tests/customers.spec.ts
import { formatDate } from '../../helpers/dates';