Як відокремити тестові дані від коду: тестування з CSV у pytest

By Volodymyr Obrizan on Травень 22, 2025 · Прочитати цю публікацію іншими мовами: Russian English

У вас є функція з простою, але розгалуженою логікою: залежно від пари умов — повертається різний результат. У ручному тестуванні така логіка описується словами: “якщо перевищення швидкості до 10 км/год — штраф $50, а якщо це ще й у шкільній зоні — множимо на 2, а якщо водій має штрафні бали — додаємо по $25 за кожен”.

Щоб автоматизувати такі перевірки, доведеться написати десятки прикладів — і тут виникає питання: де їх зберігати і як зручно запускати?

У цьому дописі я покажу приклад такої функції та три способи, як можна організувати автотести:

  1. у вигляді окремих тест-функцій;
  2. у вигляді параметризованого списку в коді;
  3. у вигляді окремого 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:

Screenshot 2025-05-22 at 19.24.40.png

Колонка msg використовується для анотації тестів, як довільний коментар, який описує, що цей тест перевіряє.

Google Docs має можливість зберегти таблицю у CSV файл:

Screenshot 2025-05-22 at 19.24.57.png

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

Telegram
Viber
LinkedIn
WhatsApp

Коментарі

Увійти щоб залишити коментар.

← Назад до блогу