Why write the same test five times when you can write it once and feed it five data sets? In this lesson you will build data-driven Playwright tests with simple loops, typed test data, external JSON and CSV files, and project-level parameters.
๐ What You’ll Learn: what data-driven testing is, parameterizing with for…of, unique test titles, typing data with interfaces, describe blocks per data set, loading data from JSON and CSV, parameterizing projects with fixture options, and best practices.
๐ What Is Data-Driven Testing?
Data-driven (parameterized) testing means writing the test logic once and running it with many sets of data. Instead of four nearly identical login tests, you write one test and feed it four users.
Benefits:
- Less duplicated code, so fixes happen in one place.
- Adding a scenario means adding one line of data.
- Each data set still appears as a separate test in the report, so you see exactly which input failed.
Playwright Test has no special “data provider” annotation. You simply generate tests with a normal loop, which makes it flexible and easy to understand.
๐ Parameterize with a for…of Loop
All error messages below come from the Sauce Demo login page:
import { test, expect } from '@playwright/test';
const invalidLogins = [
{ name: 'locked out user', username: 'locked_out_user', password: 'secret_sauce', error: 'Sorry, this user has been locked out.' },
{ name: 'wrong password', username: 'standard_user', password: 'wrong', error: 'Username and password do not match' },
{ name: 'empty username', username: '', password: 'secret_sauce', error: 'Username is required' },
{ name: 'empty password', username: 'standard_user', password: '', error: 'Password is required' },
];
for (const data of invalidLogins) {
test('login fails: ' + data.name, async ({ page }) => {
await page.goto('https://www.saucedemo.com/');
await page.getByPlaceholder('Username').fill(data.username);
await page.getByPlaceholder('Password').fill(data.password);
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.locator('[data-test="error"]')).toContainText(data.error);
});
}
Running it produces four tests:
โ login fails: locked out user
โ login fails: wrong password
โ login fails: empty username
โ login fails: empty password
โ ๏ธ Common mistake: Every test needs a unique title. If two generated tests have the same name, Playwright reports a “duplicate test title” error. Always include a value from the data in the title.
๐งพ Type Your Test Data
Give the data a type so mistakes are caught in the editor (TypeScript interfaces):
interface LoginCase {
name: string;
username: string;
password: string;
error: string;
}
const invalidLogins: LoginCase[] = [
{ name: 'locked out user', username: 'locked_out_user', password: 'secret_sauce', error: 'Sorry, this user has been locked out.' },
// a typo such as "pasword" now shows a red underline
];
๐ Another Example: Sorting Options
const sortCases = [
{ option: 'az', label: 'Name (A to Z)', firstName: 'Sauce Labs Backpack' },
{ option: 'za', label: 'Name (Z to A)', firstName: 'Test.allTheThings() T-Shirt (Red)' },
{ option: 'lohi', label: 'Price (low to high)', firstPrice: '$7.99' },
{ option: 'hilo', label: 'Price (high to low)', firstPrice: '$49.99' },
];
test.describe('product sorting', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://www.saucedemo.com/');
await page.getByPlaceholder('Username').fill('standard_user');
await page.getByPlaceholder('Password').fill('secret_sauce');
await page.getByRole('button', { name: 'Login' }).click();
});
for (const c of sortCases) {
test('sort by ' + c.label, async ({ page }) => {
await page.locator('[data-test="product-sort-container"]').selectOption(c.option);
if (c.firstName) {
await expect(page.locator('.inventory_item_name').first()).toHaveText(c.firstName);
}
if (c.firstPrice) {
await expect(page.locator('.inventory_item_price').first()).toHaveText(c.firstPrice);
}
});
}
});
๐ฆ One describe Block per Data Set
When each data set needs several tests, generate a describe block per item:
const users = ['standard_user', 'problem_user', 'performance_glitch_user'];
for (const user of users) {
test.describe('as ' + user, () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://www.saucedemo.com/');
await page.getByPlaceholder('Username').fill(user);
await page.getByPlaceholder('Password').fill('secret_sauce');
await page.getByRole('button', { name: 'Login' }).click();
});
test('sees the products page', async ({ page }) => {
await expect(page.locator('.title')).toHaveText('Products');
});
test('can open the cart', async ({ page }) => {
await page.locator('.shopping_cart_link').click();
await expect(page).toHaveURL(/cart\.html/);
});
});
}
๐ Load Data from External Files
Keep large data sets outside the test file, so non-programmers can maintain them.
JSON
// test-data/invalid-logins.json โ [{ "name": "...", "username": "...", "password": "...", "error": "..." }]
import invalidLogins from '../test-data/invalid-logins.json';
for (const data of invalidLogins) {
test('login fails: ' + data.name, async ({ page }) => { /* ... */ });
}
CSV
import fs from 'fs';
import path from 'path';
import { parse } from 'csv-parse/sync'; // npm install -D csv-parse
const records: { name: string; username: string; password: string; error: string }[] =
parse(fs.readFileSync(path.join(__dirname, '../test-data/logins.csv')), { columns: true, skip_empty_lines: true });
for (const data of records) {
test('csv login: ' + data.name, async ({ page }) => { /* ... */ });
}
Full walkthroughs: read test data from JSON and read test data from CSV.
๐ Note: Test data used to generate tests must be available when Playwright collects the tests, so read it at the top level of the file (synchronously). Data loaded inside beforeAll is too late to create new tests.
๐งฉ Parameterize Whole Projects with Options
Sometimes you want to run the entire suite with different values, for example as different user roles or against different environments. Define a fixture option and set it per project:
// fixtures.ts
import { test as base } from '@playwright/test';
export type TestOptions = { role: string };
export const test = base.extend<TestOptions>({
role: ['standard_user', { option: true }], // default value, can be overridden in the config
});
export { expect } from '@playwright/test';
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import type { TestOptions } from './fixtures';
export default defineConfig<TestOptions>({
projects: [
{ name: 'standard', use: { ...devices['Desktop Chrome'], role: 'standard_user' } },
{ name: 'problem', use: { ...devices['Desktop Chrome'], role: 'problem_user' } },
],
});
// tests/roles.spec.ts
import { test, expect } from '../fixtures';
test('products page loads', async ({ page, role }) => {
await page.goto('https://www.saucedemo.com/');
await page.getByPlaceholder('Username').fill(role);
await page.getByPlaceholder('Password').fill('secret_sauce');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.locator('.title')).toHaveText('Products');
});
Each test now runs once per project, with a different role. This builds on custom fixtures.
โ Best Practices
- Include the distinguishing value in every generated test title.
- Keep each data row small and focused on one scenario.
- Type your data with an interface.
- Put large or frequently changing data in JSON or CSV files.
- Do not loop inside one test over many inputs: the first failure stops the remaining checks and the report shows one test instead of many.
// โ One test, many inputs: stops at the first failure
test('all invalid logins', async ({ page }) => {
for (const data of invalidLogins) { /* ... */ }
});
๐ Assignment
- Create the four invalid-login cases above and run them; confirm the report shows four separate tests.
- Add an interface for the data and introduce a deliberate typo to see the editor error.
- Move the data into
test-data/invalid-logins.jsonand import it. - Write a parameterized test for the four sorting options.
- Generate a describe block per user (
standard_user,problem_user) with two tests each. - Bonus: create a
roleoption with{ option: true }and two projects that use different roles.
โ FAQ
๐ค How do I write data-driven tests in Playwright?
Put your data in an array (or load it from JSON or CSV) and create a test for each item with a for...of loop, using a value from the data in the test title.
๐ค Does Playwright have a data provider like TestNG?
Not as an annotation. A normal loop that calls test() for each data item does the same job, and each item appears as a separate test in the report.
๐ค Why do I get a duplicate test title error?
Two generated tests have the same title. Include a unique value from the data, such as a name or id, in each title.
๐ค Can I read test data in beforeAll for parameterized tests?
Not for generating tests. Tests are created when the file is loaded, so read the data at the top level of the file.
๐ค How do I run the whole suite with different parameters?
Define a fixture option with { option: true } and set different values in several projects in the config.
๐ฏ Next Steps
- ๐ญ Read Test Data from JSON
- ๐ญ Read Test Data from CSV
- ๐ญ Cross-Browser Testing with Projects
- ๐ญ Playwright Tutorial with TypeScript
๐ฌ Watch the full Playwright tutorial series on YouTube!
Subscribe to SqaToolsByDY • Practice on sqatools.in
3 thoughts on “Data-Driven (Parameterized) Tests in Playwright with TypeScript”