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:
- as separate test functions;
- as a parameterized list in the code;
- 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:
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:
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 convertspeed
,limit
,license_points
, andexpected
toint
.
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