Как отделить тестовые данные от кода: тестирование с CSV в pytest
By Volodymyr Obrizan on Май 22, 2025 · Прочитать этот пост на других языках: English Ukrainian
У вас есть функция с простой, но разветвлённой логикой: в зависимости от пары условий — возвращается разный результат. В ручном тестировании такая логика описывается словами: «если превышение скорости до 10 км/ч — штраф $50, а если это ещё и в школьной зоне — умножаем на 2, а если у водителя есть штрафные баллы — добавляем по $25 за каждый».
Чтобы автоматизировать такие проверки, придётся написать десятки примеров — и тут возникает вопрос: где их хранить и как удобно запускать?
В этом посте я покажу пример такой функции и три способа, как можно организовать автотесты:
- в виде отдельных тест-функций;
- в виде параметризованного списка в коде;
- в виде отдельного CSV-файла с данными — и это самый удобный вариант, если примеров много.
В конце я дам ссылку на GitHub с полным кодом примеров.
Содержание:
Пример функции, которую нужно протестировать
Часто бывает такая ситуация, что нужно протестировать нетривиальную функцию с большим количеством тестовых примеров. Например: функция, которая рассчитывает штраф за превышение скорости в зависимости от случаев:
- насколько превышена разрешённая скорость: <=10 км/ч, <=20 км/ч, >20 км/ч;
- в какой зоне нарушена скорость: жилая застройка, учебное заведение, ремонт дороги, автомагистраль;
- количество штрафных баллов у водителя.
def calculate_traffic_fine(speed: int, limit: int, zone: str, license_points: int) -> int:
"""
Вычисляет штраф за нарушение правил дорожного движения на основе скорости, ограничения, зоны и баллов водителя.
Параметры:
speed (int): Скорость транспортного средства.
limit (int): Допустимое ограничение скорости.
zone (str): Тип зоны (residential, school, highway, construction).
license_points (int): Количество штрафных баллов у водителя.
Возвращает:
int: Размер штрафа в долларах.
"""
over_speed = speed - limit
if over_speed <= 0:
# Нет превышения скорости — штраф не начисляется
return 0
# Базовые уровни штрафа
if over_speed <= 10:
base_fine = 50
elif over_speed <= 20:
base_fine = 100
else:
base_fine = 200
# Множители штрафов для разных типов зон
zone_multipliers = {
'residential': 1.5, # жилая зона
'school': 2.0, # школьная зона
'construction': 2.5, # зона дорожных работ
'highway': 1.0, # автомагистраль
}
multiplier = zone_multipliers.get(zone.lower(), 1.0)
# Дополнительное начисление в зависимости от имеющихся баллов
penalty = 25 * license_points
total_fine = int(base_fine * multiplier + penalty)
return total_fine
Такую логику невозможно надёжно протестировать 1-2 тестовыми случаями, потому что возможных комбинаций условий несколько десятков. Рассмотрим несколько подходов, как можно решить эту задачу.
Один тест — одна функция
Один тест — одна функция — это традиционный подход, но становится заметно, что код тестов однообразен и его надо постоянно копировать.
def test_no_fine():
assert calculate_traffic_fine(50, 50, "residential", 0) == 0, "Нет превышения скорости"
def test_minimum_fine():
assert calculate_traffic_fine(51, 50, "highway", 0) == 50, "Минимальное превышение"
def test_school_area():
assert calculate_traffic_fine(51, 50, "school", 0) == 100, "Возле школы коэф. 2х"
# И ещё 10 таких функций.
Параметризация тестов в коде
Лучший подход — использовать параметризацию тестов в pytest. Преимущество такого подхода в том, что теперь не нужно копировать сам тест, лишь копировать строки с тестовыми данными. Для этого используется фикстура-декоратор @pytest.mark.parametrize
:
@pytest.mark.parametrize(
"speed,limit,zone,license_points,expected,msg",
[
(50, 50, "residential", 0, 0, "Нет превышения скорости"),
(51, 50, "highway", 0, 50, "Минимальное превышение"),
(51, 50, "school", 0, 100, "Возле школы коэф. 2х"),
# ...
# Ещё 10 таких примеров.
]
)
def test_calculate_traffic_fine(speed, limit, zone, license_points, expected, msg):
assert calculate_traffic_fine(speed, limit, zone, license_points) == expected, msg
Тестовые данные в CSV-файле
Таблицу с тестовыми данными можно подготовить в Microsoft Excel или Google Docs:
Колонка msg
используется для аннотации тестов, как произвольный комментарий, который описывает, что этот тест проверяет.
Google Docs имеет возможность сохранить таблицу в CSV файл:
CSV-файл будет иметь такой вид:
speed,limit,zone,license_points,expected,msg
50,50,residential,0,0,Нет превышения скорости
51,50,highway,0,50,Минимальное превышение
51,50,school,0,100,Возле школы коэф. 2х
51,50,highway,1,75,Повторное превышение
pytest-csv-params — это плагин для pytest, который может импортировать тестовые данные из CSV-файла.
Этот плагин нужно установить с помощью pip
:
pip install pytest-csv-params
или с помощью uv
:
uv add pytest-csv-params
Чтобы параметризовать тест данными из этого CSV-файла, нужно использовать фикстуру-декоратор @csv_params
с следующими параметрами:
data_file
— имя CSV-файла с тестовыми данными;data_casts
— это указание по преобразованию данных из CSV-файла, потому что по умолчанию все данные в CSV-файле интерпретируются как строки (типstr
). В нашем примере мы даём указание преобразоватьspeed
,limit
,license_points
иexpected
в значения типа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
Запускаем тесты с помощью команды:
pytest test_csv_params.py
и получаем сообщение в терминале, что найдено 4 теста, и что они все успешно выполнены:
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%]
Это способствует отделению тестовых данных от кода автотестов, что даёт возможность модифицировать их отдельно.
Выводы
Если у вас есть функция с большим количеством вариантов поведения, один-два тестовых примера — недостаточно. Нужно покрывать все ветки логики и крайние случаи. Писать отдельную тест-функцию для каждого случая — неудобно. Код дублируется, а добавление новых примеров становится громоздким.
Параметризация через @pytest.mark.parametrize
значительно улучшает читаемость и позволяет легко добавлять новые случаи. Вынесение тестовых данных в CSV-файл ещё больше упрощает структуру тестов, отделяет логику от данных и позволяет редактировать примеры в табличном виде — даже не открывая редактор кода. Плагин pytest-csv-params
— простой в использовании, гибкий и хорошо подходит для командной работы, когда тест-кейсы могут формировать как программисты, так и QA-специалисты.
Если ваши функции принимают много параметров и имеют много веток — протестируйте их один раз через CSV. Буквально.
Пример кода на GitHub: https://github.com/1irs/pytest-csv-params-demo