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 можно устанавливать несколько возвращаемых значений. Представьте, что у вас есть класс, который может получить информацию о пользователях у другого сервиса, но время от времени авторизацию нужно проходить вновь. Получается логика работы такого клиента следующая:

  1. Попытаться запросить информацию о пользователях

  2. При ошибке авторизации отправить запрос авторизации

  3. Попытаться запросить информацию о пользователях повторно

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


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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *