What Is Data-Driven Testing?
Data-driven testing separates test logic from test data. Instead of writing a separate test for each input combination, you write one test that runs multiple times with different data sets.
Without data-driven approach (5 separate tests):
test('login with admin credentials', async ({ page }) => {
await loginPage.login('admin@test.com', 'AdminPass1');
await expect(page).toHaveURL('/dashboard');
});
test('login with editor credentials', async ({ page }) => {
await loginPage.login('editor@test.com', 'EditorPass1');
await expect(page).toHaveURL('/dashboard');
});
// ... 3 more nearly identical tests
With data-driven approach (1 test, 5 data sets):
const validUsers = [
{ email: 'admin@test.com', password: 'AdminPass1', role: 'admin' },
{ email: 'editor@test.com', password: 'EditorPass1', role: 'editor' },
{ email: 'viewer@test.com', password: 'ViewerPass1', role: 'viewer' },
{ email: 'manager@test.com', password: 'MgrPass1', role: 'manager' },
{ email: 'support@test.com', password: 'SupportPass1', role: 'support' },
];
for (const user of validUsers) {
test(`login with ${user.role} credentials`, async ({ page }) => {
await loginPage.login(user.email, user.password);
await expect(page).toHaveURL('/dashboard');
});
}
Data-Driven Testing in Playwright
Using test.describe and Loops
const loginScenarios = [
{ email: 'admin@test.com', password: 'valid', expectSuccess: true },
{ email: 'admin@test.com', password: 'wrong', expectSuccess: false },
{ email: '', password: 'valid', expectSuccess: false },
{ email: 'invalid-format', password: 'valid', expectSuccess: false },
{ email: 'locked@test.com', password: 'valid', expectSuccess: false },
];
test.describe('Login scenarios', () => {
for (const scenario of loginScenarios) {
test(`login with email="${scenario.email}" should ${scenario.expectSuccess ? 'succeed' : 'fail'}`, async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(scenario.email, scenario.password);
if (scenario.expectSuccess) {
await expect(page).toHaveURL('/dashboard');
} else {
await expect(loginPage.errorMessage).toBeVisible();
}
});
}
});
Using External JSON Files
// test-data/users.json
{
"validUsers": [
{ "email": "admin@test.com", "password": "AdminPass1", "role": "admin" },
{ "email": "editor@test.com", "password": "EditorPass1", "role": "editor" }
],
"invalidCredentials": [
{ "email": "wrong@test.com", "password": "wrong", "error": "Invalid credentials" },
{ "email": "", "password": "", "error": "Email is required" }
]
}
import testData from './test-data/users.json';
test.describe('Valid login', () => {
for (const user of testData.validUsers) {
test(`${user.role} can login`, async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(user.email, user.password);
await expect(page).toHaveURL('/dashboard');
});
}
});
Using CSV Files
import fs from 'fs';
function loadCSV(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n');
const headers = lines[0].split(',');
return lines.slice(1).map(line => {
const values = line.split(',');
return headers.reduce((obj, header, i) => {
obj[header.trim()] = values[i].trim();
return obj;
}, {});
});
}
const priceData = loadCSV('test-data/prices.csv');
for (const row of priceData) {
test(`product ${row.name} has price ${row.expectedPrice}`, async ({ page }) => {
await page.goto(`/products/${row.slug}`);
const price = await page.textContent('.price');
expect(price).toBe(row.expectedPrice);
});
}
Common Data-Driven Patterns
Boundary Value Testing
const ageValidation = [
{ age: -1, valid: false, desc: 'negative' },
{ age: 0, valid: false, desc: 'zero' },
{ age: 1, valid: true, desc: 'minimum valid' },
{ age: 17, valid: false, desc: 'underage' },
{ age: 18, valid: true, desc: 'exactly minimum age' },
{ age: 120, valid: true, desc: 'maximum valid' },
{ age: 121, valid: false, desc: 'over maximum' },
{ age: 999, valid: false, desc: 'unrealistic' },
];
for (const { age, valid, desc } of ageValidation) {
test(`age ${age} (${desc}) should be ${valid ? 'accepted' : 'rejected'}`, async ({ page }) => {
await registrationPage.enterAge(age);
await registrationPage.submit();
if (valid) {
await expect(registrationPage.successMessage).toBeVisible();
} else {
await expect(registrationPage.errorMessage).toBeVisible();
}
});
}
Cross-Browser Data
const browsers = ['chromium', 'firefox', 'webkit'];
const viewports = [
{ width: 375, height: 667, name: 'iPhone SE' },
{ width: 768, height: 1024, name: 'iPad' },
{ width: 1920, height: 1080, name: 'Desktop' },
];
for (const viewport of viewports) {
test(`homepage renders correctly on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/');
await expect(page.locator('.hero')).toBeVisible();
await page.screenshot({ path: `screenshots/home-${viewport.name}.png` });
});
}
Environment-Based Data
Use environment variables to switch data sets between environments:
const environments = {
dev: {
baseUrl: 'https://dev.example.com',
adminEmail: 'admin@dev.test.com',
adminPassword: 'DevPass123',
},
staging: {
baseUrl: 'https://staging.example.com',
adminEmail: 'admin@staging.test.com',
adminPassword: 'StagingPass123',
},
production: {
baseUrl: 'https://example.com',
adminEmail: 'admin@example.com',
adminPassword: process.env.PROD_ADMIN_PASSWORD,
},
};
const env = environments[process.env.TEST_ENV || 'dev'];
test('admin can access dashboard', async ({ page }) => {
await page.goto(env.baseUrl);
await loginPage.login(env.adminEmail, env.adminPassword);
await expect(page).toHaveURL(`${env.baseUrl}/dashboard`);
});
Avoiding Combinatorial Explosion
With multiple parameters, the number of combinations grows exponentially. Use pairwise testing to reduce combinations while maintaining coverage.
The Problem
5 fields × 10 values each = 100,000 combinations. Testing all of them is impractical.
The Solution: Pairwise Testing
Instead of all combinations, test every pair of values at least once. This typically requires only 50-100 test cases to cover all pairwise interactions.
// Instead of all combinations, use representative pairs
const checkoutData = [
{ payment: 'visa', shipping: 'standard', currency: 'USD' },
{ payment: 'visa', shipping: 'express', currency: 'EUR' },
{ payment: 'mastercard', shipping: 'standard', currency: 'EUR' },
{ payment: 'mastercard', shipping: 'express', currency: 'USD' },
{ payment: 'paypal', shipping: 'standard', currency: 'USD' },
{ payment: 'paypal', shipping: 'express', currency: 'EUR' },
];
Test Data Factories
For complex test data, use factory functions:
function createOrder(overrides = {}) {
return {
product: 'Widget',
quantity: 1,
price: 29.99,
currency: 'USD',
shipping: 'standard',
...overrides,
};
}
const orders = [
createOrder({ quantity: 1, shipping: 'standard' }),
createOrder({ quantity: 100, shipping: 'express' }),
createOrder({ quantity: 0, price: 0 }), // Edge case
createOrder({ currency: 'EUR', price: 24.99 }),
];
Exercise: Build Data-Driven Tests
Create a data-driven test suite for a user registration form with these fields: name, email, password, age, country.
- Create a JSON file with 15+ test cases covering valid inputs, boundary values, and invalid inputs
- Write a parameterized test that reads from the JSON file
- Include at least 3 boundary value tests for the age field
- Include at least 3 negative tests for email format validation
- Add cross-viewport testing for 3 screen sizes
Key Takeaways
- Data-driven testing eliminates code duplication by parameterizing test inputs
- External data sources (JSON, CSV) allow data to be maintained independently
- Use boundary value analysis to create meaningful test data sets
- Watch for combinatorial explosion — use pairwise testing to keep suites manageable
- Factory functions create flexible, readable test data
- Environment variables switch data between dev, staging, and production