Почему юнитов недостаточно
Классическая иллюзия: «все юнит-тесты зелёные → система работает». Для агента это особенно обманчиво, потому что его суть — в потоке данных через много компонентов, а юнит-тест каждый из них изолирует и кормит идеальными входами. Баги живут ровно там, куда юнит не смотрит: на границах между компонентами.
Типичные интеграционные баги агента, которые юниты пропускают:
- Несовпадение контракта. Компонент A отдаёт
{"result": ...}, компонент B ждёт{"data": ...}. Каждый по отдельности «правильный», вместе — сломаны. - Потеря состояния. В LangGraph-графе узел не так обновил State, и следующий узел получил пустоту (модуль 03: reducers).
- Неверная маршрутизация. Роутер и обработчики по отдельности ок, но conditional edge ведёт не туда на реальном выходе LLM.
- Реальный формат tool-вызова. Модель зовёт инструмент чуть иначе, чем ты замокал в юните, — и цепочка рвётся.
Unit отвечает «работает ли компонент?» (в изоляции, с моками). Integration — «работают ли компоненты вместе?» (стыки, поток данных, реальный формат обмена). Это не замена, а следующий уровень пирамиды: юнитов много и они дёшевы, интеграционных меньше и они дороже/медленнее. Один без другого дырявый: юниты пропускают стыки, только интеграционные — пропускают детали и слишком медленны.
Что тестировать интеграционно
Интеграционный тест берёт кусок пайплайна из нескольких компонентов (или весь) и проверяет, что вход на одном конце даёт корректный поток к другому. Уровни укрупнения:
Что стоит покрывать интеграционно у агента:
- Связка «LLM → инструмент → LLM». Полный tool-цикл: модель решает вызвать инструмент, тот отрабатывает, результат возвращается в модель. Самый частый источник стыковых багов.
- Прохождение состояния через граф (LangGraph): State корректно обновляется и доходит от узла к узлу через реальные reducers.
- Ветвление на реальных выходах. Conditional edges выбирают верную ветку на настоящем (или записанном) ответе модели, а не на идеализированном моке.
- RAG-пайплайн целиком: retriever → сборка контекста → генерация — форматы стыкуются на всём пути.
Главный вопрос: что мокать
Сердце интеграционного тестирования агента — граница мока: что оставить настоящим, а что подменить. Слишком много моков — тест ничего не проверяет (по сути юнит); слишком мало — тест медленный, дорогой и флапающий. Разумный дефолт:
| Зависимость | В integration-тесте | Почему |
|---|---|---|
| Свои компоненты (tools, парсеры, граф) | ✅ реальные | Именно их стыки мы и проверяем |
| LLM | 🟡 записанный/фейковый ответ (обычно) | Недетерминизм, цена, скорость — но формат реальный |
| Внешние API (поиск, погода) | 🟡 замокать/записать | Нестабильны, платны, дают флап |
| БД / хранилище | ✅ тестовый инстанс (in-memory/контейнер) | Проверяем реальную работу с состоянием |
| Сеть в CI | ❌ по возможности избегать | Флап и медлительность CI |
Тонкость: даже подменяя 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 перезаписать кассету
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, а ночью/перед релизом) — они медленные и стоят токенов.
Самая частая причина «мигающих» интеграционных тестов: 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 (качество). Каждый слой ловит свой класс багов; вместе — надёжный агент.
Типичные ошибки
Если подменить и LLM, и инструменты, и БД, тест перестаёт проверять интеграцию — это переусложнённый юнит. Оставляй реальными свои компоненты и границы обмена, мокай только внешнее/недетерминированное.
Живые вызовы LLM/API на каждый коммит делают CI медленным, дорогим и флапающим. Используй записанные ответы (VCR) для PR, реальные — по расписанию.
assert output == эталон против недетерминированной модели мигает. Проверяй свойства и поведение (вызван ли инструмент, есть ли число, дошёл ли до конца).
Записанные ответы устаревают при смене модели/API — тест начинает проверять несуществующий формат. Перезаписывай кассеты по расписанию и при апгрейде зависимостей.
Шпаргалка
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 · протухшие кассеты
Практическое задание
Задание: покрой пайплайн интеграционными тестами
- Для агента из модулей 03–05 нарисуй пайплайн и отметь стыки между компонентами. Какие из них сейчас не покрыты (юниты их не видят)?
- Напиши интеграционный тест на связку «LLM → инструмент → LLM»: подмени LLM фейком с реальным форматом tool_call, но оставь настоящими граф и инструмент. Что проверяешь на выходе?
- Для каждой зависимости своего агента реши по таблице: реальная или мок в integration-тесте? Обоснуй границу.
- Настрой запись ответа (VCR/кассета или ручной фейк) и убедись, что тест детерминирован и не ходит в сеть. Что будет, если сменить модель?
- Напиши один тест с реальной LLM, который НЕ флапает: проверяй свойства (вызван ли нужный инструмент), а не точный текст. Пометь его
@pytest.mark.llm. - Спроектируй CI: что гоняешь на каждый PR, что — по расписанию? Как ловишь протухание кассет?
- Со звёздочкой: сломай контракт между двумя компонентами (переименуй поле в выходе одного). Ловит ли это твой юнит? А интеграционный тест?
Что дальше
И юнит-, и интеграционные тесты опираются на тест-кейсы: наборы входов и ожиданий. Где их брать, как собирать репрезентативные наборы (синтетические и ручные) и поддерживать их — отдельная тема. Следующий урок — evaluation datasets: как строить датасеты для тестирования и оценки агента.