Playwright·Cookbook Field Manual
Part 04 · Architecture, Auth & Data Card 24

Parameterized Tests

Card 24: Parameterized Tests

What This Pattern Solves

You need to run the same test logic against five user IDs, three locales, or ten API endpoints. Without parameterization, you’d copy-paste the test five, three, or ten times — changing only the input values. This creates a maintenance nightmare: if the assertion logic changes, you must update every copy. Missing one produces inconsistent behavior and false confidence. Parameterized tests let you define a data set once and generate one test per row automatically, keeping specs DRY and failures pinpoint-accurate.

How It Works

  1. Define a data array of test cases — each entry is an object with the inputs and expected outputs
  2. Use a for...of loop over the array, calling test(title, async ({ ... }) => { ... }) inside the loop
  3. Each iteration registers a separate test with a distinct title that includes the input values — reports show exactly which row failed
  4. The test body uses the loop variable (id, name, role) directly — no dynamic key lookups
  5. Failures isolate to one row: “User 2 is Bob with role user” fails, not “one of the parameterized tests failed”
  6. For more complex needs, consider test.describe.each (table-style) or test.extend (factory-style)

Code Example

import { test, expect } from '@playwright/test';

interface User {
  id: string;
  name: string;
  role: string;
}

const users: User[] = [
  { id: '1', name: 'Alice', role: 'admin' },
  { id: '2', name: 'Bob', role: 'user' },
];

for (const { id, name, role } of users) {
  test(`User ${id} is ${name} with role ${role}`, async ({ request }) => {
    const response = await request.get(`/api/user/${id}`, {
      headers: { Accept: 'application/json' },
    });
    expect(response.status()).toBe(200);

    const body: User = await response.json();
    expect(body.id).toBe(id);
    expect(body.name).toBe(name);
    expect(body.role).toBe(role);
  });
}

test('all users have required fields', async ({ request }) => {
  for (const { id } of users) {
    const response = await request.get(`/api/user/${id}`, {
      headers: { Accept: 'application/json' },
    });
    expect(response.status()).toBe(200);

    const body: User = await response.json();
    expect(body).toMatchObject({
      id: expect.any(String),
      name: expect.any(String),
      role: expect.any(String),
    });
  }
});

Run This Example

pnpm test src/24-parameterized-tests

Prerequisites

Key Concepts

When to Use This Pattern

Common Mistakes

  1. Registering tests inside another test:

    // ❌ WRONG — loop inside a test body, not at module scope
    test('parameterized', async ({ request }) => {
      for (const u of users) {
        const res = await request.get(`/api/user/${u.id}`);
        expect(res.status()).toBe(200); // One failure stops the loop
      }
    });
    
    // ✓ CORRECT — loop at module scope, each row is its own test
    for (const { id, name, role } of users) {
      test(`User ${id}`, async ({ request }) => {
        const res = await request.get(`/api/user/${id}`);
        expect(res.status()).toBe(200);
      });
    }
  2. Vague test titles that hide which row failed:

    // ❌ WRONG — title doesn't say which user, report is ambiguous
    for (const u of users) {
      test('user has correct name and role', async ({ request }) => {
        // ...
      });
    }
    
    // ✓ CORRECT — title includes the distinguishing values
    for (const { id, name, role } of users) {
      test(`User ${id} is ${name} with role ${role}`, async ({ request }) => {
        // ...
      });
    }
  3. Closing over a mutable variable:

    // ❌ WRONG — all tests share the same `user` variable (last value)
    for (const user of users) {
      test(`User ${user.id}`, async ({ request }) => {
        expect((await (await request.get(`/api/user/${user.id}`)).json()).name)
          .toBe(user.name);
      });
    }
    // All tests will use the last user's data!
    // (This is actually safe with `const` + block scope in ES modules, but
    //  be careful with `var` or non-block-scoped variables.)
    
    // ✓ CORRECT — destructure in the loop head for clarity and safety
    for (const { id, name, role } of users) {
      test(`User ${id}`, async ({ request }) => {
        expect((await (await request.get(`/api/user/${id}`)).json()).name)
          .toBe(name);
      });
    }
  4. Not considering that all tests are registered upfront:

    // ❌ WRONG — dynamic data fetch won't work (module-load time, not test time)
    const users = await fetch('/api/users').then(r => r.json());
    for (const u of users) { test(...) }
    
    // ✓ CORRECT — define data statically, or use test.extend / globalSetup
    const users = [
      { id: '1', name: 'Alice', role: 'admin' },
      { id: '2', name: 'Bob', role: 'user' },
    ];

Flow Diagram

sequenceDiagram
    participant Module as Module Load
    participant Registry as Test Registry
    participant Runner as Test Runner
    participant Report as HTML Report

    Note over Module: const users = [{...}, {...}]

    Module->>Module: for (const {id,name,role} of users)
    Module->>Registry: register test("User 1 is Alice with role admin")
    Module->>Module: for loop continues...
    Module->>Registry: register test("User 2 is Bob with role user")

    Note over Runner: All tests registered. Begin execution.

    Runner->>Runner: Run: "User 1 is Alice with role admin"
    Runner->>Runner: GET /api/user/1 → 200 → expect name=Alice ✓
    Note over Report: Passed ✓ (42ms)

    Runner->>Runner: Run: "User 2 is Bob with role user"
    Runner->>Runner: GET /api/user/2 → 200 → expect name=Bob ✓
    Note over Report: Passed ✓ (38ms)

    Note over Report: 2 tests, 2 passed<br/>Failures isolate to exact row

Live Demo

👇 This component is what the Playwright test interacts with:

Loading…

Run This Example

pnpm test src/24-parameterized-tests