Как тестировать инструменты агента (tool testing изолированно)?
Краткий тезис
Изолированное тестирование инструментов агента (tool testing) гарантирует, что каждый инструмент работает корректно независимо от LLM и других компонентов. Основные аспекты: unit-тесты валидации аргументов по JSON Schema, интеграционные тесты с мок-серверами, обработка ошибок (таймауты, 500, невалидный ввод), проверка механизмов rate limiting и идентичности вызовов (idempotency). Такой подход позволяет выявить дефекты на ранней стадии, снизить зависимость от внешних API и повысить надёжность агента.
1. Зачем нужно изолированное тестирование инструментов?
Инструменты в AI-агенте — это функции, которые агент вызывает для получения внешних данных или выполнения действий (поиск в БД, вызов API, работа с файлами). Без изоляции:
- Сбой внешнего API может маскировать ошибку в самом инструменте
- LLM может «галлюцинировать» параметры, но тесты инструмента должны это выявлять
- Инструменты имеют собственную логику: преобразование аргументов, обработка ответов, пагинация, кэширование
Изолированное тестирование означает, что мы проверяем инструмент без запуска всей цепочки агента, подменяя внешние зависимости моками (mocks) или стабами (stubs).
2. Unit-тестирование: валидация аргументов (JSON Schema)
2.1. Определение схемы инструмента
Каждый инструмент агента должен иметь декларативную спецификацию входных параметров — обычно JSON Schema (OpenAPI или инструменты фреймворков вроде LangChain, AutoGPT, Semantic Kernel).
Пример схемы для инструмента «Поиск авиабилетов»:
{
"name": "search_flights",
"description": "Ищет доступные рейсы между городами",
"parameters": {
"type": "object",
"properties": {
"origin": {"type": "string", "description": "IATA код отправления"},
"destination": {"type": "string", "description": "IATA код прибытия"},
"date": {"type": "string", "format": "date"},
"passengers": {"type": "integer", "minimum": 1, "maximum": 9}
},
"required": ["origin", "destination", "date"]
}
}
2.2. Что тестируем в unit-тестах
- Корректность парсинга схемы – проверяем, что инструмент принимает только разрешённые параметры.
- Пограничные значения (boundary tests): для
passengers– 1, 9, 0, -1, 10. - Типы данных: передача строки вместо числа, передача несуществующего ключа.
- Обязательные поля: вызов без
originилиdateдолжен вернуть ошибку валидации. - Форматы: дата в неверном формате (например, "01-01-2025" вместо "2025-01-01").
2.3. Реализация на pytest
import pytest
from agent.tools.flight_search import FlightSearchTool
def test_valid_arguments():
tool = FlightSearchTool()
result = tool.run(origin="LHR", destination="CDG", date="2025-06-15", passengers=2)
assert result.success
def test_missing_required_field():
tool = FlightSearchTool()
with pytest.raises(ValidationError):
tool.run(origin="LHR", date="2025-06-15") # нет destination
def test_invalid_passengers_count():
tool = FlightSearchTool()
with pytest.raises(ValidationError):
tool.run(origin="LHR", destination="CDG", date="2025-06-15", passengers=0)
def test_invalid_date_format():
tool = FlightSearchTool()
with pytest.raises(ValidationError):
tool.run(origin="LHR", destination="CDG", date="15/06/2025", passengers=1)
Термины:
- ValidationError – исключение, выбрасываемое при несоответствии схеме.
- Boundary test – тест граничных значений.
3. Интеграционное тестирование: реальный вызов API с мок-сервером
3.1. Зачем мокировать внешний API?
Реальные API могут быть медленными, платными, недоступными или возвращать разные данные. Для изолированного тестирования мы подменяем HTTP-запросы с помощью библиотек типа responses (Python) или wiremock.
3.2. Пример теста с моками
import responses
import pytest
from agent.tools.weather_tool import WeatherTool
@responses.activate
def test_weather_success():
# Настраиваем mock ответа
responses.add(
responses.GET,
"https://api.weather.com/v1/city=London",
json={"temp": 20, "humidity": 60},
status=200
)
tool = WeatherTool()
result = tool.run(city="London")
assert result.temp == 20
assert result.humidity == 60
3.3. Что проверяем в интеграционных тестах
- Формирование URL и query-параметров – правильный эндпоинт, передача токена, заголовков.
- Обработка ответов – парсинг JSON, XML, или бинарных данных.
- Пагинация – несколько страниц результата.
- Таймауты – как инструмент ведёт себя при задержке ответа.
Термин: Mock-сервер – программа или библиотека, имитирующая поведение реального API на основе заданных правил.
4. Тестирование обработки ошибок (error handling)
4.1. Типичные сценарии ошибок
- HTTP-ошибки: 4xx (клиентские), 5xx (серверные).
- Сетевые ошибки: таймаут соединения, DNS-ошибка, отказ в соединении.
- Невалидный ответ: JSON с неожиданной структурой, пустой ответ.
4.2. Пример теста
@responses.activate
def test_weather_http_500():
responses.add(
responses.GET,
"https://api.weather.com/v1/city=London",
status=500,
body="Internal Server Error"
)
tool = WeatherTool()
with pytest.raises(ToolExecutionError, match="API вернул ошибку 500"):
tool.run(city="London")
4.3. Ожидаемое поведение инструмента
- Возвращать специализированное исключение (например,
ToolExecutionError), а не общееException. - Содержать в сообщении код ошибки, URL, тело запроса (без чувствительных данных).
- Логировать ошибку для последующего анализа.
Термин: ToolExecutionError – кастомное исключение, инкапсулирующее информацию об ошибке вызова инструмента.
5. Тестирование rate limiting и очередей
5.1. Зачем тестировать rate limiting?
Инструменты могут вызываться агентом часто, и внешние API накладывают ограничения (например, 100 запросов в минуту). Агент должен корректно обрабатывать такие ситуации: ждать, повторять, переключаться на резервный источник.
5.2. Аспекты тестирования
- Превышение лимита: мок возвращает
429 Too Many Requestsс заголовкомRetry-After. - Логика повторных попыток: проверяем, что инструмент делает указанное количество retry с экспоненциальной задержкой.
- Очередь запросов: если инструмент имеет встроенную очередь, тестируем, что она не переполняется и не блокирует агента.
5.3. Пример
@responses.activate
def test_rate_limit_retry():
# Первый вызов – 429, второй – 200
responses.add(
responses.GET,
"https://api.weather.com/v1/city=London",
status=429,
headers={"Retry-After": "2"}
)
responses.add(
responses.GET,
"https://api.weather.com/v1/city=London",
json={"temp": 18},
status=200
)
tool = WeatherTool(max_retries=2, retry_delay=0.1) # ускорено для теста
result = tool.run(city="London")
assert result.temp == 18
# Проверяем, что было ровно 2 вызова
assert len(responses.calls) == 2
Термин: Retry-after – заголовок HTTP, указывающий клиенту, через сколько секунд повторить запрос.
6. Тестирование идемпотентности (idempotency)
6.1. Определение
Идемпотентный инструмент – его повторный вызов с одинаковыми аргументами не приводит к дублированию побочных эффектов (например, создание дубликата заказа, повторная отправка email).
6.2. Как тестировать
- Генерируем уникальный ключ идемпотентности (idempotency key) для каждого вызова (часто используется идемпотентный хеш от аргументов).
- Симулируем повторный вызов с тем же ключом и проверяем, что:
- Возвращается тот же результат (если инструмент кэширует ответ).
- Не происходит повторного side effect (проверяем через мок, что количество запросов к целевому API = 1).
- Для неидемпотентных инструментов (например, «отправить сообщение») документируем это и проверяем, что каждый вызов обрабатывается отдельно.
6.3. Пример
@responses.activate
def test_idempotency():
responses.add(
responses.POST,
"https://api.example.com/create_order",
json={"order_id": "123", "status": "created"},
status=201
)
tool = CreateOrderTool()
# Первый вызов
first = tool.run(items=["book"], idempotency_key="abc123")
# Второй вызов с тем же ключом
second = tool.run(items=["book"], idempotency_key="abc123")
assert first == second # результат одинаков
# Проверяем, что реальный POST был вызван только один раз
assert len(responses.calls) == 1
Термин: Idempotency key – уникальный идентификатор, прикрепляемый к запросу, чтобы сервер мог распознать дубликат.
7. Автоматизация тестов (pytest, unittest)
7.1. Структура тестового набора
tests/
├── tools/
│ ├── test_flight_search.py
│ ├── test_weather.py
│ └── test_create_order.py
├── conftest.py (фикстуры, общие моки)
└── pytest.ini
7.2. Использование фикстур
# conftest.py
import pytest
import responses
@pytest.fixture
def mocked_responses():
with responses.RequestsMock() as rsps:
yield rsps
7.3. Запуск с покрытием
pytest tests/ --cov=agent.tools --cov-report=term-missing
Цель – не менее 90% покрытия для критических инструментов.
8. Мокирование внешних зависимостей (mock library)
8.1. Инструменты
- unittest.mock (встроенная библиотека Python) – для замены объектов, функций.
- responses – для подмены HTTP-запросов (requests).
- pytest-mock – интеграция mocks с pytest.
8.2. Пример подмены сложной зависимости
Инструмент может использовать не HTTP, а вызов Redis, RabbitMQ, базы данных. Используем unittest.mock.patch:
from unittest.mock import patch
def test_redis_cache():
with patch('agent.tools.cache.get_redis_client') as mock_redis:
mock_redis.return_value.get.return_value = None # кэш пуст
tool = CachedWeatherTool()
result = tool.run(city="Paris")
assert result.source == "api"
# Проверяем, что был вызов set для сохранения в кэш
mock_redis.return_value.set.assert_called_once()
9. Инструменты тестирования: продвинутые техники
- Тестирование с real API против mock: для критических инструментов полезно иметь дополнительный набор интеграционных тестов с реальным API (но с низкой частотой запуска, помеченных
@pytest.mark.slow). - Параметризация тестов: проверка множества наборов входных данных.
@pytest.mark.parametrize("origin, destination, date, expected_code", [
("LHR", "CDG", "2025-06-15", 200),
("LHR", "CDG", "invalid-date", 422),
("", "CDG", "2025-06-15", 400),
])
@responses.activate
def test_flight_search_parametrized(origin, destination, date, expected_code):
responses.add(responses.GET, "https://api.flights.com/search",
status=expected_code)
tool = FlightSearchTool()
if expected_code // 100 != 2:
with pytest.raises(ToolExecutionError):
tool.run(origin=origin, destination=destination, date=date)
else:
result = tool.run(origin=origin, destination=destination, date=date)
assert result.success
10. Пример комплексного теста инструмента
Ниже – тест, который объединяет проверку валидации, мок API, обработку ошибки и rate limiting.
import pytest
import responses
from agent.tools.currency_tool import CurrencyTool, ToolExecutionError
@pytest.fixture
def tool():
return CurrencyTool(api_key="test-key", max_retries=3, retry_delay=0.01)
class TestCurrencyTool:
def test_conversion_success(self, tool, mocked_responses):
mocked_responses.get(
"https://api.exchangerate.com/latest?base=USD&target=EUR&amount=100",
json={"rate": 0.92, "result": 92.0},
status=200
)
result = tool.convert(base="USD", target="EUR", amount=100)
assert result == 92.0
def test_missing_currency(self, tool):
with pytest.raises(ValueError, match="неизвестная валюта"):
tool.convert(base="XYZ", target="EUR", amount=100)
def test_rate_limit_and_retry(self, tool, mocked_responses):
# Первые два вызова – 429, третий – успех
mocked_responses.get(
"https://api.exchangerate.com/latest?base=USD&target=EUR&amount=10",
status=429,
headers={"Retry-After": "1"}
)
mocked_responses.get(
"https://api.exchangerate.com/latest?base=USD&target=EUR&amount=10",
status=429,
headers={"Retry-After": "1"}
)
mocked_responses.get(
"https://api.exchangerate.com/latest?base=USD&target=EUR&amount=10",
json={"rate": 0.92, "result": 9.2},
status=200
)
result = tool.convert(base="USD", target="EUR", amount=10)
assert result == 9.2
assert len(mocked_responses.calls) == 3
def test_connection_timeout(self, tool, mocked_responses):
import requests.exceptions
mocked_responses.get(
"https://api.exchangerate.com/latest?base=USD&target=EUR&amount=100",
body=requests.exceptions.ConnectTimeout()
)
with pytest.raises(ToolExecutionError, match="таймаут"):
tool.convert(base="USD", target="EUR", amount=100)
11. CI/CD и регрессионное тестирование
- Добавьте тесты в конвейер CI (GitHub Actions, GitLab CI).
- Запускайте unit-тесты при каждом коммите.
- Интеграционные тесты с реальными API – по расписанию (например, раз в день) или с меткой
@pytest.mark.integration. - При изменении схемы инструмента (добавление нового параметра) тесты должны немедленно сигнализировать о нарушениях.
12. Лучшие практики
- Тестируйте через публичный интерфейс инструмента (
run,call), а не внутренние методы. - Используйте property-based testing (Hypothesis) для генерации крайних значений JSON Schema.
- Документируйте тестовые сценарии в репозитории – что покрыто, а что нет.
- Изолируйте тесты от внешнего состояния (БД, файловая система) – используйте
tmp_pathдля фалов. - Не смешивайте тесты разных инструментов в одном тесте.
Пет-проект для закрепления
Задача Разработать инструмент WeatherTool для агента, который обращается к OpenWeatherMap API, и написать для него полный набор изолированных тестов.
Инструменты Python 3.11, pytest, responses, unittest.mock.
Шаги:
- Реализуйте класс
WeatherTool:- Валидация входных параметров: city (строка), units (metric/imperial, опционально).
- Вызов API через
requests.get. - Обработка ошибок: 404 (город не найден), 500, таймаут.
- Добавьте механизм повторных попыток при 429 с заголовком
Retry-After. - Реализуйте идемпотентность: если запрос уже выполнялся с таким городом и ключом, верните кэшированный ответ.
- Напишите unit-тесты для валидации (10+ тестов).
- Напишите интеграционные тесты с моками (5+ сценариев).
- Проверьте, что тесты запускаются изолированно (без интернета).
- Добавьте измерение покрытия (coverage >=95%).
Ожидаемый результат Папка с кодом инструмента и тестами, отчёт о покрытии, README с описанием, как запускать.
Связь с другими вопросами
| Вопрос | Тема |
|---|---|
| 790 | Проектирование архитектуры AI-агента |
| 791 | Интеграция инструментов в агента |
| 792 | Безопасность вызовов инструментов |
| 794 | Логирование и трассировка вызовов |
| 795 | Мониторинг и алертинг агента |
Навигация
- Предыдущий: 792
- Следующий: 794
- Индекс: 00. Индекс разборов