Як відокремити тестові дані від коду: тестування з CSV у pytest
By Volodymyr Obrizan on Травень 22, 2025 · Прочитати цю публікацію іншими мовами: Russian English
У вас є функція з простою, але розгалуженою логікою: залежно від пари умов — повертається різний результат. У ручному тестуванні така логіка описується словами: “якщо перевищення швидкості до 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
# Base fine tiers
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