How to separate test data from code: testing with CSV in pytest

By Volodymyr Obrizan on May 22, 2025 · Read this post in other languages: Russian Ukrainian

You have a function with simple but branched logic: depending on a pair of conditions — a different result is returned. In manual testing, such logic is described in words: “if the speed exceeds the limit by up to 10 km/h — the fine is $50, and if it is also in a school zone — multiply by 2, and if the driver has penalty points — add $25 for each.”

To automate such checks, you will have to write dozens of examples — and here arises the question: where to store them and how to conveniently run them?

In this post, I will show an example of such a function and three ways to organize automated tests:

  1. as separate test functions;
  2. as a parameterized list in the code;
  3. as a separate CSV file with data — and this is the most convenient option if there are many examples.

At the end, I will provide a link to GitHub with the full example code.

Contents:

Example of the function to test

It often happens that you need to test a non-trivial function with a large number of test cases. For example: a function that calculates a fine for speeding depending on cases:

  • how much the speed limit is exceeded: <=10 km/h, <=20 km/h, >20 km/h;
  • in which zone the speed was violated: residential area, school, road construction, highway;
  • number of penalty points on the driverʼs license.
def calculate_traffic_fine(speed: int, limit: int, zone: str, license_points: int) -> int:
    """
    Calculates the traffic fine based on speed, limit, zone, and driver's points.

    Parameters:
        speed (int): Vehicle speed.
        limit (int): Allowed speed limit.
        zone (str): Type of zone (residential, school, highway, construction).
        license_points (int): Number of penalty points on the driver’s license.

    Returns:
        int: Fine amount in dollars.
    """
    over_speed = speed - limit

    if over_speed <= 0:
        # No speeding — no fine
        return 0

    # Base fine tiers
    if over_speed <= 10:
        base_fine = 50
    elif over_speed <= 20:
        base_fine = 100
    else:
        base_fine = 200

    # Fine multipliers for different zone types
    zone_multipliers = {
        'residential': 1.5,   # residential area
        'school': 2.0,        # school zone
        'construction': 2.5,  # road construction zone
        'highway': 1.0,       # highway
    }

    multiplier = zone_multipliers.get(zone.lower(), 1.0)

    # Additional penalty depending on points
    penalty = 25 * license_points

    total_fine = int(base_fine * multiplier + penalty)
    return total_fine

Such logic cannot be reliably tested with 1-2 test cases because there are several dozen possible condition combinations. Letʼs consider several approaches to solve this task.

One test — one function

One test — one function — is a traditional approach, but it becomes obvious that the test code is repetitive and must be constantly copied.

def test_no_fine():
    assert calculate_traffic_fine(50, 50, "residential", 0) == 0, "No speeding"

def test_minimum_fine():
    assert calculate_traffic_fine(51, 50, "highway", 0) == 50, "Minimal speeding"

def test_school_area():
    assert calculate_traffic_fine(51, 50, "school", 0) == 100, "Near school 2x multiplier"

# And 10 more such functions.

Parameterizing tests in code

A better approach is to use test parameterization in pytest. The advantage of this approach is that now you donʼt have to copy the test itself, only copy the lines with test data. For this, the fixture-decorator @pytest.mark.parametrize is used:

@pytest.mark.parametrize(
    "speed,limit,zone,license_points,expected,msg",
    [
        (50, 50, "residential", 0, 0, "No speeding"),
        (51, 50, "highway", 0, 50, "Minimal speeding"),
        (51, 50, "school", 0, 100, "Near school 2x multiplier"),
        # ...
        # 10 more such examples.
    ]
)
def test_calculate_traffic_fine(speed, limit, zone, license_points, expected, msg):
    assert calculate_traffic_fine(speed, limit, zone, license_points) == expected, msg

Test data in a CSV file

You can prepare a table with test data in Microsoft Excel or Google Docs:

Screenshot 2025-05-22 at 20.59.17.png

The msg column is used to annotate tests as a free comment describing what the test checks.

Google Docs allows saving the table as a CSV file:

Screenshot 2025-05-22 at 20.59.31.png

The CSV file will look like this:

speed,limit,zone,license_points,expected,msg
50,50,residential,0,0,No speeding
51,50,highway,0,50,Minimal speeding
51,50,school,0,100,Near school 2x multiplier
51,50,highway,1,75,Repeat violation

pytest-csv-params is a pytest plugin that can import test data from a CSV file.

You need to install this plugin using pip:

pip install pytest-csv-params

or using uv:

uv add pytest-csv-params

To parameterize a test with data from this CSV file, use the fixture-decorator @csv_params with the following parameters:

  • data_file — name of the CSV file with test data;
  • data_casts — instructions for converting data from the CSV file, because by default all data in the CSV file is interpreted as strings (str). In our example, we specify to convert speed, limit, license_points, and expected to int.
from pytest_csv_params.decorator import csv_params

@csv_params(
    data_file="calculate_traffic_fine_tests.csv",
    data_casts={
        "speed": int, "limit": int, "license_points": int, "expected": int,
    },
)
def test_calculate_traffic_fine(speed, limit, zone, license_points, expected, msg):
    assert calculate_traffic_fine(speed, limit, zone, license_points) == expected, msg

Run tests with the command:

pytest test_csv_params.py 

and you will see a message in the terminal that 4 tests were found and all passed successfully:

platform darwin -- Python 3.13.1, pytest-8.3.5, pluggy-1.6.0
rootdir: /Users/obrizan/Projects/Blog/pytest-csv-params-demo
configfile: pyproject.toml
plugins: csv-params-1.2.0
collected 4 items

test_csv_params.py::test_calculate_traffic_fine[50-50-residential-0-0-No speeding] PASSED    [ 25%]
test_csv_params.py::test_calculate_traffic_fine[51-50-highway-0-50-Minimal speeding] PASSED  [ 50%]
test_csv_params.py::test_calculate_traffic_fine[51-50-school-0-100-Near school 2x multiplier] PASSED  [ 75%]
test_csv_params.py::test_calculate_traffic_fine[51-50-highway-1-75-Repeat violation] PASSED   [100%]

This promotes separation of test data from test code, allowing you to modify them independently.

Conclusions

If you have a function with many behavior variants, one or two test cases are not enough. You need to cover all logic branches and edge cases. Writing a separate test function for each case is inconvenient. Code duplication occurs, and adding new examples becomes cumbersome.

Parameterization via @pytest.mark.parametrize greatly improves readability and allows easy addition of new cases. Moving test data to a CSV file simplifies the test structure even more, separates logic from data, and allows editing examples in a tabular form — even without opening a code editor. The pytest-csv-params plugin is easy to use, flexible, and well suited for team work when test cases can be created by both developers and QA specialists.

If your functions take many parameters and have many branches — test them once through CSV. Literally.

Example code on GitHub: https://github.com/1irs/pytest-csv-params-demo

Telegram
Viber
LinkedIn
WhatsApp

Comments

Sign in to leave a comment.

← Back to blog