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.

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.
test.each accepts an array of data and runs the test function once for each data row:
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.
For small datasets, test.each can also be used with inline syntax where values are interpolated into the test name:
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.
When the dataset grows large, separate the data into an external file so it can be edited without touching the test code:
[
{ "email": "user@example.com", "password": "secret123", "status": "success" },
{ "email": "empty@example.com", "password": "", "status": "validation" }
]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.
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.
When different scenarios use different datasets, test.describe helps group them and add shared configuration:
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.
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.
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.
It's better to have a few relevant datasets than many excessive ones. Choose combinations that maximize coverage:
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.
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.
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.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.