Data-Driven Testing
Data-driven testing separates test logic from test data: one test body, many datasets. Parametrize inputs from arrays, CSV, JSON or Excel, and you get broad coverage, readable reports, and data that business users can own without touching code.
What is this?
Data-driven testing (DDT) keeps the test procedure fixed and varies only the data it runs against. The same test body executes once per dataset row - each row being an input set plus expected outcome. pytest's @pytest.mark.parametrize, JUnit's @ParameterizedTest and Playwright's fixtures for() all express this pattern, which turns one test into a matrix of cases with full reporting per row.
Why do you need it?
- One code path validates dozens of inputs, so coverage grows without a proportional maintenance burden.
- A new edge case is a data row, not new code - non-developers can add cases from a CSV or JSON file.
- Each row runs independently and reports separately, so a failing case does not hide the others.
- Boundary conditions (empty strings, nulls, unicode, huge numbers) are easy to encode explicitly.
- Data can be generated or sourced from production captures, giving realistic, high-value coverage.
Mental model
A recipe card
A data row
A single recipe (test body) describes how to cook any dish; the ingredients list (data row) is what changes. Cook the recipe ten times with ten different ingredient lists and you get ten meals from one card. Data-driven testing cooks the same recipe once per row.
How it works
- Define a test body that takes inputs and an expected result as parameters.
- Declare a data source: inline tuples, a CSV/JSON file, or a factory that yields rows.
- The runner expands one case per dataset row and executes the body once for each.
- Each case gets a unique test ID (for example test_add[3-6-9]) so reports and failures are traceable.
- One row failing fails just that case; the runner continues the rest and reports the matrix.
One test body expanding across a data matrix.
Key commands
pytest test_login.py -k 'mobile or desktop'Run only the parametrized cases whose test IDs match mobile or desktop.
mvn test -Dtest=PasswordValidatorTestRun the parameterized JUnit 5 test class with Maven.
npx playwright test --grep 'checkout'Filter test titles; parametrized Playwright fixtures produce per-case titles.
python -m pytest --collect-only -qPreview the exact list of expanded cases and their IDs before running.
python -m pytest --durations=5Show the slowest cases, useful when one data row dominates runtime.
npx jest -t 'password'Run test cases whose names contain 'password' when tests are named from data.
Examples
JUnit 5 parameterized test with a CSV source
import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvFileSource;import org.junit.jupiter.params.provider.ValueSource;import static org.junit.jupiter.api.Assertions.*; class PasswordValidatorTest { @ParameterizedTest @ValueSource(strings = { "longenough", "1ng3$Passw0rd!x" }) void acceptsValidPasswords(String password) { assertTrue(PasswordValidator.isValid(password)); } @ParameterizedTest @CsvFileSource(resources = "/invalid-passwords.csv", numLinesToSkip = 1) void rejectsInvalidPasswords(String password, String reason) { assertFalse(PasswordValidator.isValid(password), reason); }}The first test feeds inline values with @ValueSource; the second reads rows from invalid-passwords.csv, one argument per column, skipping the header. Both execute once per value. The second argument (reason) doubles as a JUnit assertion message, so a failure says exactly why the case should be invalid.
In production
- Checkout and payment suites run one flow against dozens of price, currency and flag combinations.
- Validation tests read from CSV owned by QA or product, adding edge cases without code changes.
- API contract tests replay captured production request/response pairs as a regression matrix.
- Cross-browser matrixes combine data-driven inputs with browser parameterization in one report.
Best practices
- Keep the data file close to the test and document columns with a header row and a README note.
- Name cases meaningfully: pytest.param(...) with ids, or JUnit @DisplayName templates per row.
- Assert both happy paths and rejection paths - including an explicit reason string for each negative case.
- Validate the data file early (column count, encoding) so a bad CSV fails fast, not mid-matrix.
- Avoid thousands of near-identical rows; keep high-signal cases that target boundaries and edge states.
Common mistakes
- Putting the expected value inside the test body instead of the data, silently duplicating logic per row.
- Using Excel or CSV with fragile formatting (trailing commas, quotes, encoding) that breaks row parsing.
- Mixing cases that need different setups in one parametrized test, forcing awkward branching in the body.
- Copying data files into multiple suites so a fix in one place does not propagate to the others.
Common issues
Premature end of CSV dataCSV file truncated or an encoding issue (BOM, non-UTF8).
Re-export the CSV as UTF-8 without BOM and verify the trailing newline.
Parameterized argument [0] did not match count of 2 parametersColumn count in the file does not match the method's parameter count.
Align CSV columns with the method signature, or use numLinesToSkip and check the header.
Collected 0 itemsData file path or glob is wrong, or the file filter excludes the rows.
Verify the resources path and file naming, and run --collect-only to see what matched.
Debugging tips
- Run --collect-only to confirm every row expanded into the case you expect, including IDs.
- Use -k or --grep to run a single row's case instead of the whole matrix.
- Add the row's data to the failure message so CI logs identify the exact input that failed.
- When a row fails, diff it against a passing row to isolate which input changed the outcome.
Interview checkpoint
Hands-on lab
- Create a CSV data source with pricing rows
- Write a parametrized test that reads the CSV
- Add a reason column for failing-case clarity
- Run the matrix and interpret per-case results
Validate Checkout Pricing from a CSV
40 min ยท intermediate
Quick quiz
Which pytest feature lets one test run against a matrix of inputs?
If one data row fails in a parametrized test, the rest of the rows still run and are reported.
You added 50 rows to a CSV, but pytest reports 'Collected 0 items'. What is the most likely cause?
Cheat sheet
@pytest.mark.parametrize('a,b,expected', [(1,2,3)])pytest inline rowsids=['case-a', 'case-b']Meaningful case names@CsvFileSource(resources='/data.csv')JUnit CSV source@ValueSource(strings={...})JUnit single-arg sourcetestData.forEach((row) => test(...))JS data-driven looppython -m pytest --collect-onlyPreview expanded cases