Первая часть

Предлагаю вторую часть начать с того, на чём мы закончили первую и это исключения. В прошлой статье мы тестировали исключения, которые должны были вызываться в тестируемом объекте:

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