Гайд · TNWS AI

Как эмулировать ответ AI API в Python-тесте без сетевого запроса

6 мин—

Проверяем обработку ответа AI API через unittest.mock: успешный JSON, сломанное тело и отсутствие реального сетевого вызова.

Задача и применимость

Проверить клиентскую функцию, которая читает AI API, не расходуя ключ и не создавая зависимость PR-теста от сети. Вместо имитации внутреннего результата функции подмените границу HTTP: urllib.request.urlopen. Подмена действует только внутри контекстного менеджера patch, после него исходная функция восстанавливается. Этот тест проверяет десериализацию и контракт клиентского кода; он не доказывает, что удалённый сервер доступен или что действительный ключ работает. Для этого нужен отдельный доверенный интеграционный запуск. Здесь используется локальная тестовая фикстура: значения приведены как пример входа и ожидаемого поведения, а не как измерение реального сервиса. Если ваш проект вызывает внешнюю модель, проверьте документацию выбранного поставщика отдельно и не считайте локальную симуляцию проверкой доступности API.

Названия методов Python, синтаксис GitHub Actions и используемые версии actions сверены с официальными первоисточниками 19.09.2026. Команды в шаблонах следует запускать в собственном репозитории после проверки путей; никакие цены, тарифы и результаты производительности в статье не предполагаются.

Подготовка данных и критерий ошибки

Перед кодом сформулируйте один проверяемый инвариант из задачи: что конкретно должно пройти и какой специально нарушенный пример обязан завершиться ошибкой. Используйте синтетические обращения и стабильные ID. Данные, попавшие в репозиторий или журнал CI, могут увидеть участники проекта; не копируйте сюда реальные обращения, ключи и закрытые документы. Фикстура не должна зависеть от сегодняшнего ответа модели: ожидаемое значение задаётся после предметной проверки.

Оставьте тестовые файлы рядом с кодом. При изменении бизнес-правила обновляйте его описание и expected осознанно, отдельным ревью. Простой зелёный запуск не является доказательством: сначала проверьте, что отрицательный кейс действительно делает job красным. Ошибка должна сообщать ID или номер строки, но не раскрывать чувствительный исходный текст.

Входная фикстура, которую можно скопировать

Сохраните строки в обозначенный ниже файл; комментарии с путями — пояснение и не входят в JSONL или CSV. Если пример показывает две версии ответа, прогоните их отдельно, как описано в шагах.

{"priority":"urgent","summary":"Платёж не прошёл"}
{"priority":

Пошаговая реализация

  1. Создайте каталог scripts и файл scripts/evaluate.py; при необходимости добавьте каталог data и пример из предыдущего блока. Убедитесь, что кодировка текстовых файлов — UTF-8.
  2. Скопируйте скрипт ниже и запустите команду python scripts/evaluate.py локально. Для проекта с отдельным модулем замените демонстрационную функцию на импорт приложения. Сначала сохраните исходное проверяемое поведение.
  3. Сохраните код и запустите python scripts/evaluate.py; адрес .invalid заведомо служит примером и не должен открываться, если подмена поставлена правильно. Переносите тест в проект так, чтобы patch указывал имя в модуле, где функция ищет urlopen, а не обязательно исходный модуль urllib. Поменяйте возвращаемый JSON на неизвестную priority и добавьте assertRaises(ValueError). Не создавайте тестовый API-ключ. При рефакторинге клиента следите, чтобы тест имитировал именно тот интерфейс, который вызывает production-функция; иначе зелёный тест не покрывает нужную границу.
  4. Добавьте workflow из следующего блока в .github/workflows/ai-evaluation.yml. Он запускается для pull request и не передаёт секреты коду из PR. Если сетевой интеграционный тест всё же необходим, выведите его в отдельный доверенный workflow и ограничьте доступ к ключу.
  5. Откройте успешный и намеренно неуспешный запуск. Убедитесь, что статус job соответствует коду завершения Python, а сообщение указывает на кейс, который нужно исправлять. После проверки верните корректные данные, не оставляйте искусственный сбой в ветке.
# scripts/evaluate.py
import io, json, unittest
from unittest.mock import patch
from urllib.request import Request, urlopen

def fetch_ticket(url):
    request = Request(url, data=b'{}', headers={"Content-Type":"application/json"})
    with urlopen(request, timeout=5) as response:
        value = json.load(response)
    if value.get("priority") not in {"urgent", "normal"}:
        raise ValueError("invalid priority")
    return value

class ClientTests(unittest.TestCase):
    def test_success(self):
        payload = b'{"priority":"urgent","summary":"ok"}'
        with patch(__name__ + ".urlopen", return_value=io.BytesIO(payload)) as mocked:
            self.assertEqual(fetch_ticket("https://example.invalid/ai")["priority"], "urgent")
            mocked.assert_called_once()
    def test_malformed_json(self):
        with patch(__name__ + ".urlopen", return_value=io.BytesIO(b'{')):
            with self.assertRaises(json.JSONDecodeError):
                fetch_ticket("https://example.invalid/ai")

if __name__ == "__main__":
    unittest.main()

Готовый workflow для проверки в pull request

Файл ниже запускает скрипт через Python. permissions: contents: read оставляет встроенному токену только чтение кода. Если вы уже используете другой CI, сохраняйте тот же негативный тест и ненулевой код выхода, а файл workflow адаптируйте к своей инфраструктуре.

name: AI evaluation
on:
  pull_request:
permissions:
  contents: read
jobs:
  evaluate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v7
        with:
          python-version: '3.13'
      - run: python scripts/evaluate.py

Пример входа и ожидаемого результата

При mock-теле {"priority":"urgent","summary":"ok"} функция возвращает urgent. При теле { возникает JSONDecodeError, который тест ожидает. Если код случайно выполнит настоящий запрос, тест к .invalid провалится вместо того, чтобы незаметно потратить ресурс. Это наблюдаемое поведение демонстрационного кода, а не отчёт о запуске на внешней модели. В production проверяйте, что конкретный контракт подходит к бизнес-процессу, и сохраняйте вход/ожидание рядом с именем кейса.

Где этот тест может обмануть

io.BytesIO в шаблоне достаточно для json.load и контекстного менеджера, но если production-код читает HTTP status или headers, понадобится более полная имитация ответа. Патчить нужно символ в месте использования. Отдельно тестируйте таймауты и HTTP-коды, а проверку авторизации оставьте для доверенного smoke-теста. Если тест проверяет только формат, не представляйте его как оценку фактической правильности ответа. Если тест проверяет только список ID, не делайте вывода о полноте подтверждающих документов. Разделяйте сбой инфраструктуры, некорректный корпус и нарушение прикладного инварианта: у этих проблем разные действия.

Внимательно смотрите дифф при изменении ожидаемого результата. Ослабление утверждения ради зелёного статуса может скрыть ошибку. В публичных артефактах и summary показывайте только синтетический ID и минимально необходимые поля. При увеличении корпуса проверьте, что новый тестовый случай действительно добавляет неизвестную ранее ветку поведения, а не повторяет существующий текст с другой пунктуацией.

Чек-лист финальной проверки

  • У входных данных есть понятный ID и проверенное ожидаемое поведение.
  • Успешная фикстура проходит локально, испорченная падает с понятной причиной.
  • Скрипт запускается по тому же пути в CI, что и в инструкции.
  • Вход, журнал и репозиторий не содержат секретов или реальных персональных данных.
  • Сравнивается актуальная версия кода; PR не подменяет эталон без ревью.
  • Проверены границы именно этого теста: что он обнаруживает и что не проверяет.

Частые вопросы

Почему patch(name + ".urlopen")?

Функция fetch_ticket использует импортированное имя urlopen в текущем модуле; подмена должна попасть именно туда.

Можно ли так проверить доступность сервиса?

Нет. Mock исключает сеть по замыслу; доступность проверяется отдельным интеграционным тестом.

Как протестировать timeout?

Настройте side_effect мока на соответствующее исключение и проверьте обработку этого исключения клиентом.

Почему тест проверяет плохой JSON?

Клиент не должен молча принимать обрезанный или некорректный ответ как успешную классификацию.

Как принять изменение после теста

Попросите второго участника проекта воспроизвести пример из статьи на своей ветке и оценить ожидаемый результат независимо от вывода скрипта. Особенно важно проверить, не изменили ли авторы PR эталон одновременно с функцией так, чтобы ошибка стала невидимой. Сохраните ссылку на конкретный запуск рядом с ревью и повторите тест после объединения изменений, если последующие коммиты затрагивают ту же обработку данных. При обнаружении настоящего сбоя сначала добавьте отдельную синтетическую фикстуру, которая воспроизводит проблему, и лишь потом исправляйте код. Так можно проверить, что исправление действительно удерживается, а тест не оказался формальным.

Официальная документация

Источники проверены 19.09.2026: 1, 2, 3, 4. Перед переносом на другой runtime ещё раз проверьте версии и актуальные поля документации.

Читайте также

Комментарии

Пока тихо. Скажите первое слово