Migrating to Data-Driven Testing: How to Refactor an Existing Test Suite

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.errors

Validation 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 == 401

These 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_value

Step 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_role

Step 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 deletion

Step 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_role

For 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.json

Group 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 locked

Use 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'] == role

The 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.

Read more

Start now free