Статьи

Моки зависимостей в LiteStar

ALittleMoronТестирование ПО0 просмотров
Модульная схема тестового приложения Litestar, где реальная зависимость заменяется голубым мок-объектом.

Коротко

Когда я писал pet-проект на Litestar, я споткнулся о довольно неприятную вещь: в API-тестах мне нужно было подменить use-case на мок, но обычный mocker.patch ничего не менял. Litestar'у было абсолютно всё равно на мой patch, потому что зависимость уже собиралась через его DI-механику.

На тот момент рабочий путь выглядел так: собрать тестовое приложение с подмененными зависимостями и передать мок прямо в Provide. После этого handler получал не настоящий use-case, а тестовый объект, состояние которого можно было менять из теста.

Сейчас я бы уже не воспринимал это как хороший универсальный способ. Это скорее история про границу фреймворкового DI: пока приложение маленькое и вход один, можно жить на Provide; как только хочется одинаково мокать зависимости в разных слоях и тестах, начинает проситься отдельная точка сборки зависимостей.

Контекст

Я хотел тестировать API-слой как контракт: ручка получает запрос, вызывает use-case и возвращает правильный response. Сам use-case в таком тесте проверять не хотелось. Его можно отдельно покрыть unit-тестами или BDD-сценариями, где уже важна бизнес-логика.

То есть в API-тесте мне нужен был примерно такой контроль:

  • положить в мок use-case'а данные, которые он должен вернуть;
  • дернуть HTTP endpoint;
  • проверить status code и JSON;
  • не ходить в реальный storage и не собирать настоящую бизнес-цепочку.

Для слоистого приложения это нормальное желание. API-тест не обязан превращаться в интеграционный тест всего сервиса.

Где я споткнулся

Проблема была в том, что зависимость создавалась не там, где я пытался ее patch'ить. Handler получал объект через DI Litestar, поэтому mocker.patch в моем случае не попадал в реальную точку сборки.

Может, я тогда что-то делал не так. Но по факту для теста важнее другое: если фреймворк сам собирает зависимости, то и подменять их часто приходится через механизм фреймворка. Иначе получается странное ощущение: patch написан, тест запускается, а в handler всё равно приезжает настоящий объект.

Так я пришел к фикстуре с зависимостями приложения.

Рабочий workaround

В conftest.py я держал мок use-case'а как session fixture и передавал его в зависимости тестового приложения:

@pytest.fixture(scope="session")
def mock_list_items_use_case() -> MockListItems:
    return MockListItems()


@pytest.fixture(scope="session")
def app_dependencies(
    mock_storage: MockStorage,
    mock_list_items_use_case: MockListItems,
) -> Mapping[str, Provide]:
    return {
        "storage": provide_async(mock_storage),
        "list_items_use_case": provide_async(
            mock_list_items_use_case,
        ),
    }


@pytest.fixture(scope="session")
def test_app(app_dependencies: Mapping[str, Provide]) -> Litestar:
    return create_app(debug=True, plugins=get_plugins(), deps=app_dependencies)

Дальше handler получал зависимость как обычно, но в тестовом приложении под этим именем лежал уже мок:

ListItemsUseCaseDeps = Annotated[
    ListItemsUseCase,
    Dependency(skip_validation=True),
]


@get("items/")
async def list_items_handler(
    list_items_use_case: ListItemsUseCaseDeps,
) -> ItemsListSchema:
    items = await list_items_use_case.execute()
    return ItemsListSchema.from_domain_schema(schema=items)

Это не самый красивый код на свете, но он решал конкретную задачу: API-тест мог управлять поведением use-case'а без реального storage.

Почему тут всплыл skip_validation

Самый важный момент в старом решении - Dependency(skip_validation=True).

Litestar смотрит на объявленный тип зависимости и может валидировать значение, которое приезжает в handler. Если вместо ListItemsUseCase передать совершенно другой объект, например MockListItems, эта проверка начинает мешать. В моем случае нужно было сказать фреймворку: да, я понимаю, что объект не того конкретного класса; для этого теста мне важен контракт, а не точное совпадение типа.

Если мок реализует тот же абстрактный интерфейс, проблема может вообще не появиться. В этом примере можно было бы мокать storage, оставить настоящий use-case и не трогать skip_validation. Но это уже другой тип теста. Мне тогда хотелось проверить только HTTP-контракт, поэтому use-case я подменял целиком.

Важная оговорка по актуальному Litestar: этот пример исторический. В текущей документации dependency-параметры явно помечаются через NamedDependency, а bypass validation показывают через SkipValidation. При этом Dependency(skip_validation=True) всё ещё описан в reference по параметрам. Если переписывать этот код сегодня как инструкцию, я бы отдельно проверил версию Litestar и обновил пример под текущий API.

Что в этом решении неприятно

Главная неприятность - shared state.

Мок лежит на уровне сессии, а тесты меняют его состояние. Значит, после каждого теста нужно руками возвращать объект в чистое состояние:

@pytest.fixture(autouse=True)
def setup(
    mock_list_items_use_case: MockListItems,
) -> Generator[None, None, None]:
    self.use_case = mock_list_items_use_case
    yield
    self.use_case.items = []

Это работает, но требует дисциплины. Забыл очистить список - следующий тест получил чужие данные. Добавил в мок еще одно поле - теперь нужно не забыть чистить и его. Чем больше таких моков, тем сильнее тестовая инфраструктура начинает жить своей отдельной жизнью.

Вторая неприятность - тесты становятся завязаны на DI конкретного фреймворка. Для Litestar один способ подмены, для FastAPI другой, для broker'а третий, для CLI вообще ручная сборка. Пока проект маленький, это терпимо. Когда входов становится несколько, начинает казаться, что тесты повторяют архитектуру фреймворков, а не архитектуру приложения.

И тут у меня недовольство не только конкретно этим workaround'ом. Сама DI-система Litestar ощущается переусложненной: Provide, named dependencies, validation, skip_validation, отдельные правила для dependency kwargs. Когда просто хочешь подменить use-case в тесте, приходится слишком много помнить про внутреннюю механику фреймворка. Насколько я помню, Илья Соболев, который контрибьютил в Litestar, тоже отдельно ругался на сложность их DI. То есть это не только мое ощущение после одного неудачного теста.

Сейчас я бы смотрел в сторону общего контейнера

Именно поэтому сейчас я бы рассматривал это решение как временный workaround, а не как паттерн, который хочется тащить дальше.

Если приложение живет только в Litestar и у него пара ручек, встроенного DI может быть достаточно. Но если есть HTTP, broker, фоновые задачи, CLI-команды и общие use-case'ы, то лучше вынести сборку зависимостей в одно место. Тогда API-слой не решает, как создать use-case, а только получает его и вызывает.

В этом месте появляются anydi, dishka и похожие решения. У dishka для Litestar сейчас есть нормальная интеграция: зависимости можно получать через FromDishka, handler помечать через @inject, использовать DishkaRouter для автоинъекции и подключать контейнер через setup_dishka.

Смысл не в том, что контейнер магически делает код чистым. Он просто переносит wiring из фреймворка в отдельный слой. Для тестов это удобно: можно собрать тестовый контейнер с моковыми use-case'ами и подключить его к Litestar, FastAPI, FastStream или другому входу одинаковым способом.

Подробнее я эту мысль уже расписал отдельно: [[Использование общего IOC-контейнера]].

Итог

Старое решение с Provide и session-моком было рабочим. Оно позволяло тестировать API-контракт и не проверять use-case там, где я не хотел его проверять.

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

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

Что проверить руками перед публикацией

  • Версию Litestar в старом pet-проекте. От этого зависит, оставлять пример как исторический или обновлять код под текущий API с NamedDependency и SkipValidation.
  • Нужно ли показывать полный тестовый класс. Сейчас я оставил только кусок с очисткой shared state, чтобы статья не превращалась обратно в простыню кода.
  • Ссылку на конкретное высказывание Ильи Соболева про DI Litestar, если статья пойдет наружу. Сейчас это оставлено как авторское воспоминание, а не как цитата.