Параметризованные тесты в Pytest: обзор с примерами

c0b9b0354d74a9d55ab7c2319eb2a3ba.png

Привет, Хабр!

Хотите сделать процесс тестирования более эффективным и покрыть больше случаев с меньшим количеством кода? Тогда параметризованные тесты в Pytest — именно то, что вам нужно. В этой статье мы разберёмся, как с помощью параметризации можно существенно ускорить и упростить тестирование вашего приложения.

Сначала установим Pytest:

pip install pytest

Синтаксис

Прежде чем перейти к параметризации, рассмотрим основные фичи Pytest:

  • Автоматическое обнаружение тестовых файлов и функций: Pytest автоматически находит файлы, начинающиеся с test_ или заканчивающиеся на _test.py, и функции внутри них.

  • Мощные фикстуры: Фикстуры позволяют управлять подготовкой и очисткой тестовой среды.

  • Поддержка асинхронных тестов: Pytest отлично работает с асинхронным кодом через библиотеки вроде pytest-asyncio.

  • Гибкие возможности параметризации: Позволяют запускать тесты с различными наборами данных.

Декоратор @pytest.mark.parametrize — сердце параметризации в Pytest. Он позволяет запустить один и тот же тест с разными наборами входных данных. Синтаксис довольно прост.

Синтаксис декоратора:

@pytest.mark.parametrize(argnames, argvalues)
def test_function(argnames):
    # Тело теста
  • argnames: имя или список имён параметров, которые будут использоваться в тестовой функции.

  • argvalues: список значений или кортежей значений, соответствующих параметрам.

Пример с одним параметром:

import pytest

@pytest.mark.parametrize("number", [1, 2, 3, 4, 5])
def test_is_positive(number):
    assert number > 0

Тест test_is_positive будет запущен 5 раз с разными значениями number.

Pytest позволяет параметризовать тесты с несколькими аргументами, что хорошо при тестировании функций с несколькими входными параметрами.

Синтаксис для нескольких аргументов:

@pytest.mark.parametrize("arg1, arg2, arg3", [
    (val1_1, val2_1, val3_1),
    (val1_2, val2_2, val3_2),
    # ...
])
def test_function(arg1, arg2, arg3):
    # Тело теста

Пример тестирования функции сложения:

import pytest

@pytest.mark.parametrize("a, b, expected_sum", [
    (1, 2, 3),
    (5, 5, 10),
    (-1, 1, 0),
    (0, 0, 0),
])
def test_addition(a, b, expected_sum):
    assert a + b == expected_sum

Тест test_addition будет запущен 4 раза с различными комбинациями a, b и expected_sum.

Можно передавать более сложные структуры данных в качестве параметров, такие как списки или словари.

Пример с использованием словарей:

import pytest

test_data = [
    {"input": [1, 2, 3], "expected": 6},
    {"input": [0, 0, 0], "expected": 0},
    {"input": [-1, -2, -3], "expected": -6},
]

@pytest.mark.parametrize("data", test_data)
def test_sum_list(data):
    assert sum(data["input"]) == data["expected"]

Здесь передаем словарь data в тестовую функцию.

Также можно комбинировать параметризацию с фикстурами для более сложных сценариев тестирования.

Пример:

import pytest

@pytest.mark.parametrize("username, password", [
    ("user1", "pass1"),
    ("user2", "pass2"),
    ("admin", "adminpass"),
], ids=["User One", "User Two", "Administrator"])
def test_login(username, password):
    # Тест логина с заданными учетными данными
    pass

Здесь фикстура base_number предоставляет базовое число, а параметризация обеспечивает различные значения для increment и expected.

Чтобы сделать выводы тестов более читаемыми, можно использовать параметр ids в декораторе @pytest.mark.parametrize.

Пример:

import pytest

test_values = [1, 2, 3]

@pytest.mark.parametrize("number", test_values)
class TestNumberOperations:

    def test_is_positive(self, number):
        assert number > 0

    def test_is_integer(self, number):
        assert isinstance(number, int)

Теперь при запуске тестов в выводе будут отображаться понятные идентификаторы тестовых случаев.

Можно параметризовать не только отдельные функции, но и целые классы или модули с тестами.

Параметризация класса:

import pytest

@pytest.fixture(params=[("user1", "pass1"), ("user2", "pass2")])
def user_credentials(request):
    return request.param

def test_login(user_credentials):
    username, password = user_credentials
    # Логика теста с использованием username и password
    pass

В этом случае каждый тестовый метод внутри класса TestNumberOperations будет выполнен для каждого значения number.

Иногда может потребоваться параметризовать фикстуру. Pytest позволяет это сделать с помощью декоратора @pytest.fixture с параметром params.

Пример:

import pytest

@pytest.fixture
def user_profile(username):
    # Фикстура, которая создает профиль пользователя на основе имени
    return {"username": username, "active": True}

@pytest.mark.parametrize("username", ["alice", "bob"], indirect=True)
def test_user_profile(user_profile):
    assert user_profile["active"] is True

Если хочется передать параметр в фикстуру, можно использовать параметр indirect.

Пример:

import pytest

@pytest.fixture
def user_profile(username):
    # Фикстура, которая создает профиль пользователя на основе имени
    return {"username": username, "active": True}

@pytest.mark.parametrize("username", ["alice", "bob"], indirect=True)
def test_user_profile(user_profile):
    assert user_profile["active"] is True

Параметр username передаётся в фикстуру user_profile.

Примеры параметризованных тестов в Pytest

Параметризация тестов с простыми типами данных

Начнем с самого базового примера — параметризация с использованием простых типов данных, таких как числа и строки.

import pytest

@pytest.mark.parametrize("input_value, expected_output", [
    (1, True),
    (0, False),
    (-1, False),
    (100, True),
])
def test_is_positive(input_value, expected_output):
    assert (input_value > 0) == expected_output

Проверяем функцию, которая определяет, является ли число положительным.

С помощью декоратора @pytest.mark.parametrize передаем список кортежей, каждый из которых содержит input_value и expected_output.

Списки и словари в параметризации

Иногда входные данные и ожидаемые результаты лучше представлять в виде более сложных структур, например, списков или словарей.

import pytest

@pytest.mark.parametrize("user_data", [
    {"username": "artem", "password": "wonderland", "status": 200},
    {"username": "ivan", "password": "builder", "status": 200},
    {"username": "nikita", "password": "", "status": 400},
])
def test_user_login(user_data):
    response = simulate_login(user_data["username"], user_data["password"])
    assert response.status_code == user_data["status"]

def simulate_login(username, password):
    # упрощенная функция для имитации логина
    if username and password:
        return MockResponse(200)
    else:
        return MockResponse(400)

class MockResponse:
    def __init__(self, status_code):
        self.status_code = status_code

естируем функцию логина с различными наборами данных. Передаем в тестовую функцию словарь user_data, содержащий параметры для теста.

Параметризация с фикстурами

Фикстуры в Pytest — это способ подготовки и очистки тестовой среды. Их можно параметризовать.

import pytest

@pytest.fixture(params=[
    {"db_name": "test_db_1", "user": "user1"},
    {"db_name": "test_db_2", "user": "user2"},
])
def db_connection(request):
    db = connect_to_database(request.param["db_name"], request.param["user"])
    yield db
    db.disconnect()

def connect_to_database(db_name, user):
    # функция подключения к базе данных
    return MockDatabaseConnection(db_name, user)

class MockDatabaseConnection:
    def __init__(self, db_name, user):
        self.db_name = db_name
        self.user = user
    def disconnect(self):
        pass

def test_database_query(db_connection):
    result = db_connection.query("SELECT * FROM test_table;")
    assert result is not None

Тестируем выполнение запроса к базе данных с различными параметрами подключения.

Фикстура db_connection параметризована и создает соединение с БД с разными настройками.

Загрузка тестовых данных из внешних источников

Когда тестовых данных много или они часто меняются, имеет смысл хранить их во внешних файлах.

Пример с файла CSV:

import pytest
import csv

def get_test_data_from_csv():
    with open('test_data.csv', newline='') as csvfile:
        reader = csv.DictReader(csvfile)
        return [row for row in reader]

@pytest.mark.parametrize("username, password, expected_status", get_test_data_from_csv())
def test_external_data(username, password, expected_status):
    response = simulate_login(username, password)
    assert response.status_code == int(expected_status)

# Содержание файла test_data.csv:
# username,password,expected_status
# artem,wonderland,200
# ivan,builder,200
# nikita,hack,403

# повторно используемая функция simulate_login и класс MockResponse из предыдущих примеров
def simulate_login(username, password):
    #  функция для имитации логина
    if username == "nikita":
        return MockResponse(403)
    elif username and password:
        return MockResponse(200)
    else:
        return MockResponse(400)

class MockResponse:
    def __init__(self, status_code):
        self.status_code = status_code

Загружаем тестовые данные из файла test_data.csv и используем их для параметризации теста.

Функция get_test_data_from_csv считывает данные из CSV-файла и возвращает список кортежей для параметризации.

Подробнее с возможностями библиотеки Pytest вы можете ознакомиться по официальной документации.

25 сентября в рамках курса «Python Developer. Professional» пройдет открытый урок на тему «Django Class Based Views». После участия в уроке вы сможете легко и быстро создавать свои представления на основе классов в Django за несколько строчек кода. Если интересно, записывайтесь по ссылке.

© Habrahabr.ru