Python — тестирование с помощью pytest(ч.2)
Первая часть
Предлагаю вторую часть начать с того, на чём мы закончили первую и это исключения. В прошлой статье мы тестировали исключения, которые должны были вызываться в тестируемом объекте:
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}",)
Пример выше — это интеграционный тест, который использует внепроцессную зависимость (база данных). Что делать, если нужно вызвать требуемое исключение в юнит-тесте, когда тестовый код изолирован от своих зависимостей? Использовать side_effect:
@dataclass
class GetUserResponse:
user: User | None = None
error: str | None = None
@property
def success(self) -> bool:
return not error
class GetUserUseCase:
def __init__(self, user_repository: BaseUserRepository) -> None:
self._user_repository = user_repository
def execute(self, user_id: id) -> GetUserResponse:
try:
user = self._user_repository.get(user_id)
except UserDoesNotExist as error:
return GetUserResponse(error=error.error_data)
return GetUserResponse(user=user)
# test_get_user_use_case.py
# MockerFixture из пакета pytest_mock. История умалчивает,
# почему я начал сразу с неё, но проблем не возникало, плюс я привык :)
class TestGetUserUseCase:
def test_execute(self, mocker: MockerFixture) -> None:
UNKNOWN_USER_ID = 999
user_repository_mock = mocker.Mock()
user_repository_mock.get.side_effect = UserDoesNotExist(error_data="...")
use_case = GetUserUseCase(user_repository_mock)
with pytest.raises(UserDoesNotExist) as error:
await use_case.execute(UNKNOWN_USER_ID)
assert str(UNKNOWN_USER_ID) in error.value.error_data
В side_effect можно устанавливать несколько возвращаемых значений. Представьте, что у вас есть класс, который может получить информацию о пользователях у другого сервиса, но время от времени авторизацию нужно проходить вновь. Получается логика работы такого клиента следующая:
Попытаться запросить информацию о пользователях
При ошибке авторизации отправить запрос авторизации
Попытаться запросить информацию о пользователях повторно
class HTTPUserClient(BaseUserClient):
def __init__(self, transport: HTTPTransport) -> None:
self._transport = transport
def get_all_users(self) -> list[User]:
try:
return self._transport.get(...)
except HTTPAuthError: # <- Авторизация не удалась
self._transport.get(...) # Логика авторизации
return self._transport.get(...)
class TestHTTPUserClient:
def test_get_all_users(self, mocker: MockerFixture) -> None:
transport_mock = mocker.Mock(
get=mocker.Mock(
side_effect=[
HTTPAuthError(...),
None # <- на запрос авторизации.
[User(...), User(...), ...],
],
)
)
http_user_client = HTTPUserClient(client_mock)
got = http_user_client.get_all_users()
assert got == [User(...), User(...), ...]
А что делать, если зависимость, которую мы хотим изолировать от логики тестируемого объекта, нельзя внедрить снаружи с помощью DI? По возможности стоит избегать таких зависимостей, но чем ближе код к инфраструктуре, тем сложнее это делать.
Для решения этой проблемы можно использовать monkey patching. Суть подхода в том, что мы динамически меняем поведение объекта во время выполнения программы.
class FileReader:
@classmethod
def read(cls, file_name: str) -> str:
return open(file_name).read()
class TestFileReader:
def test_read(self, mocker: MockerFixture) -> None:
mocker.patch(
target="test_any.open",
side_effect=[
mocker.Mock(read=mocker.Mock(return_value="Hello, World!\n")),
],
)
got = FileReader.read("test.txt")
assert got == "Hello, World!\n"
def test_read_behavior(self, mocker: MockerFixture) -> None:
open_mock = mocker.patch(
target="test_any.open",
side_effect=[
mocker.Mock(read=mocker.Mock(return_value="Hello, World!\n")),
],
)
FileReader.read("test.txt")
open_mock.assert_called_once_with("test.txt")
Далее давайте разберём — фикстуры-генераторы. В целом его можно рассматривать, как фикстуру на стероидах, у которой есть полноценный setUp и tearDown. Механизм их работы похож на механизм контекстных менеджеров за исключением того, что информация об исключении вызванном в тесте, не попадает в фикстуру.
Сравним:
# 1. Фикстура, которая получает файл и закрывает его после теста
@pytest.fixture()
def get_file(file_name: str) -> TextIO: # <- Входящие аргументы __init__
file = open(file_name)
yield file # <- Строки 4 и 5 это __enter__
file.close() # <- __exit__ без получения информации об ошибке.
# Попадём сюда в любом случае(только если в самой
# фикстуре не возникнет исключение)
def test_any(get_file: TextIO) -> None:
1 / 0 # <- Код, который вызовет исключение
# 2. Контекстный менеджер с помощью contextlib
@contextlib.contextmanager
def get_file(file_name: str) -> TextIO: # <- Входящие аргументы __init__
file = open(file_name)
try:
yield file # <- Строки 17-19 это __enter__
finally:
file.close() # <- Строки 20-22 это __exit__
raise # <- Будет возбуждено исключение, полученное на 26 строке.
with get_file("text.txt") as a:
1 / 0
Как нетрудно догадаться, фикстуры-генераторы полезны как и контекстные менеджеры тогда, когда нам нужно получить доступ к требуемому «ресурсу», а после работы «освободить» его.
@pytest.fixture()
def base_user() -> UserModel:
user = UserModel.objects.create(...)
yield user
user.delete()
@pytest.fixture()
def safe_session() -> Session:
session = Session(...)
yield session
session.rollback()
@pytest.fixture()
def fill_db() -> None:
# Код инициализации БД тестовыми данными
yield
# Код очистки БД от тестовых данных
Напоминаю, что для улучшения пользовательского опыта работы с фикстурами не нужно забывать об их возможной параметризации:
# В данном примере user будет определяться на уровне каждого из классов
@pytest.fixture()
def create_user(user: UserModel) -> UserModel:
user.save()
yield user
user.delete()
class TestFirstBehavior:
@pytest.fixture()
def user(self) -> UserModel:
return UserModel(name="Olga", age=27)
def test_first(self, create_user: UserModel) -> None:
...
class TestSecondBehavior:
@pytest.fixture()
def user(self) -> UserModel:
return UserModel(name="Igor", age=31)
def test_second(self, create_user: UserModel) -> None:
...
Есть второй способ, который поможет нам с tearDown, и это addfinalizer:
@pytest.fixture()
def create_user(user: UserModel, request) -> UserModel:
user.save()
request.addfinalizer(lambda: user.delete()) # <- Любой Callable объект без обязательных аргументов
return user
Первое удобство в том, что вы можете указать несколько finalizer и хранить их логику отдельно от фикстуры. Выполнение finalizer происходит в очерёдности LIFO (last in first out).
@pytest.fixture
def some_finalizers(request):
request.addfinalizer(lambda: print(1))
request.addfinalizer(lambda: print(2))
def test_finalizers(some_finalizers: None) -> None:
print("test")
# pytest .
# test
# 2
# 1
Второе удобство в возможности указать finalizer до логики setUp это поможет защитить нас от не «подчищенных» состояний, если ошибка произошла в самой фикстуре в момент setUp
@pytest.fixture()
def create_two_user(first_user: UserModel, second_user: UserModel, request) -> list[UserModel]:
request.addfinalizer(lambda: first_user.delete()) # <- До setUp
request.addfinalizer(lambda: second_user.delete()) # <- До setUp
first_user.save() # <- До setUp
second_user.save() # <- IntegrityError. finalizer удалит first_user
return user
В предыдущей части я рассказывал, что с помощью pytest.mark.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.fixture(params=[{"name": "Oleg", "age": "27"}, {"name": "Ivan", "age": "31"}])
def user(request) -> UserModel:
user = UserModel(name=request.param["name"], age=request.param["age"])
yield user
user.delete()
def test_user_presentation(user: UserModel) -> None:
print(user.name, user.age)
# pytest .
# first test: Oleg, 27
# second test: Ivan, 31
Спасибо всем, кто дочитал статью! Если вам нужно больше информации про pytest, прошу дать мне знать (комментарии или «лайки»). В следующей части мы будем писать тесты на боевой код.