Я знаю, что разработчики по-разному относятся к тестированию программного обеспечения. Вот некоторые примеры подхода к тестам, которые встречались мне за время работы:
На своей первой работе я просто не писал тесты, не зная ничего о них и не понимая, как это делается😊
Полтора года проработал в компании, где разработчики не писали юнит(модульные) или интеграционные тесты. Всё тестировалось тестировщиками с помощью какого-то 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.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")
...
У вас может возникнуть логичный вопрос: "Чем использование сторонней библиотеки лучше, чем то, что мы сделали ранее?". Далее я как раз собираюсь ответить на него.
Давайте рассмотрим некоторые из преимуществ использования фикстуры вместо фабричных функций:
Фикстурами могут пользоваться множество тестов, не импортируя их.
# 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, прошу дать мне знать(комментарии или "лайки").