Migrating to Data-Driven Testing: How to Refactor an Existing Test Suite
Most teams don't start with data-driven testing — they end up with hundreds of duplicated test functions and decide to refactor. The migration is straightforward if you follow a systematic approach. This guide covers how to identify migration candidates, execute the refactor safely, and manage the resulting test data.
Identifying Migration Candidates
Not every test benefits from being data-driven. Target these patterns:
Pattern 1: Copy-paste tests with different values
# Before: candidate for data-driven refactoring
def test_login_admin():
response = login('admin@example.com', 'pass')
assert response.status == 200
assert response.body['role'] == 'admin'
def test_login_editor():
response = login('editor@example.com', 'pass')
assert response.status == 200
assert response.body['role'] == 'editor'
def test_login_viewer():
response = login('viewer@example.com', 'pass')
assert response.status == 200
assert response.body['role'] == 'viewer'These three tests have identical structure with different input values. Classic data-driven candidate.
Pattern 2: Sequential boundary tests
def test_password_too_short():
result = validate_password('abc12')
assert not result.valid
assert 'at least 8 characters' in result.errors
def test_password_minimum_length():
result = validate_password('abcd1234')
assert result.valid
def test_password_no_digit():
result = validate_password('abcdefgh')
assert not result.valid
assert 'at least one digit' in result.errorsValidation tests with different inputs and different expected outputs — parametrize works here.
Pattern 3: Same API endpoint, different request/response pairs
def test_get_user_returns_200():
r = get('/users/123')
assert r.status == 200
def test_get_nonexistent_user_returns_404():
r = get('/users/99999')
assert r.status == 404
def test_get_user_without_auth_returns_401():
r = get('/users/123', headers={})
assert r.status == 401These can be data-driven on (path, auth_headers, expected_status).
Anti-pattern: tests with different assertions
def test_create_user():
user = create_user('test@example.com')
assert user.id is not None
assert user.email == 'test@example.com'
assert user.created_at is not None
assert user.role == 'user'
def test_create_admin_user():
user = create_admin_user('admin@example.com')
assert user.id is not None
assert user.email == 'admin@example.com'
assert user.role == 'admin'
assert user.permissions == ['read', 'write', 'delete']These look similar but test different things and have different assertion sets. Don't force them into a single parametrized test — you'll end up with a mess of conditional assertions.
Migration Process
Step 1: Extract the Pattern
Identify the structure of the duplicated tests. What's the same? What varies?
# Common structure:
# 1. Call function with input X
# 2. Assert response code equals Y
# 3. Assert response field Z
# Variables: input, expected_code, expected_field_valueStep 2: Write the Parametrized Test
Start with the simplest cases and verify the new test produces the same results as the originals:
@pytest.mark.parametrize('email,expected_role', [
('admin@example.com', 'admin'),
('editor@example.com', 'editor'),
('viewer@example.com', 'viewer'),
])
def test_login_role_assignment(email, expected_role):
response = login(email, 'pass')
assert response.status == 200
assert response.body['role'] == expected_roleStep 3: Run Both Old and New Tests
Don't delete the old tests yet. Run both to verify the new parametrized test covers the same cases:
# New parametrized test produces 3 test cases
pytest test_login_data_driven.py -v
# PASSED test_login_role_assignment[admin@example.com-admin]
# PASSED test_login_role_assignment[editor@example.com-editor]
# PASSED test_login_role_assignment[viewer@example.com-viewer]Confirm these pass before touching the originals.
Step 4: Delete the Originals
Once you've verified the parametrized version covers all cases:
# Remove original test functions
git diff -- test_login.py # Review the deletionStep 5: Extract Data to External File (When Appropriate)
If the test has more than 10 cases, or if business stakeholders will add cases, extract to a file:
# tests/data/login_cases.json
[
{"email": "admin@example.com", "expected_role": "admin"},
{"email": "editor@example.com", "expected_role": "editor"},
{"email": "viewer@example.com", "expected_role": "viewer"}
]import json
import pytest
def load_login_cases():
with open('tests/data/login_cases.json') as f:
return [(c['email'], c['expected_role']) for c in json.load(f)]
@pytest.mark.parametrize('email,expected_role', load_login_cases())
def test_login_role_assignment(email, expected_role):
response = login(email, 'pass')
assert response.status == 200
assert response.body['role'] == expected_roleFor 2-10 cases, keeping data inline is usually cleaner. For 10+ cases, external files are easier to maintain.
Managing the Data Layer
File Organization
tests/
├── data/
│ ├── auth/
│ │ ├── valid_login_cases.json
│ │ └── invalid_login_cases.csv
│ ├── validation/
│ │ ├── email_cases.csv
│ │ └── password_cases.csv
│ └── api/
│ └── endpoint_contract_cases.jsonGroup data files by the feature they test. This makes it easy to find and update test data when a feature changes.
CSV vs JSON
Use CSV when:
- Data is flat (no nesting)
- Non-technical stakeholders will edit the data
- You want Excel-compatible format
email,password,expected_status,expected_error
valid@test.com,correctpass,200,
invalid@test.com,wrongpass,401,Invalid credentials
locked@test.com,correctpass,403,Account lockedUse JSON when:
- Data is nested or complex
- Test cases have optional fields
- Data is programmatically generated
[
{
"input": {"email": "valid@test.com", "password": "correctpass"},
"expected": {"status": 200, "role": "user"}
},
{
"input": {"email": "invalid@test.com", "password": "wrongpass"},
"expected": {"status": 401, "errors": ["Invalid credentials"]}
}
]Generating Data Programmatically
For boundary value analysis, generate data in conftest.py:
# conftest.py
import pytest
def generate_email_boundary_cases():
return [
# (email, should_be_valid, error_message)
('', False, 'Email is required'),
('notanemail', False, 'Invalid email format'),
('missing@', False, 'Invalid email format'),
('@nodomain.com', False, 'Invalid email format'),
('valid@example.com', True, None),
('user+tag@example.com', True, None),
('a' * 240 + '@example.com', False, 'Email too long'),
('valid@' + 'a' * 240 + '.com', False, 'Email too long'),
]
@pytest.fixture(params=generate_email_boundary_cases())
def email_test_case(request):
email, expected_valid, expected_error = request.param
return {'email': email, 'valid': expected_valid, 'error': expected_error}Generated cases are self-documenting: the code shows the logic, and the parametrize output shows the values.
Handling Sensitive Test Data
Never store real user credentials, real credit card numbers, or PII in test data files. Use:
Test-only data: Stripe's test card numbers (4242424242424242), generated emails like test+{uuid}@example.com, fake names from Faker.
Environment variables for credentials:
import os
@pytest.mark.parametrize('role,env_var', [
('admin', 'TEST_ADMIN_EMAIL'),
('user', 'TEST_USER_EMAIL'),
])
def test_login(role, env_var):
email = os.environ[env_var]
response = login(email, os.environ['TEST_PASSWORD'])
assert response.body['role'] == roleThe data file specifies the role; the credentials come from environment variables. The data file is safe to commit; the environment variables are secrets.
Measuring Migration Results
After migrating a batch of tests, measure:
Test count: Did the number of test functions decrease? (Yes, if you replaced 30 duplicate functions with 1 parametrized function — but test cases should stay the same.)
Execution time: Did suite execution speed improve? (Should improve slightly due to less test discovery overhead.)
Maintenance incidents: Track how often test code changes due to application changes. Parametrized tests with external data files should require fewer code changes — stakeholders update the data file, not the test code.
New test case additions: Is it easier to add test cases now? If adding a new validation case went from "add a new test function" to "add a row to a CSV," the migration is successful.
Common Migration Mistakes
Forcing non-uniform tests into parametrize. If two tests differ in assertions (not just inputs), they're different tests. Don't parametrize them with conditional assertions — it's harder to read and harder to debug.
Abandoning test IDs. Default parametrize IDs are numeric (test_login[0], test_login[1]). Always provide readable IDs (test_login[admin], test_login[viewer]). Failure messages must be readable without looking up which index is which.
Putting too much data in external files. If a test has 3 cases, inline them. External files for small datasets add unnecessary indirection.
Ignoring test ordering in parametrize. Most frameworks run parametrized tests in definition order. If your tests have side effects that depend on ordering, you'll get flaky results. Fix the side effects, not the ordering.
Summary
Data-driven migration is worthwhile when you have duplicate tests with identical logic and different inputs. The process: identify patterns, write parametrized version, verify coverage parity, delete originals. External data files are appropriate for 10+ cases or non-developer-maintained data. The measure of success isn't fewer lines of test code — it's reduced maintenance burden when application inputs or validation rules change.