Data-Driven API Testing in Playwright: Arrays, JSON and CSV
Boundary values, invalid types, every role, every country code: API testing is full of “same test, different data”. Copy-pasting a test ten times makes a suite impossible to maintain. Data-driven testing writes the logic once and feeds it rows of data from an array, a JSON file or a CSV that even non-coders can edit. In this lesson you’ll build all three styles in Playwright TypeScript, generate unique data safely, and keep every row as its own named, parallel test.
test() inside the loop, giving each row a unique title. Data can come from an inline array, a JSON file (import it with resolveJsonModule) or a CSV file read with fs (or a parser like csv-parse). Each row becomes a separate test in the report, runs in parallel and can be re-run alone with -g. Generate unique values (worker index + timestamp) for anything that must not collide.Why Data-Driven API Tests?
- One logic, many cases: boundaries, types, roles, locales.
- Readable reports: each row is a named test, e.g.
age=101 -> 422 [max+1]. - Easy to extend: adding a case is adding a row.
- Shared ownership: business and manual testers can maintain data files.
The Rule: Generate Tests at Collection Time
Playwright collects tests by running the spec file’s top-level code, so a plain for loop that calls test() registers one test per item. Two rules:
- Data must be available synchronously at load time (inline arrays, imported JSON,
fs.readFileSync). Don’t fetch data withawaitat the top level to create tests. - Every generated test needs a unique title, so include the data in it.
The Complete Example
Data files first. data/age-boundaries.json:
[
{ "age": 17, "expected": 422, "note": "min-1" },
{ "age": 18, "expected": 200, "note": "min" },
{ "age": 100, "expected": 200, "note": "max" },
{ "age": 101, "expected": 422, "note": "max+1" },
{ "age": "30", "expected": 422, "note": "string" },
{ "age": 30.5, "expected": 422, "note": "decimal" },
{ "age": null, "expected": 422, "note": "null" }
]
data/object-ttl.csv:
ttl,expected
9,422
10,201
1800,201
1801,422
tests/data-driven.spec.ts shows all four techniques:
import { test, expect } from '../src/fixtures';
import fs from 'node:fs';
import path from 'node:path';
import ageCases from '../data/age-boundaries.json';
// 1. Inline array
const pageCases = [
{ per_page: 0, expected: 400 },
{ per_page: 1, expected: 200 },
{ per_page: 50, expected: 200 },
{ per_page: 51, expected: 400 },
];
for (const { per_page, expected } of pageCases) {
test(`GET users?per_page=${per_page} -> ${expected}`, async ({ usersApi }) => {
expect((await usersApi.list({ per_page })).status()).toBe(expected);
});
}
// 2. JSON file (needs "resolveJsonModule": true in tsconfig, or read it with fs)
for (const c of ageCases) {
test(`validate/user age=${JSON.stringify(c.age)} -> ${c.expected} [${c.note}]`, async ({ request }) => {
const res = await request.post('validate/user', { data: { name: 'Asha', email: 'asha@example.com', age: c.age } });
expect(res.status()).toBe(c.expected);
});
}
// 3. CSV file
function readCsv(file: string): Record<string, string>[] {
const [header, ...rows] = fs.readFileSync(file, 'utf8').trim().split(/\r?\n/);
const keys = header.split(',');
return rows.map(line => Object.fromEntries(line.split(',').map((v, i) => [keys[i], v.trim()])));
}
const objectCases = readCsv(path.join(__dirname, '../data/object-ttl.csv'));
test.describe('POST /objects ttl boundaries (CSV)', () => {
for (const row of objectCases) {
test(`ttl=${row.ttl} -> ${row.expected}`, async ({ objectsApi, cleanup }) => {
const res = await objectsApi.create({ name: `csv ttl ${row.ttl}`, ttl: Number(row.ttl) });
if (res.status() === 201) cleanup(async () => objectsApi.delete((await res.json()).id));
expect(res.status()).toBe(Number(row.expected));
});
}
});
// 4. Generated unique data per run
test('generated unique names never collide', async ({ objectsApi, cleanup }) => {
const names = Array.from({ length: 3 }, (_, i) => `run-${Date.now()}-${test.info().workerIndex}-${i}`);
const ids = new Set<string>();
for (const name of names) {
const obj = await objectsApi.createOrThrow({ name, ttl: 30 });
cleanup(() => objectsApi.delete(obj.id));
ids.add(obj.id);
}
expect(ids.size).toBe(3);
});
This file generates 16 tests. The report reads like a test plan:
✓ GET users?per_page=0 -> 400
✓ GET users?per_page=51 -> 400
✓ validate/user age=17 -> 422 [min-1]
✓ validate/user age="30" -> 422 [string]
✓ POST /objects ttl boundaries (CSV) › ttl=1801 -> 422
...
16 passed
Run one row while debugging: npx playwright test -g "age=101".
The usersApi fixture used above wraps /users with typed query parameters (src/api/UsersApi.ts):
import { APIRequestContext } from '@playwright/test';
export interface UserQuery {
page?: number;
per_page?: number;
role?: 'admin' | 'editor' | 'viewer';
city?: string;
active?: boolean;
q?: string;
sort?: string;
order?: 'asc' | 'desc';
}
export class UsersApi {
constructor(private readonly request: APIRequestContext) {}
list(query: UserQuery = {}) {
return this.request.get('users', { params: query as Record<string, string | number | boolean> });
}
get(id: number | string) {
return this.request.get(`users/${id}`);
}
orders(id: number) {
return this.request.get(`users/${id}/orders`);
}
}
Which Data Source to Use?
| Source | Best for | Watch out for |
|---|---|---|
| Inline array | Small sets that belong to one test file; types checked by TypeScript | Gets long; move to a file past ~15 rows |
| JSON file | Structured data with types (numbers, null, nested objects) | Needs resolveJsonModule or fs; no comments allowed |
| CSV file | Tables shared with business/manual testers (Excel exports) | Everything is text (convert numbers); commas inside values need a real parser |
| Excel (.xlsx) | Business-owned rule tables | Use a library like xlsx; export to CSV when possible |
| Generated data | Unique names, emails, ids | Make it reproducible when debugging (log the values) |
readCsv above is fine for simple files. When values may contain commas, quotes or line breaks, use csv-parse: parse(fs.readFileSync(file), { columns: true, skip_empty_lines: true }) returns the same array of objects.Typing Your Test Data
Give data rows a type so a typo in a data file is caught early:
type AgeCase = { age: unknown; expected: 200 | 422; note: string };
const cases = ageCases as AgeCase[];
// Or validate the file itself with Zod at load time:
// const cases = z.array(z.object({ age: z.unknown(), expected: z.number(), note: z.string() })).parse(ageCases);
Generating Realistic Test Data
Libraries like @faker-js/faker create realistic names, emails, addresses and phone numbers, including Indian locales:
import { fakerEN_IN as faker } from '@faker-js/faker';
faker.seed(2026); // same values every run: reproducible failures
const user = {
name: faker.person.fullName(),
email: faker.internet.email().toLowerCase(),
city: faker.location.city(),
};
Seeding makes “random” data repeatable. Remove or change the seed when you want variety, and always print the generated values in failure messages so a failing row can be reproduced.
Data-Driven Across Environments and Roles
Sometimes the “data” is the environment or role. Playwright projects can run the same tests with different settings:
// playwright.config.ts
projects: [
{ name: 'qa', use: { baseURL: 'https://qa.example.com/api/v1/' } },
{ name: 'staging', use: { baseURL: 'https://staging.example.com/api/v1/' } },
],
// npx playwright test --project=staging
For roles, combine a role list with a permission matrix (see the authorization lesson) and loop over both dimensions to generate one test per cell.
Common Mistakes
- Duplicate titles: two rows with the same title make Playwright report a duplicate-title error. Include every distinguishing value.
- Looping inside one test: the first failing row hides the rest, and the report shows one test. Generate one test per row (or use
expect.softdeliberately). - Async data loading at the top level to create tests: load data synchronously.
- Forgetting type conversion from CSV (
"10"vs10), which silently tests the wrong thing. - Shared mutable rows: don’t modify the data objects inside tests running in parallel.
- Huge data sets in functional suites: 5,000 rows belong in a separate nightly job, not every pull request.
Interview Questions: Data-Driven Testing in Playwright
How do you do data-driven testing in Playwright?
Loop over a data source at the top level of the spec file and call test() inside the loop with a unique title per row. Data can be an inline array, an imported JSON file or a CSV/Excel file read synchronously.
Does Playwright have a built-in data provider like TestNG?
No special annotation is needed; standard loops generate parameterised tests. Projects can also parameterise whole runs, for example per environment.
How do you read a CSV file in a Playwright test?
Read it synchronously with fs.readFileSync and split lines and columns for simple files, or use a parser such as csv-parse with columns: true to get an array of objects, then loop over the rows to create tests.
Why should each data row be its own test?
So every row passes or fails independently, appears by name in the report, runs in parallel and can be re-run alone, rather than one failure hiding the rest.
How do you import JSON test data in TypeScript?
Enable resolveJsonModule (and esModuleInterop) in tsconfig and import the file directly, or read and parse it with fs. Add a type or a Zod schema to validate its structure.
How do you generate unique test data?
Combine timestamps, test.info().workerIndex or random UUIDs, or use a library like faker with a seed for realistic and reproducible values.
What is the risk of duplicate test titles?
Playwright requires unique titles within a file and reports duplicates as an error; titles should include the data values that distinguish each case.
How do you run the same tests against multiple environments?
Define a Playwright project per environment with its own baseURL and settings, then select it with –project, or set the base URL through an environment variable.
Where should large data sets run?
In a separate scheduled or nightly job rather than on every commit, keeping the pull-request suite fast while still covering many combinations.
How do you keep data files maintainable?
Use clear column names, include a note or description column, keep expected results next to inputs, validate the file structure, version it in Git, and avoid duplicating the same data in multiple files.