Python – Testing with pytest(Part 2)

Part one

I suggest we start the second part where we left off the first one, and that's exceptions. In the previous article, we tested exceptions that were supposed to be thrown in the tested object:

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}",)

The example above is an integration test that uses an out-of-process dependency (database). What if you want to raise a required exception in a unit test when the test code is isolated from its dependencies? Use 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

IN side_effect you can set multiple return values. Imagine that you have a class that can get information about users from another service, but from time to time you need to re-authorize. The logic of such a client is as follows:

  1. Try to request information about users

  2. If authorization fails, send an authorization request

  3. Try to request user information again

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(...), ...]

But what if the dependency we want to isolate from the logic of the object under test cannot be injected from the outside using DI? Such dependencies should be avoided if possible, but the closer the code is to the infrastructure, the more difficult it is to do.

To solve this problem you can use monkey patchingThe essence of the approach is that we dynamically change the behavior of an object during program execution.

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")

Next, let's take a look at the generator fixtures. In general, it can be considered as a fixture on steroids, which has a full-fledged setUp And teardown. The mechanism of their work is similar to the mechanism of context managers, except that information about the exception raised in the test does not get into the fixture.

Let's compare:

# 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

As you might guess, fixture generators are useful, just like context managers, when we need to access the required “resource” and then “release” it after work.

@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
    # Код очистки БД от тестовых данных

Let me remind you that to improve the user experience of working with fixtures, you should not forget about their possible parameterization:

# В данном примере 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:
        ...

There is a second way that will help us with tearDown, and that is addfinalizer:

@pytest.fixture()
def create_user(user: UserModel, request) -> UserModel:
    user.save()
    request.addfinalizer(lambda: user.delete()) # <- Любой Callable объект без обязательных аргументов
    return user

The first convenience is that you can specify multiple finalizers and keep their logic separate from the fixture. The execution of finalizers occurs in LIFO (last in first out) order.

@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

The second convenience is the ability to specify a finalizer before the setUp logic; this will help protect us from non-“cleaned” states if an error occurred in the fixture itself at the time of 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

In the previous part I told you that with the help of pytest.mark.parameterize You can parameterize tests with input data:

@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]

It is also possible to parameterize fixtures:

@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

Thanks to everyone who read the article to the end! If you need more information about pytestplease let me know (comments or “likes”). In the next part we will write tests for the production code.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *