Почему юнитов недостаточно

Классическая иллюзия: «все юнит-тесты зелёные → система работает». Для агента это особенно обманчиво, потому что его суть — в потоке данных через много компонентов, а юнит-тест каждый из них изолирует и кормит идеальными входами. Баги живут ровно там, куда юнит не смотрит: на границах между компонентами.

Типичные интеграционные баги агента, которые юниты пропускают:

  • Несовпадение контракта. Компонент A отдаёт {"result": ...}, компонент B ждёт {"data": ...}. Каждый по отдельности «правильный», вместе — сломаны.
  • Потеря состояния. В LangGraph-графе узел не так обновил State, и следующий узел получил пустоту (модуль 03: reducers).
  • Неверная маршрутизация. Роутер и обработчики по отдельности ок, но conditional edge ведёт не туда на реальном выходе LLM.
  • Реальный формат tool-вызова. Модель зовёт инструмент чуть иначе, чем ты замокал в юните, — и цепочка рвётся.
ℹ️ Unit vs Integration — разный фокус

Unit отвечает «работает ли компонент?» (в изоляции, с моками). Integration — «работают ли компоненты вместе?» (стыки, поток данных, реальный формат обмена). Это не замена, а следующий уровень пирамиды: юнитов много и они дёшевы, интеграционных меньше и они дороже/медленнее. Один без другого дырявый: юниты пропускают стыки, только интеграционные — пропускают детали и слишком медленны.

Что тестировать интеграционно

Интеграционный тест берёт кусок пайплайна из нескольких компонентов (или весь) и проверяет, что вход на одном конце даёт корректный поток к другому. Уровни укрупнения:

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Пайплайн агента: что проверяет интеграционный тест Вход запрос юзера LLM → tool_call решает вызвать поиск Tool: search возвращает данные LLM → ответ формулирует итог Форматтер финальный вывод 🔴 красные стрелки = СТЫКИ: их проверяет integration (юнит их не видит) unit: узел 1 unit: узел 2 unit: узел 3 юниты: integration: узлы 1→2→3 ВМЕСТЕ, поток данных сквозной интеграц.: Уровни: пара компонентов → подграф → весь агент end-to-end чем шире охват, тем ценнее тест — и тем он медленнее/дороже/недетерминированнее

Что стоит покрывать интеграционно у агента:

  • Связка «LLM → инструмент → LLM». Полный tool-цикл: модель решает вызвать инструмент, тот отрабатывает, результат возвращается в модель. Самый частый источник стыковых багов.
  • Прохождение состояния через граф (LangGraph): State корректно обновляется и доходит от узла к узлу через реальные reducers.
  • Ветвление на реальных выходах. Conditional edges выбирают верную ветку на настоящем (или записанном) ответе модели, а не на идеализированном моке.
  • RAG-пайплайн целиком: retriever → сборка контекста → генерация — форматы стыкуются на всём пути.

Главный вопрос: что мокать

Сердце интеграционного тестирования агента — граница мока: что оставить настоящим, а что подменить. Слишком много моков — тест ничего не проверяет (по сути юнит); слишком мало — тест медленный, дорогой и флапающий. Разумный дефолт:

ЗависимостьВ integration-тестеПочему
Свои компоненты (tools, парсеры, граф)✅ реальныеИменно их стыки мы и проверяем
LLM🟡 записанный/фейковый ответ (обычно)Недетерминизм, цена, скорость — но формат реальный
Внешние API (поиск, погода)🟡 замокать/записатьНестабильны, платны, дают флап
БД / хранилище✅ тестовый инстанс (in-memory/контейнер)Проверяем реальную работу с состоянием
Сеть в CI❌ по возможности избегатьФлап и медлительность CI
✅ Мокай LLM ответом, но НЕ убирай стык

Тонкость: даже подменяя LLM, ты должен сохранить реальный формат её ответа (настоящий tool_call с правильными полями), чтобы тест проверял настоящий стык «модель → ToolNode → инструмент». Мок, который возвращает уже-распарсенный результат, минуя формат обмена, — бесполезен: он выкидывает ровно ту границу, ради которой писался интеграционный тест. Мокай содержимое ответа, но не механику его прохождения по пайплайну.

Записанные ответы (VCR / cassettes)

Как совместить «реальный формат LLM/API» с «детерминизмом и скоростью»? Популярный приём — record-and-replay (VCR, «кассеты»): один раз вызываешь настоящую LLM/API, записываешь ответ в файл, а в тестах воспроизводишь запись вместо реального вызова.

Первый прогон (record):  тест → РЕАЛЬНАЯ LLM/API → ответ сохраняется в cassette.yaml
Дальнейшие прогоны (replay): тест → cassette.yaml (реальный формат, 0 токенов, мс)

  ✓ детерминизм (тот же записанный ответ каждый раз)
  ✓ реальный формат обмена (записан с настоящего вызова)
  ✓ быстро и бесплатно в CI (сеть не дёргается)
  ⚠️ запись «протухает» — при смене модели/API перезаписать кассету
Integration-тест tool-цикла с записанным ответом LLM
python
import pytest

# фейковая LLM: отдаёт РЕАЛЬНЫЙ формат tool_call, потом финальный ответ
class FakeLLM:
    def __init__(self, scripted): self.scripted = list(scripted); self.i = 0
    def invoke(self, messages):
        resp = self.scripted[self.i]; self.i += 1
        return resp   # тот же формат, что настоящая модель (tool_calls / content)

def test_agent_tool_cycle_end_to_end():
    fake = FakeLLM([
        # шаг 1: модель решает вызвать инструмент (РЕАЛЬНЫЙ формат tool_call)
        ToolCallMessage(tool="search", args={"query": "погода Москва"}),
        # шаг 2: получив результат инструмента, формулирует ответ
        AIMessage(content="В Москве сейчас +18°C."),
    ])
    # СВОИ компоненты — реальные: граф, ToolNode, настоящий search-инструмент
    agent = build_agent(llm=fake, tools=[real_search_tool])

    result = agent.invoke({"input": "какая погода в Москве?"})

    # проверяем СКВОЗНОЙ поток, а не отдельный узел:
    assert result["tool_calls"][0].tool == "search"     # стык LLM→tool сработал
    assert "18" in result["output"]                     # результат дошёл до ответа
    assert result["state"]["step"] == "done"            # состояние прошло через граф
⚠️ Кассеты «протухают» — держи их свежими

Записанный ответ фиксирует формат модели/API на момент записи. Сменили модель, версию SDK или контракт внешнего API — запись устарела, и тест либо ложно зелёный (проверяет несуществующий формат), либо ложно красный. Периодически перезаписывай кассеты против реальных сервисов (например, ночным прогоном) и обновляй при апгрейде зависимостей. VCR ускоряет CI, но не освобождает от сверки с реальностью.

Что делать с недетерминизмом LLM

Иногда всё же нужен интеграционный тест с реальной LLM (проверить, что настоящая модель на данном промпте вызывает нужный инструмент). Но реальная модель недетерминирована — строгий assert output == "..." будет флапать. Приёмы, чтобы такой тест был стабильным:

  • Проверяй свойства, а не точный текст. Не == "В Москве +18", а «вызван инструмент search», «в ответе есть число», «нет исключений», «дошли до финального узла».
  • temperature=0 — снижает (не убирает) разброс, делает поведение стабильнее.
  • Проверяй траекторию/структуру, а не формулировку: какие узлы посетил, какие инструменты вызвал, в каком порядке (это отдельная большая тема — урок про trajectory evaluation).
  • Изолируй «живые» тесты. Помечай их (@pytest.mark.llm), гоняй реже (не на каждый PR, а ночью/перед релизом) — они медленные и стоят токенов.
❌ Строгое сравнение текста + реальная LLM = флап

Самая частая причина «мигающих» интеграционных тестов: assert output == эталон против настоящей модели. Формулировка меняется от запуска к запуску — тест то зелёный, то красный без изменений в коде, команда теряет к нему доверие и отключает. С недетерминированной LLM проверяй структурные свойства и поведение, а точные совпадения оставь записанным ответам (VCR) или замоканной LLM.

Место в CI

Интеграционные тесты — средний слой пирамиды: их меньше юнитов, они медленнее, но и они должны быть достаточно быстрыми для CI. Практичное разделение по стоимости:

На КАЖДЫЙ PR (быстро, детерминированно):
  unit-тесты (LLM замокана)  +  integration с ЗАПИСАННЫМИ ответами (VCR)
  → нет сети, нет токенов, зелёный за секунды

Реже (ночью / перед релизом, помечены @pytest.mark.llm):
  integration с РЕАЛЬНОЙ LLM/API  +  e2e/eval прогоны
  → ловят «протухшие» кассеты и реальные регрессии формата

Правило: быстрые детерминированные тесты — на каждый коммит,
         медленные «живые» — по расписанию, отдельным джобом.
🎯 Как это складывается с прошлым уроком

Юниты (прошлый урок) + интеграционные (этот) закрывают детерминированную корректность агента: компоненты работают и правильно стыкуются. То, насколько хорош ответ реальной модели, — это уже оценка качества (прошлый раздел: judge, датасеты, regression). Полная защита = unit + integration (код и стыки) + eval (качество). Каждый слой ловит свой класс багов; вместе — надёжный агент.

Типичные ошибки

❌ Ошибка 1: замокать всё — и потерять стык

Если подменить и LLM, и инструменты, и БД, тест перестаёт проверять интеграцию — это переусложнённый юнит. Оставляй реальными свои компоненты и границы обмена, мокай только внешнее/недетерминированное.

❌ Ошибка 2: реальная сеть в CI на каждый PR

Живые вызовы LLM/API на каждый коммит делают CI медленным, дорогим и флапающим. Используй записанные ответы (VCR) для PR, реальные — по расписанию.

❌ Ошибка 3: строгое сравнение текста с реальной LLM

assert output == эталон против недетерминированной модели мигает. Проверяй свойства и поведение (вызван ли инструмент, есть ли число, дошёл ли до конца).

❌ Ошибка 4: забыть про протухание кассет

Записанные ответы устаревают при смене модели/API — тест начинает проверять несуществующий формат. Перезаписывай кассеты по расписанию и при апгрейде зависимостей.

Шпаргалка

Integration-тесты агента — всё в одном месте
text
INTEGRATION = компоненты ВМЕСТЕ, проверка СТЫКОВ и потока данных (не изоляция)
  unit: «работает ли компонент?» · integration: «работают ли вместе?»
  баги на ГРАНИЦАХ: несовпадение контракта · потеря state · роутинг · формат tool_call

ЧТО ПОКРЫВАТЬ: связка LLM→tool→LLM (главное!) · прохождение State через граф ·
  ветвление на реальных выходах · RAG-пайплайн целиком
  уровни: пара компонентов → подграф → весь агент e2e

ГРАНИЦА МОКА (что реальное / что мок):
  свои компоненты (tools/парсеры/граф) → РЕАЛЬНЫЕ (их стыки и проверяем)
  LLM → записанный/фейковый ответ, но РЕАЛЬНЫЙ ФОРМАТ (не убирай стык!)
  внешние API → мок/запись · БД → тестовый инстанс · сеть в CI → избегать
  ⚠️ замокал всё → потерял интеграцию (это юнит)

VCR / КАССЕТЫ (record-and-replay):
  record: 1 раз реальный вызов → сохранить · replay: из файла (детерм., 0 токенов, мс)
  ⚠️ кассеты ПРОТУХАЮТ при смене модели/API → перезаписывать по расписанию

НЕДЕТЕРМИНИЗМ реальной LLM:
  НЕ == текста! проверяй СВОЙСТВА/ПОВЕДЕНИЕ (вызван tool, есть число, дошёл до конца)
  temperature=0 · проверять траекторию/структуру · @pytest.mark.llm, гонять реже

CI: PR → unit + integration(VCR) (быстро/детерм.) · ночь/релиз → real LLM + e2e
ЗАЩИТА = unit (компоненты) + integration (стыки) + eval (качество)

ГРАБЛИ: замокать всё · сеть в CI на PR · == с реальной LLM · протухшие кассеты

Практическое задание

Задание: покрой пайплайн интеграционными тестами

  1. Для агента из модулей 03–05 нарисуй пайплайн и отметь стыки между компонентами. Какие из них сейчас не покрыты (юниты их не видят)?
  2. Напиши интеграционный тест на связку «LLM → инструмент → LLM»: подмени LLM фейком с реальным форматом tool_call, но оставь настоящими граф и инструмент. Что проверяешь на выходе?
  3. Для каждой зависимости своего агента реши по таблице: реальная или мок в integration-тесте? Обоснуй границу.
  4. Настрой запись ответа (VCR/кассета или ручной фейк) и убедись, что тест детерминирован и не ходит в сеть. Что будет, если сменить модель?
  5. Напиши один тест с реальной LLM, который НЕ флапает: проверяй свойства (вызван ли нужный инструмент), а не точный текст. Пометь его @pytest.mark.llm.
  6. Спроектируй CI: что гоняешь на каждый PR, что — по расписанию? Как ловишь протухание кассет?
  7. Со звёздочкой: сломай контракт между двумя компонентами (переименуй поле в выходе одного). Ловит ли это твой юнит? А интеграционный тест?

Что дальше

И юнит-, и интеграционные тесты опираются на тест-кейсы: наборы входов и ожиданий. Где их брать, как собирать репрезентативные наборы (синтетические и ручные) и поддерживать их — отдельная тема. Следующий урок — evaluation datasets: как строить датасеты для тестирования и оценки агента.