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

от автора

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

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

  • Полтора года проработал в компании, где разработчики не писали юнит(модульные) или интеграционные тесты. Всё тестировалось тестировщиками с помощью какого-то 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.maek.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, прошу дать мне знать(комментарии или «лайки»).


ссылка на оригинал статьи https://habr.com/ru/articles/835196/