Pull to refresh

Python — тестирование с помощью pytest(ч.1)

Level of difficultyMedium
Reading time9 min
Views3.7K

Я знаю, что разработчики по-разному относятся к тестированию программного обеспечения. Вот некоторые примеры подхода к тестам, которые встречались мне за время работы:

  • На своей первой работе я просто не писал тесты, не зная ничего о них и не понимая, как это делается😊

  • Полтора года проработал в компании, где разработчики не писали юнит(модульные) или интеграционные тесты. Всё тестировалось тестировщиками с помощью какого-то BDD фреймворка для тестов, и ручным тестированием.

  • Сейчас я третий год работаю в компании, где мы стараемся писать код по TDD. Тесты в таком подходе появляются ещё до реализации функционала. Реже наоборот. Но тест пишем всегда.

К чему я это всё? Я работал в разных условиях, и каждый из подходов имеет свои преимущества и недостатки. Даже код без тестов имеет место, но скорее в ваших личных пет проектах, в рамках проверки гипотезы или желания как можно скорее написать какой-то кусочек программы.

С каждым годом моё отношение к тестам немного меняется и, так сказать, "устаканивается", но одно остаётся неизменным: я считаю, что без тестов нельзя! Нельзя гарантировать работоспособность коммерческого программного обеспечения. Не говоря уже о том, что даже тесты не гарантируют этого на 100%. Они лишь подтверждают то, что в ряде протестированных нами случаев приложение с большой вероятностью должно работать, как ожидается.

В этой статье я бы хотел затронуть тестирование на python c помощью pytest.

Что пишут сами разработчики pytest: Фреймворк pytest упрощает написание небольших, легко читаемых тестов и может масштабироваться для поддержки сложного функционального тестирования приложений и библиотек.

Опустим момент с установкой pytest, я думаю, каждый желающий сможет справиться с этим сам. Я бы хотел остановиться на конкретных примерах кода, показывающих возможности этого фреймворка.

  1. Самый простой тест(Hello, world!):

def test_answer() -> None:
    assert 2 + 2 == 4

# Запускаем все тесты
# $ pytest .
============ test session starts ===========================
platform linux -- Python 3.10.12, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/user/project
plugins: anyio-4.3.0
collected 1 item                                                                                                                                                                                                       

test_any.py .                                         [100%]

============ 1 passed in 0.00s =============================

Попробуем сломать тест:

def test_answer() -> None:
    assert 2 + 2 == 5

# Запускаем все тесты
# $ pytest .
============ test session starts ===========================
platform linux -- Python 3.10.12, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/user/project
plugins: anyio-4.3.0
collected 1 item                                                                                                                                                                                                       

test_any.py F                                                                                                                                                                                                    [100%]

============ FAILURES ======================================
____________ test_answer ___________________________________

    def test_answer():
>       assert 2 + 2 == 5
E       assert (2 + 2) == 5

test_any.py:2: AssertionError
============ short test summary info =======================
FAILED test_any.py::test_answer - assert (2 + 2) == 5
============ 1 failed in 0.01s =============================

давайте напишем что-то более осмысленное:

# Функция вычисляющая факториал числа
def factorial(n: int) -> int:
    if n in [0, 1]:
        return 1
    return n * factorial(n - 1)


def test_factorial() -> None:
    expected = 120
    
    got = factorial(5)
    
    assert expected == got

# $ pytest . ->  1 passed in 0.00s

Как можно заметить по коду, наша функция, вычисляющая факториал, имеет два возможных поведения(заход в условие или его пропуск), и первое из них может наступить при двух разных значениях. Итого, чтобы полностью убедиться в работоспособности этой функции нам нужно 3 теста:

def test_factorial_return_one_if_number_eq_zero() -> None:
    expected = 1
    
    got = factorial(0)
    
    assert expected == got


def test_factorial_return_one_if_number_eq_one() -> None:
    expected = 1
    
    got = factorial(1)
    
    assert expected == got


def test_factorial_with_five() -> None:
    expected = 120
    
    got = factorial(5)
    
    assert expected == got

# $ pytest . ->  3 passed in 0.01s

Думаю кто-то из вас заметил, что все функции одинаковые по своей сути и коду. Разница заключается только в значениях. Вообще, в любых более менее сложных местах я стараюсь избегать группировки тестов, если они проверяют разные "ветки" поведения, но кажется, что здесь это вполне уместно, и pytest нам с радостью с этим поможет с помощью pytest.mark.parametrize:

@pytest.mark.parametrize(
    ("number", "expected"),
    [
        (0, 1),
        (1, 1),
        (5, 120),
    ],
)
def test_factorial(number: int, expected: int) -> None:
    got = factorial(number)
    
    assert expected == got

# $ pytest . ->  3 passed in 0.01s

Если вы хотите использовать функционал группировки тестов, но иметь больший контроль над каждым из тестов, то на помощь вам придёт pytest.param:

@pytest.mark.parametrize(
    ("number", "expected"),
    [
        pytest.param(0, 1, id="return one if number equal zero"),
        (1, 1),
        (5, 120),
        pytest.param(
            100,
            93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000,
            marks=pytest.mark.skip(reason="Slow test"),
        ),
    ],
)
def test_factorial(number: int, expected: int) -> None:
    got = factorial(number)
    
    assert expected == got

# $ pytest . ->  3 passed, 1 skipped in 0.01s

Мы уже дважды встретили pytest.mark. Давайте рассмотрим немного подробнее его популярные применения:

  1. pytest.mark.parametrize - поможет, чтобы сгруппировать тесты. Будьте осторожны, если указать несколько parametrize декораторов, то вы можете получить комбинаторный взрыв😊:

@pytest.mark.parametrize("number1", [1, 2, 3])
@pytest.mark.parametrize("number2", [4, 5, 6])
@pytest.mark.parametrize("number3", [7, 8, 9])
def test_sum_from_builtins(number1: int, number2: int, number3: int) -> None:
    got = sum([number1, number2, number3])
    
    assert got == number1 + number2 + number3

# $ pytest . ->  27 passed in 0.02s

# Входные данные
# 1 - [7, 4, 1]
# 2 - [7, 4, 2]
# 3 - [7, 4, 3]
# 4 - [7, 5, 1]
# ...
# 27 - [9, 6, 3]
  1. pytest.mark.skip - даёт возможность пропускать требуемые тесты. Хороший пример, когда эта маркировка хорошо обоснована и выглядит лучше, чем временное комментирование теста - это интеграционный тест, который перестал работать, но не ломает общей функциональности приложения:

@pytest.mark.skip(
  reason="Известная ошибка в версии библиотеки 1.2.3, исправление ожидается в следующем релизе",
)
def test_some_function() -> None:
    assert some_function() == expected_result

Ещё есть skipif. Суть та же, но можно установить условие:

@pytest.mark.skipif(
    sys.platform == "win32", 
    reason="Тест не поддерживается на Windows",
)
def test_unix_specific_function() -> None:
    assert unix_specific_function() == expected_result

В примере выше тест не будет выполняться на машине того, кто использует windows в качестве операционной системы. В этом нет ничего страшного, если таких тестов немного, и в CI тесты всё равно будут выполняться на требуемой ОС. Это может создать проблемы, если тому, кто работает на windows нужно будет писать такой тест(ы).

  1. pytest.mark.xfail - помеченные им тесты должны завершаться неперехваченной ошибкой. В моей практике встречались тесты, которые были завязаны на удалённую базу данных(не являюсь ценителем таких тестов). В один прекрасный момент в удалённой тестовой БД поменялись данные, и нам нужно было ждать накатки свежих, корректных данных. Как раз тогда мы и использовали данную маркировку, чтобы не удалять требуемый тест и иметь возможность выпускать новые версии приложения:

@pytest.mark.xfail(
    reason="Нужно дождаться, пока накатят новые акционные "
           "цены после НГ(неделя максимум думаю).",
)
class TestDomainPrices:
  ...

Одна из самых важных фич pytest это, конечно же, фикстуры(pytest.fixture). И вместо того, чтобы показать вам именно pytest.mark.usefixtures я хочу заострить внимание в целом на механизме фикстур в pytest.

Пример тестирования без фикстур:

class PriceManager:
    def __init__(
        self,
        x_price_source: PriceSource,
        y_price_source: PriceSource,
    ) -> None:
        ...
    
    def get_price(self, product: Product) -> Decimal | None:
        if product.type == "x":
            return self.x_price_source.get(product)
        elif product.type == "y":
            return self.y_price_source.get(product)
        else:
            return None


class TestPriceManager:
    def test_get_price_if_product_type_eq_x(self) -> None:
        product = Product(type="x")
        price_manager = PriceManager(
            x_price_source=StubXPriceSource(return_result=Decimal("150.00")),
            y_price_source=StubYPriceSource(return_result=Decimal("220.00")),
        )
        
        got = price_manager.get_price(product)
        
        assert got == Decimal("150.00")
        
    def test_get_price_if_product_type_eq_y(self) -> None:
        product = Product(type="y")
        price_manager = PriceManager(
            x_price_source=StubXPriceSource(return_result=Decimal("150.00")),
            y_price_source=StubYPriceSource(return_result=Decimal("220.00")),
        )
        
        got = price_manager.get_price(product)
        
        assert got == Decimal("220.00")

    def test_get_price_if_product_type_unknown(self) -> None:
        product = Product(type="unknown_product_type")
        price_manager = PriceManager(
            x_price_source=StubXPriceSource(return_result=Decimal("150.00")),
            y_price_source=StubYPriceSource(return_result=Decimal("220.00")),
        )
        
        got = price_manager.get_price(product)
        
        assert got == None

Обратите внимание, что в наших юнит-тестах каждый раз нужно по новой создавать PriceManager. Решением в лоб, будет фабрика для PriceManager:

def price_manager() -> PriceManager:
    return PriceManager(
        x_price_source=StubXPriceSource(return_result=Decimal("150.00")),
        y_price_source=StubYPriceSource(return_result=Decimal("220.00")),
    )

    
class TestPriceManager:
    def test_get_price_if_product_type_eq_x(self) -> None:
        product = Product(type="x")
        # Я специально не сделал так: price_manager.get_price(product),
        # потому что, это смешало бы шаги подготовки данных для теста 
        # и непосредственного тестирования объекта.
        # подход называется: AAA(arrange act assert) - всем советую 😊
        price_manager = price_manager()
        
        got = price_manager.get_price(product)
        
        assert got == Decimal("150.00")
        
    ...

С помощью pytest можно сделать эту фабрику более гибкой:

@pytest.fixture()
def price_manager() -> PriceManager:
    return PriceManager(
        x_price_source=StubXPriceSource(return_result=Decimal("150.00")),
        y_price_source=StubYPriceSource(return_result=Decimal("220.00")),
    )

    
class TestPriceManager:
    # pytest сам внедрит(DI) во время теста требуемые(price_manager) зависимости. 
    def test_get_price_if_product_type_eq_x(self, price_manager: PriceManager) -> None:
        product = Product(type="x")
        
        got = price_manager.get_price(product)
        
        assert got == Decimal("150.00")
        
    ...

У вас может возникнуть логичный вопрос: "Чем использование сторонней библиотеки лучше, чем то, что мы сделали ранее?". Далее я как раз собираюсь ответить на него.

Давайте рассмотрим некоторые из преимуществ использования фикстуры вместо фабричных функций:

  1. Фикстурами могут пользоваться множество тестов, не импортируя их.

# conftest.py

import pytest


@pytest.fixture()
def five() -> int:
    return 5
# first_test_module.py

def test_first(five: int) -> None:
    ...
# second_test_module.py

def test_second(five: int) -> None:
    ...
  1. Для фикстуры можно указать, как часто она будет выполняться:

# Возможные значения: "session", "package", "module", "class", "function"
# По умолчанию установлено значение "function"

# scope="session" указывает на то, что эта фикстура 
# будет выполнена единожды за весь сеанс тестирования 
@pytest.fixture(scope="session")
def crate_test_db() -> None:
    ...


# scope="function" указывает на то, что эта фикстура 
# будет выполняться для каждой тестовой функции 
@pytest.fixture(scope="function")
def async_session() -> AyncSession:
    ...
  1. Одни фикстуры могут использовать другие. Вкупе с первым пунктом можно делать сложные иерархии фикстур, не беспокоясь о куче импортов и не засорять код явными вызовами a(), b(), ... :

@pytest.fixture()
def a() -> None:
    ...

@pytest.fixture()
def b(a: None) -> None:
    ...

@pytest.fixture()
def c(b: None, fixture_from_another_file: None) -> None:
    ...
  1. Можно включить автоиспользование требуемых фикстур и с требуемой частотой:

# Эта фикстура будет автоматически запускаться 
# перед каждым тестом и очищать тестовую базу данных
@pytest.fixture(scope="function", autouse=True)
def clear_test_db() -> None:
    ...

У вас мог возникнуть логичный вопрос: "Как передать в фикстуру аргументы?", ведь не всегда нам достаточно базового поведения. Есть два варианта:

  1. С помощью parametrize + inderect=True:

class MyTester:
    def __init__(self, x: int, y: int) -> None:
        self._x = x
        self._y = y

    def sum(self) -> int:
        return self._x + self._y

@pytest.fixture()
def tester(request) -> MyTester:
    return MyTester(request.param[0], request.param[1])

class TestIt:
    @pytest.mark.parametrize('tester', [[1, 2], [3, 0]], indirect=True)
    def test_tc1(self, tester) -> None:
       assert 3 == tester.sum()

# $ pytest . -> 2 passed in 0.00s
  1. С помощью всё того же parametrize(не самый явный способ):

@pytest.fixture()
def my_tester(test_data: list[int]) -> MyTester:
    return MyTester(test_data[0], test_data[1])

class TestIt:
    @pytest.mark.parametrize('test_data', [[1, 2], [3, 0], [2, 1]])
    def test_tc1(self, my_tester: MyTester):
       assert 3 == my_tester.sum()

# $ pytest . -> 3 passed in 0.00s

В таком варианте, скорее всего, ваша idea будет намекать вам, что, что-то вы делаете не так:

Вы конечно можете добавить test_data в аргументы функции, но это будет выглядеть не менее странно:

class TestIt:
    @pytest.mark.parametrize('test_data', [[1, 2], [3, 0], [2, 1]])
    # test_data не используется в самом коде теста. 
    def test_tc1(self, my_tester: MyTester, test_data: list[int]):
       assert 3 == my_tester.sum()

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

Ни для кого, не секрет, что в нашем коде могут присутствовать исключения, которые возникают по нашей(или нет) воле. Например, перехватили ошибку от библиотеки в инфраструктурном коде, и в бизнес-логику передали уже что-то не касающееся сторонней библиотеки:

class SQLAlchemyTariffRepository(BaseTariffRepository):
    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def get(self, id: int) -> Tariff:
        async with self._session as session:
            result = await session.execute(
                select(TariffModel).filter_by(id=id),
            )
            try:
                tariff_model = result.one()[0]
            except NoResultFound:
                raise TariffDoesNotExist(f"{id=}")
            return tariff_model.to_entity()

Как правильно протестировать такой код? Очень просто!

# Ещё один полезный маркировщик, чтобы асинхронные тесты работали
@pytest.mark.asyncio()
class TestSQLAlchemyTariffRepository:
    async def test_get_raise_exception_if_tariff_does_not_exist(
        self,
        async_session: AsyncSession,
        sqlalchemy_tariff_repository: SQLAlchemyTariffRepository,
    ) -> None:
        UNKNOWN_TARIFF_ID = 999

        with pytest.raises(TariffDoesNotExist) as error:
          await sqlalchemy_tariff_repository.get(UNKNOWN_TARIFF_ID)

        assert error.value.args == (f"id={UNKNOWN_TARIFF_ID}",)

Также в завершение хочется сказать "Не бойтесь тестов. Тесты - это ваша опора и документация сервиса!"

Спасибо всем, кто осилил статью! Если вам нужно больше информации про pytest, прошу дать мне знать(комментарии или "лайки").

Tags:
Hubs:
Total votes 13: ↑10 and ↓3+9
Comments21

Articles