Предлагаю вторую часть начать с того, на чём мы закончили первую и это исключения. В прошлой статье мы тестировали исключения, которые должны были вызываться в тестируемом объекте:
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, прошу дать мне знать(комментарии или «лайки»). В следующей части мы будем писать тесты на боевой код.
ссылка на оригинал статьи https://habr.com/ru/articles/836680/
Добавить комментарий