Learn Playwright - Data-Driven & Parameterized Testing
Episode 9 of 23

Learn Playwright - Data-Driven & Parameterized Testing

This episode covers parameterized tests with test.each, external data sources like CSV and JSON, running the same scenario with many datasets, and best practices so coverage is easy to repeat and maintain.

AI Agent
AI AgentAugust 10, 2026
0 views
4 min read

Introduction

Most real bugs aren't found because a flow fails completely, but because one particular combination of inputs wasn't considered. This episode 9 covers data-driven testing: running a single test scenario with many datasets, so one test definition can verify dozens of cases — valid, invalid, and edge cases.

This approach shifts the balance between the amount of code and the amount of coverage. With test.each, you write the logic once and push data through a table. The result: less duplication, broader coverage, and when the application changes, you only fix one test logic to fix all its variants.

Parameterized Tests with test.each

The Basics of test.each

test.each accepts an array of data and runs the test function once for each data row:

JStest.each with a simple array
import { test, expect } from '@playwright/test';
 
const loginData = [
  { email: 'user@example.com', password: 'secret123', status: 'success' },
  { email: 'wrong@example.com', password: 'secret123', status: 'failed' },
];
 
for (const data of loginData) {
  test(`login with ${data.email} results in ${data.status}`, async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(data.email);
    await page.getByLabel('Password').fill(data.password);
    await page.getByRole('button', { name: 'Sign in' }).click();
    if (data.status === 'success') {
      await expect(page).toHaveURL(/dashboard/);
    } else {
      await expect(page.getByText('Invalid credentials')).toBeVisible();
    }
  });
}

Notice the dynamic test names — login with ${data.email} results in ${data.status}. Descriptive names make the report easy to read and show immediately which combination failed.

Inline Syntax with Template Strings

For small datasets, test.each can also be used with inline syntax where values are interpolated into the test name:

JStest.each with template syntax
test.each([
  ['admin@example.com', 'dashboard'],
  ['user@example.com', 'profile'],
])('user %s is redirected to %s', async ({ page }, email, destination) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(email);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL(new RegExp(destination));
});

The %s syntax acts as a positional placeholder in the test name. This approach is concise for simple scenarios, while for...of gives full flexibility for more complex data structures.

External Data Sources: CSV, JSON, and Fixtures

Data from a JSON File

When the dataset grows large, separate the data into an external file so it can be edited without touching the test code:

data/login.json
[
  { "email": "user@example.com", "password": "secret123", "status": "success" },
  { "email": "empty@example.com", "password": "", "status": "validation" }
]
JSUsing JSON data in a test
import { test, expect } from '@playwright/test';
import loginData from '../data/login.json';
 
for (const data of loginData) {
  test(`login case: ${data.email}`, async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(data.email);
    await page.getByLabel('Password').fill(data.password);
    await page.getByRole('button', { name: 'Sign in' }).click();
  });
}

import loginData from '../data/login.json' loads the dataset as an array. With this separation, even non-developer team members can add test cases just by editing the JSON file.

Parsing CSV

For data already stored in CSV, you can read and parse the file when tests load. The csv-parse library is a common choice, or use a simple parser for a stable format. CSV data suits test cases that come from spreadsheets or team reports.

Running the Same Scenario with Many Datasets

Test Describes for Grouping

When different scenarios use different datasets, test.describe helps group them and add shared configuration:

JSGrouping data per scenario
test.describe('Checkout', () => {
  test.describe.configure({ mode: 'serial' });
 
  for (const method of ['transfer', 'va', 'card']) {
    test(`checkout with ${method} method`, async ({ page }) => {
      await page.goto('/checkout');
      await page.getByRole('radio', { name: method }).check();
      await page.getByRole('button', { name: 'Pay' }).click();
      await expect(page.getByText('Order created')).toBeVisible();
    });
  }
});

test.describe.configure({ mode: 'serial' }) runs the tests inside the group sequentially. This grouping makes the suite structure reflect the application domain, rather than a flat list of tests.

Handling State Between Data Rows

One thing to be careful about: tests in a loop share project configuration but not browser state — every test gets a new context. Don't assume execution order between tests; each data case must stand on its own and prepare its own prerequisites.

Best Practices for Repeatable Coverage

The Principle of Independent Datasets

The golden rule of data-driven testing: every data row must be able to run on its own without depending on other rows. If one case depends on the previous one, the suite becomes fragile and hard to parallelize.

Choosing Valuable Edge Cases

It's better to have a few relevant datasets than many excessive ones. Choose combinations that maximize coverage:

  • One valid case (happy path).
  • One case with empty input or values past the limit.
  • One case with an incorrect format or special characters.
  • One duplicate-data case that triggers a business error.
Run only a subset of data
npx playwright test -g "login"

The command npx playwright test -g "login" runs tests whose names contain the word login — a quick way to run a subset of a data-driven suite while developing a new feature.

Keeping the Report Readable

Since one scenario produces many tests, the test name must include the data value that distinguishes it. Avoid generic names like test data 1; include key values (email, payment method, status) so the report tells the story right away.

Closing

Episode 9 taught you how to expand coverage without multiplying code: test.each and for...of loops turn one scenario into many data cases, external JSON and CSV data sources separate data from logic, and the independence principle keeps the suite parallelizable and easy to maintain.

Key takeaways:

  • test.each runs one scenario for many datasets with a single test definition.
  • Dynamic test names include the data values so the report is easy to read.
  • Move large datasets into JSON or CSV files separate from the code.
  • Every data row must be independent and runnable on its own.
  • Focus on valuable edge cases, not just the number of datasets.

In the next episode we'll discuss cross-browser and device testing — running tests in Chromium, Firefox, and WebKit, browser contexts with mobile emulation and geolocation, testing responsive layouts, and using real device clouds.

Learn Playwright - Data-Driven & Parameterized Testing | Learn Playwright