English translation is not available yet. Showing Russian content.

Как тестировать инструменты агента (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. Инструменты

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.

Шаги:

  1. Реализуйте класс WeatherTool:
    • Валидация входных параметров: city (строка), units (metric/imperial, опционально).
    • Вызов API через requests.get.
    • Обработка ошибок: 404 (город не найден), 500, таймаут.
    • Добавьте механизм повторных попыток при 429 с заголовком Retry-After.
    • Реализуйте идемпотентность: если запрос уже выполнялся с таким городом и ключом, верните кэшированный ответ.
  2. Напишите unit-тесты для валидации (10+ тестов).
  3. Напишите интеграционные тесты с моками (5+ сценариев).
  4. Проверьте, что тесты запускаются изолированно (без интернета).
  5. Добавьте измерение покрытия (coverage >=95%).

Ожидаемый результат Папка с кодом инструмента и тестами, отчёт о покрытии, README с описанием, как запускать.


Связь с другими вопросами

ВопросТема
790Проектирование архитектуры AI-агента
791Интеграция инструментов в агента
792Безопасность вызовов инструментов
794Логирование и трассировка вызовов
795Мониторинг и алертинг агента

Навигация