CSV & JSON for Data-Driven Testing
Learn how to use CSV and JSON files for data-driven testing in Playwright, Jest, and other frameworks. Covers when to use each format, how to convert.
Data-driven testing is one of the highest-ROI practices in test automation. Instead of writing a separate test for each combination of inputs, you define the logic once and feed it a dataset — each row becomes a test case. The test suite grows in coverage without growing in code.
CSV and JSON are the two formats that underpin data-driven test suites in most projects. Understanding when to use each, and how to convert between them, makes building and maintaining these suites significantly easier.
What data-driven testing looks like
Without data-driven testing, a form validation test suite looks like this:
TYPESCRIPT1test('rejects email without @', async ({ page }) => { 2 await page.fill('[name="email"]', 'notanemail') 3 await page.click('[type="submit"]') 4 await expect(page.locator('.error')).toBeVisible() 5}) 6 7test('rejects email without domain', async ({ page }) => { 8 await page.fill('[name="email"]', 'user@') 9 await page.click('[type="submit"]') 10 await expect(page.locator('.error')).toBeVisible() 11}) 12 13// ... 8 more tests with identical structure
With data-driven testing, it looks like this:
TYPESCRIPT1const emailCases = [ 2 { input: 'notanemail', valid: false, desc: 'no @ symbol' }, 3 { input: 'user@', valid: false, desc: 'no domain' }, 4 { input: '@domain.com', valid: false, desc: 'no local part' }, 5 { input: 'user@.com', valid: false, desc: 'dot-leading domain' }, 6 { input: 'user@domain', valid: false, desc: 'no TLD' }, 7 { input: 'user@domain.com', valid: true, desc: 'valid standard email' }, 8 { input: 'user+tag@domain.co.uk', valid: true, desc: 'valid with plus tag' }, 9] 10 11for (const { input, valid, desc } of emailCases) { 12 test(`email validation: ${desc}`, async ({ page }) => { 13 await page.fill('[name="email"]', input) 14 await page.click('[type="submit"]') 15 if (valid) { 16 await expect(page.locator('.error')).not.toBeVisible() 17 } else { 18 await expect(page.locator('.error')).toBeVisible() 19 } 20 }) 21}
One test definition, seven test cases. Add more rows to the array, get more coverage with zero additional test code.
JSON for test data: when and why
JSON is the natural choice when your test data has:
Nested structure — user objects with nested addresses, orders with line items, API request bodies with deeply structured fields.
Mixed types — some fields are strings, some are numbers, some are booleans, some are arrays. JSON preserves types natively. CSV stores everything as strings.
Direct API body mapping — if you're testing a REST API, JSON fixtures map directly to request bodies without any transformation.
JSON1[ 2 { 3 "id": "test_001", 4 "user": { "name": "Alice", "role": "admin" }, 5 "permissions": ["read", "write", "delete"], 6 "shouldSucceed": true 7 }, 8 { 9 "id": "test_002", 10 "user": { "name": "Bob", "role": "viewer" }, 11 "permissions": ["read"], 12 "shouldSucceed": false 13 } 14]
TYPESCRIPT1import testCases from './fixtures/permission-tests.json' 2 3for (const tc of testCases) { 4 test(`permissions: ${tc.id}`, async ({ request }) => { 5 const response = await request.post('/api/documents', { 6 data: { owner: tc.user, requiredPermissions: tc.permissions } 7 }) 8 if (tc.shouldSucceed) { 9 expect(response.status()).toBe(201) 10 } else { 11 expect(response.status()).toBe(403) 12 } 13 }) 14}
CSV for test data: when and why
CSV is the right choice when your test data is:
Flat — rows and columns with no nesting. Login credentials, product listings, user registrations.
Business-owned — product managers, business analysts, and QA leads can edit a spreadsheet and save as CSV without touching code. This democratises test case maintenance.
Large volume — performance test scenarios with thousands of rows are easier to manage as CSV than as JSON arrays.
Imported from external systems — user lists from HR systems, product catalogues from ERP systems, test cases exported from TestRail — almost all come as CSV.
CSV1email,password,expectedRole,shouldSucceed 2admin@example.com,Admin123!,admin,true 3member@example.com,Member123!,member,true 4inactive@example.com,Inactive123!,,false 5wrongpass@example.com,wrongpassword,,false
TYPESCRIPT1import { parse } from 'csv-parse/sync' 2import { readFileSync } from 'fs' 3 4const csv = readFileSync('./tests/fixtures/login-cases.csv', 'utf8') 5const cases = parse(csv, { columns: true, skip_empty_lines: true }) 6 7for (const tc of cases) { 8 test(`login: ${tc.email}`, async ({ request }) => { 9 const response = await request.post('/api/auth/login', { 10 data: { email: tc.email, password: tc.password } 11 }) 12 if (tc.shouldSucceed === 'true') { 13 expect(response.status()).toBe(200) 14 const body = await response.json() 15 expect(body.user.role).toBe(tc.expectedRole) 16 } else { 17 expect(response.status()).toBe(401) 18 } 19 }) 20}
Converting between CSV and JSON
The need to convert between these formats comes up constantly:
- A business analyst provides a CSV of test cases; your test framework expects JSON
- An API returns a JSON array; you want to open it in Excel for analysis
- Your test data generator produces JSON; your database import tool requires CSV
The CSV ↔ JSON Converter tool handles both directions with support for comma, semicolon, tab, and pipe delimiters. For scripted conversion in CI or local dev:
TYPESCRIPT1// CSV → JSON (using csv-parse) 2import { parse } from 'csv-parse/sync' 3import { readFileSync, writeFileSync } from 'fs' 4 5const csv = readFileSync('input.csv', 'utf8') 6const rows = parse(csv, { columns: true, skip_empty_lines: true }) 7writeFileSync('output.json', JSON.stringify(rows, null, 2))
TYPESCRIPT1// JSON → CSV (using csv-stringify) 2import { stringify } from 'csv-stringify/sync' 3import { readFileSync, writeFileSync } from 'fs' 4 5const data = JSON.parse(readFileSync('input.json', 'utf8')) 6const csv = stringify(data, { header: true }) 7writeFileSync('output.csv', csv)
For one-off conversions without installing a package:
TYPESCRIPT1// Minimal JSON → CSV (no dependencies) 2function jsonToCsv(rows: Record<string, unknown>[]): string { 3 if (rows.length === 0) return '' 4 const headers = Object.keys(rows[0]) 5 const escape = (v: unknown) => { 6 const s = String(v ?? '') 7 return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s 8 } 9 return [ 10 headers.join(','), 11 ...rows.map(r => headers.map(h => escape(r[h])).join(',')) 12 ].join('\n') 13}
Choosing a format: decision guide
| Situation | Use |
|---|---|
| API request body testing | JSON |
| Form field validation (flat data) | CSV or JSON |
| Business team maintains the data | CSV |
| Nested objects / arrays | JSON |
| Performance test scenarios | CSV |
| Test data with boolean/number types | JSON |
| Output from a spreadsheet tool | CSV |
| Data imported directly into API calls | JSON |
| More than 1,000 rows | CSV |
Type coercion: the hidden CSV trap
CSV stores everything as strings. When you load a CSV row, every value — including true, false, 42, and null — comes back as a string. This causes subtle failures:
TYPESCRIPT1// Loaded from CSV — everything is a string 2const row = { active: 'true', count: '42', deleted: 'false' } 3 4// This will FAIL — 'false' is truthy as a string 5if (row.deleted) { /* always executes */ } 6 7// Correct — explicit type conversion 8const active = row.active === 'true' 9const count = parseInt(row.count, 10) 10const deleted = row.deleted === 'true'
When your CSV data drives assertions that depend on type (boolean checks, numeric comparisons), always convert explicitly. A helper function eliminates the repetition:
TYPESCRIPT1function coerce(value: string): string | number | boolean | null { 2 if (value === '') return null 3 if (value === 'true') return true 4 if (value === 'false') return false 5 const num = Number(value) 6 if (!isNaN(num) && value.trim() !== '') return num 7 return value 8}
Organising your test data files
A clean fixture directory structure prevents the data sprawl that makes large test suites hard to maintain:
tests/
fixtures/
auth/
valid-credentials.csv
invalid-credentials.csv
permission-matrix.json
products/
create-valid.json
create-invalid.json
search-queries.csv
api-responses/
user-profile.json
order-detail.json
error-responses.json
Keep test data close to the tests that use it. If a fixture is only used by one test file, it belongs next to that file. If it's shared across multiple test files, it belongs in a shared fixtures directory.
Version your fixtures with your code. A test that depends on a fixture that isn't in the repository is a test that breaks on a fresh clone.
Share this article
Follow for more
Follow me on social media for more developer tips, tricks, and tutorials. Let's connect and build something great together!