# Writing Playwright Tests · PW-10

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

## Playwright projects should use the `eslint-plugin-playwright` recommended config · PW-10.1 · **SHOULD** · **TEST**

Lint Playwright code with the [recommended config](https://github.com/playwright-community/eslint-plugin-playwright#usage) 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:

```js
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](/standards/engineering/coding-standards/playwright/assertions#playwright-tests-must-have-a-strong-assertion) |
| `no-standalone-expect` | [Playwright assertions must only be made in the test body or hooks](/standards/engineering/coding-standards/playwright/assertions#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](#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](/standards/engineering/coding-standards/playwright/waiting#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](/standards/engineering/coding-standards/playwright/waiting#playwright-tests-must-not-override-the-default-waituntil-option) |
| `prefer-hooks-on-top` | [Playwright hooks must be declared above the tests that use them](/standards/engineering/coding-standards/playwright/hooks#playwright-hooks-must-be-declared-above-the-tests-that-use-them) |
| `no-duplicate-hooks` | [Playwright tests should use as few hooks as possible](/standards/engineering/coding-standards/playwright/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()](#group-the-stages-of-a-playwright-test-into-named-steps-with-teststep) (partly: it only catches nested steps) |
| `require-top-level-describe` \* | [Playwright tests must be wrapped in a describe block](#playwright-tests-must-be-wrapped-in-a-describe-block-which-gives-a-human-readable-description-of-the-system-area-under-test) |
| `require-tags` \* | [Playwright tests must be tagged by regression priority and system area](#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()](#known-failing-playwright-tests-must-be-skipped-using-testfixme) |

\* 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](/standards/engineering/coding-standards/typescript/typescript-maintainability#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:

```ts
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:

```ts
// 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:

```ts
// 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.

```ts
// 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.

```ts
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](https://playwright.dev/docs/test-annotations#tag-tests), 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:

```ts
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.

```ts
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:

```ts
// 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](/standards/engineering/coding-standards/playwright/writing-tests#comments-in-playwright-tests-must-document-business-logic)), and use [test steps](/standards/engineering/coding-standards/playwright/writing-tests#group-the-stages-of-a-playwright-test-into-named-steps-with-teststep) to label the stages of a test:

```ts
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](https://playwright.dev/docs/api/class-test#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.

```ts
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:

```ts
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

```ts
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.

```ts
while (someCondition) {
  // Some code here
}

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

✔️ Instead, use a loop of definite length.

```ts
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:

```ts
// 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.

```ts
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.

```ts
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](https://playwright.dev/docs/api/class-test#test-fixme-1).

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:

```ts
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:

```ts
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:

```ts
// 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](/standards/engineering/coding-standards/playwright/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](/standards/engineering/coding-standards/playwright/page-object-model), and API calls belong in its [endpoint file](/standards/engineering/coding-standards/playwright/api-testing#crud-actions-must-be-defined-as-functions-in-the-playwright-endpoint-file).

❌ The helper is defined in the test file:

```ts
// 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:

```ts
// 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';
```
