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.

  1. Create a JSON file with 15+ test cases covering valid inputs, boundary values, and invalid inputs
  2. Write a parameterized test that reads from the JSON file
  3. Include at least 3 boundary value tests for the age field
  4. Include at least 3 negative tests for email format validation
  5. 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