Как отделить тестовые данные от кода: тестирование с CSV в pytest

By Volodymyr Obrizan on Май 22, 2025 · Прочитать этот пост на других языках: English Ukrainian

У вас есть функция с простой, но разветвлённой логикой: в зависимости от пары условий — возвращается разный результат. В ручном тестировании такая логика описывается словами: «если превышение скорости до 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

    # Базовые уровни штрафа
    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

Комментарии

Войти чтобы оставить комментарий.

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