Я знаю, что разработчики по-разному относятся к тестированию программного обеспечения. Вот некоторые примеры подхода к тестам, которые встречались мне за время работы:
-
На своей первой работе я просто не писал тесты, не зная ничего о них и не понимая, как это делается?
-
Полтора года проработал в компании, где разработчики не писали юнит(модульные) или интеграционные тесты. Всё тестировалось тестировщиками с помощью какого-то BDD фреймворка для тестов, и ручным тестированием.
-
Сейчас я третий год работаю в компании, где мы стараемся писать код по TDD. Тесты в таком подходе появляются ещё до реализации функционала. Реже наоборот. Но тест пишем всегда.
К чему я это всё? Я работал в разных условиях, и каждый из подходов имеет свои преимущества и недостатки. Даже код без тестов имеет место, но скорее в ваших личных пет проектах, в рамках проверки гипотезы или желания как можно скорее написать какой-то кусочек программы.
С каждым годом моё отношение к тестам немного меняется и, так сказать, «устаканивается», но одно остаётся неизменным: я считаю, что без тестов нельзя! Нельзя гарантировать работоспособность коммерческого программного обеспечения. Не говоря уже о том, что даже тесты не гарантируют этого на 100%. Они лишь подтверждают то, что в ряде протестированных нами случаев приложение с большой вероятностью должно работать, как ожидается.
В этой статье я бы хотел затронуть тестирование на python c помощью pytest.
Что пишут сами разработчики pytest: Фреймворк pytest упрощает написание небольших, легко читаемых тестов и может масштабироваться для поддержки сложного функционального тестирования приложений и библиотек.
Опустим момент с установкой pytest, я думаю, каждый желающий сможет справиться с этим сам. Я бы хотел остановиться на конкретных примерах кода, показывающих возможности этого фреймворка.
-
Самый простой тест(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. Давайте рассмотрим немного подробнее его популярные применения:
-
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]
-
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 нужно будет писать такой тест(ы).
-
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") ...
У вас может возникнуть логичный вопрос: «Чем использование сторонней библиотеки лучше, чем то, что мы сделали ранее?». Далее я как раз собираюсь ответить на него.
Давайте рассмотрим некоторые из преимуществ использования фикстуры вместо фабричных функций:
-
Фикстурами могут пользоваться множество тестов, не импортируя их.
# 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: ...
-
Для фикстуры можно указать, как часто она будет выполняться:
# Возможные значения: "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: ...
-
Одни фикстуры могут использовать другие. Вкупе с первым пунктом можно делать сложные иерархии фикстур, не беспокоясь о куче импортов и не засорять код явными вызовами
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: ...
-
Можно включить автоиспользование требуемых фикстур и с требуемой частотой:
# Эта фикстура будет автоматически запускаться # перед каждым тестом и очищать тестовую базу данных @pytest.fixture(scope="function", autouse=True) def clear_test_db() -> None: ...
У вас мог возникнуть логичный вопрос: «Как передать в фикстуру аргументы?», ведь не всегда нам достаточно базового поведения. Есть два варианта:
-
С помощью 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
-
С помощью всё того же 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/
Добавить комментарий