SQ Blog— заметки разработчика.

Рекомендации по написанию unite тестов на Python (pytest)

03.01.2024Pythonpytest

1. Структура приложения

  • Для джанго проекта лучше располагать тесты в директориях django application из принципа "наименьшего удивления", т.к. большинство проектов на django используют подобный подход. Но внутри тестов повторяем структуру django application.
  • Для FastApi приложений, выносим директорию тестов на внешний уровень.

django_and_fastapi_project_structure

2. Использование Mock + DI предпочтительнее, чем monkeypatch.

Monkeypatch стоит применять в end-to-end (допустим тестирование Api endpoint).

Для всех остальных случаев стоит применять unittest.Mock, т.к. его поведение более контролируемое и прогнозируемое.

Желательно ознакомиться с достаточно интересным обсуждением на тему когда необходимо использовать monkeypatch, а когда Mock по ссылке

Пример использования Mock

@pytest.fixture
def bonus_calculator_mock() -> CalculateBonusesService:
    # Применение `Mock` в качестве фикстуры
    service = Mock(CalculateBonusesService)
    service.calculate.return_value = CalculateBonusesResult(
        mobile_phone="79999999999",
        amount=100,
        expiration=TimeProvider.now(),
    )

    return service


class TestAcceptOrders:

    async def test_incorrect_bonus_payment_failure(
            self,
            db, 
            bonus_calculator_mock,
    ):
        items = OrderItemFactory.batch(3, price=1000, quantity=1)
        order = OrderFactory.build(items=items, bonus_payment=200)

        # Если необходимо переопределить `Mock` для конкретного тест кейса.
        raises_validator = Mock(BonusesPaymentValidator)
        raises_validator.validate.side_effect = OrderConstraintError()

        with pytest.raises(OrderConstraintError):
            await AcceptOrders(
                db=db,
                calculate_service=bonus_calculator_mock,
                validate_service=raises_validator,
            ).execute(order=order)

Обсуждение на тему когда необходимо использовать monkeypatch, а когда Mock по ссылке

3. Стараемся использовать обычный Mock, вместо AsyncMock & MagicMock.

Mock - подходит почти под все ситуации, если ему передать в качестве спецификации тип, который мы хотим замокать, то Mock сам создаст все необходимые методы, даже если они асинхронные.

mock = Mock(BonusesPaymentValidator)
await mock.some_async_method()  # отработает как и ожидалось.

Mock vs AsyncMock

AsyncMock - отличается наличием асинхронным режимом, стоит применять в том случае, если мокается отдельный async callable, например:

service = SomeService()
service.some_async_method = Mock()
await service.some_async_method()  # будет ошибка при вызове.
service.some_async_method = AsyncMock()
await service.some_async_method()  # отработает как и ожидалось.

Mock vs MagicMock

MagicMock - отличается наличием дандер (магических) методов, например __iter__ || __getitem__. Следует учитывать, что __call__ есть и в обычном Mock().

mock = Mock()
iter(mock)  # будет ошибка.
magic_mock = MagicMock()
iter(magic_mock)  # вернёт итератор.
magic_mock[0]  # вернёт MagicMock

Вследствие такого поведения, чаще "стреляет в ногу", чем помогает.

Его стоит использовать только в случае, если вы полностью уверены в том, что делаете, на практике подобных кейсов почти не встречается в корпоративных приложения.

4. Используйте parametrize

Для устранения избыточности тест-кейсов, лучше всего использовать parametrize.

Например этот тест-кейс создаст 12 тестов, с декартовым произведением параметров (это похоже на itertools.product(...)).

class TestQueryBuilderFilters:

    @pytest.mark.parametrize(
        "filter_value, exp_query_name",
        (
                ("foo", "term"),
                (1, "term"),
                (5.0, "term"),
                (["foo", "bar"], "terms"),
                ([1, 13], "terms"),
                ([5.0, 7.7], "terms"),
        ),
    )
    @pytest.mark.parametrize(
        "filter_is_nested, filter_key",
        (
                (False, "test_key"),
                (True, "test.nested_key"),
        ),
    )
    def test_when_filter_exact_and_value_is_base_or_list_then_query_name_choices_correct(
        self,
        filter_value,
        exp_query_name,
        filter_is_nested,
        filter_key,
    ):
        filter_item = FilterItem(
            key=filter_key,
            filter_type=EXACT,
            is_nested=filter_is_nested,
            value=filter_value,
        )

        result = Builder.build({"filters": [filter_item]})

        assert exp_query_name in result
        assert filter_value == result[exp_query_name][filter_item.key]

5. Для каждого метода желательно описывать отдельный класс и в нём уже список тестов.

class TestCategoriesRepositoryAddMany:
    async def test_add_many_success(
        self,
        category_repo,
        category_collection,
    ):
        ...

    @pytest.mark.parametrize(
        "name, exp_slug",
        (
            ("Some Name", "some-name"),
            ("Категория для теста", "kategoriya-dlya-testa"),
        ),
    )
    async def test_when_category_created_then_slug_autogenerated(
        self,
        category_repo,
        name,
        exp_slug,
    ):
        ...


class TestCategoriesRepositoryGetFiltered:
    @pytest.mark.parametrize(
        "count",
        (0, 4, 13),
    )
    async def test_when_parent_not_set_in_filter_then_query_not_filtered_by_parent(
        self,
        category_repo,
        count,
    ):
       ...

    async def test_when_parent_is_str_null_then_only_root_categories_in_result(
        self,
        category_repo,
    ):
        ...

    async def test_when_parent_is_set_then_only_with_this_parent_categories_in_result(
        self,
        category_repo,
    ):
        ...

6. Нейминг для тест-кейсов.

Наименование теста должно максимально однозначно указывать на то, что тестируется, тут не стоит гоняться за короткими названиями и лаконичностью.

Выберите и везде придерживайтесь одного-двух паттернов для наименования тест-кейсов.

Таких паттернов очень много, вы можете поискать в сети или придумать свой, главное быть последовательным при его применении.

Вот несколько примеров:

  1. test_when_<тестируемое условие>_then_<ожидаемый результат>

    • Пример:

      def test_when_items_contain_special_characters_then_this_remove_from_group_title():
          ...
      
      def test_when_invalid_promocode_then_order_canceled():
          ...
      
  2. test_<тестируемый функционал>_<ожидаемый результат>

    • Пример:

      def test_get_user_success():
          ...
      
      def test_get_user_raise_value_error():
          ...
      

Links

Github

© 2026 Dmitrii Kulakov

|

Privacy Policy