Quick answer
Wrap test() in a for...of that iterates your dataset. Give each iteration a unique title so the reporter shows one row per test. Playwright shards them across workers automatically.
1. Inline arrays — the 90% case
import { test, expect } from '@playwright/test';
const cases = [
{ email: '', expected: 'Email is required' },
{ email: 'no-at', expected: 'Enter a valid email' },
{ email: 'a@b', expected: 'Enter a valid email' },
{ email: 'ok@acme.co', expected: '' },
];
for (const { email, expected } of cases) {
test(`login validates: "${email || '(empty)'}"`, async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Email').blur();
if (expected) await expect(page.getByRole('alert')).toContainText(expected);
else await expect(page.getByRole('alert')).toBeHidden();
});
}2. JSON datasets
data/users.json
ts
[
{ "role": "admin", "email": "admin@acme.co", "canDelete": true },
{ "role": "viewer", "email": "viewer@acme.co", "canDelete": false }
]tests/rbac.spec.ts
ts
import users from '../data/users.json' with { type: 'json' };
for (const u of users) {
test(`${u.role} delete permission = ${u.canDelete}`, async ({ page }) => {
// ... login as u.email, navigate, check button
});
}3. CSV datasets
import { readFileSync } from 'node:fs';
import { parse } from 'csv-parse/sync';
const rows = parse(readFileSync('data/products.csv'), {
columns: true, skip_empty_lines: true,
}) as { sku: string; price: string; taxable: string }[];
for (const row of rows) {
test(`checkout SKU ${row.sku}`, async ({ page }) => {
// ...
});
}4. Generated data with faker
import { faker } from '@faker-js/faker';
test('sign up 5 unique users', async ({ page }) => {
for (let i = 0; i < 5; i++) {
const user = {
email: `test+${Date.now()}-${i}@example.com`,
name: faker.person.fullName(),
pwd: faker.internet.password({ length: 14 }),
};
// sign up user
}
});Uniqueness across parallel workers
Add
process.env.TEST_WORKER_INDEX to generated emails. Without it two workers can collide on the same millisecond.6. Keeping large datasets fast
| Problem | Fix |
|---|---|
| 500 tests, all hitting the same server | Set workers: 4 and use test.describe.configure({ mode: 'parallel' }) |
| Repeated login before every test | Save storageState once, reuse it across the run |
| Full E2E per row | Test only the varying step — mock the rest via request context |
| Report becomes unreadable | Group rows with test.describe(dataset name, ...) |
7. Hands-on task (25 minutes)
- 1
Convert 3 copies to 1 loop
Find 3 near-identical tests. Merge into a
for...ofwith a dataset. - 2
Extract to JSON
Move the dataset to
data/*.json, import withwith { type: "json" }. - 3
Tag half as @smoke
Filter with
--grep @smoke. Confirm only tagged rows run.
Continue to Module 6 — API Testing.
Frequently asked questions
1.What is data-driven testing?
Running the same test logic against many datasets. Instead of copy-pasting a test five times, you loop over the data and generate five tests — one per row.
2.Does Playwright have test.each like Jest?
You use a plain `for` loop over your data array and call `test(title, fn)` inside. Playwright reports one test per iteration with the dynamic title you set.
3.Where should test data live?
Small inline datasets — top of the spec. Larger sets — a `data/*.json` or `.csv` file. Sensitive datasets — a fixture that reads from an environment variable or a secret manager. Never commit real user data.
4.How do I generate unique data per run?
Use `faker-js/faker` for names/emails and `Date.now()` or a UUID for uniqueness. Never rely on hard-coded strings that collide across parallel workers.
5.How do I tag a dataset (smoke, regression)?
Include `@smoke` in the test title. Filter with `npx playwright test --grep @smoke`. In config, you can also define projects that run only tagged tests.
6.Are looped tests still parallel?
Yes. Playwright shards tests across workers regardless of how they were created. Each iteration gets its own worker slot.