CLODEx.
ПРАКТИКА / CLODEx

Офлайн-тесты API-клиента Python без ключа и платных запросов

Быстрая проверка клиента не обязана обращаться к модели. Отделим построение запроса от транспорта и проверим то, что контролирует приложение: URL, метод, заголовки, тело и количество попыток. Fake transport вернёт фиксированный ответ, а сетевой guard немедленно остановит случайное соединение. Это тест клиента, не тест production API.

Редакционная проверка · / Практические руководства

Граница теста: request builder, а не модель

В этом примере транспорт передаётся аргументом. Функция построения запроса не импортирует HTTP-клиент, не читает окружение с ключами и не получает ответ от сервера. Для данных используется YOUR_API_KEY и домен example.invalid; настоящий секрет не нужен. Контракт намеренно узкий: HTTPS origin либо origin/v1, нестриминговый Chat Completions, один choice. Любой другой path, query или fragment отклоняется. Это не универсальный конструктор URL для всех провайдеров: если шлюз использует дополнительный префикс, заведите отдельный явный контракт и тесты, а не набор строковых замен.

Сборка запроса без сетевого побочного эффекта

Сохраните файл как offline_client.py. У вариантов base_url с завершающим слешем и без него будет один и тот же путь /v1/chat/completions. Заголовок Authorization формируется отдельно, а JSON сериализуется стандартным модулем с UTF-8. Переводы строки в ключе отвергаются до вызова transport. repr объекта намеренно не содержит headers и body. Это уменьшает риск случайного вывода секрета, но не заменяет правила логирования: прямое обращение к request.headers всё ещё даёт доступ к ключу. В рабочем приложении URL шлюза должен задаваться доверенной конфигурацией, а не произвольным пользовательским вводом.

python
"""Request construction with an injected transport; no network client here."""
from dataclasses import dataclass, field
import json
from urllib.parse import urlsplit


@dataclass(frozen=True)
class Request:
    method: str
    url: str
    headers: dict = field(repr=False)
    body: bytes = field(repr=False)


def build_request(base_url, api_key, model, prompt):
    if (not isinstance(base_url, str)
            or any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in base_url)):
        raise ValueError("invalid base URL")
    parts = urlsplit(base_url)
    if (parts.scheme != "https" or not parts.hostname or parts.username is not None
            or parts.password is not None or parts.query or parts.fragment
            or parts.path not in {"", "/", "/v1", "/v1/"}):
        raise ValueError("expected an HTTPS origin or origin/v1")
    if parts.port is not None and parts.port == 0:
        raise ValueError("invalid port")  # urlsplit.port also rejects malformed ports
    if (not isinstance(api_key, str) or not api_key
            or any(c.isspace() or ord(c) < 33 or ord(c) > 126 for c in api_key)):
        raise ValueError("invalid key shape")
    if not isinstance(model, str) or not model.strip() or not isinstance(prompt, str):
        raise ValueError("invalid request fields")
    body = json.dumps({"model": model, "stream": False, "n": 1,
                       "messages": [{"role": "user", "content": prompt}]},
                      ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    return Request("POST", "https://" + parts.netloc + "/v1/chat/completions",
                   {"Authorization": "Bearer " + api_key,
                    "Content-Type": "application/json"}, body)


def complete(transport, base_url, api_key, model, prompt):
    request = build_request(base_url, api_key, model, prompt)
    return transport(request, timeout=20)  # exactly one transport invocation

Исполняемый тест с блокировкой сети

Сохраните следующий код рядом с модулем и запустите Python. Mock проверяет точное число вызовов. Патчи socket запрещают создание сетевого сокета, DNS и create_connection в этом процессе на время теста. Попытка использовать настоящий Python HTTP transport вместо mock завершится ошибкой. Это проверка именно Python-примера, не универсальная изоляция сторонних subprocess или native-библиотек. Для независимого сетевого запрета CI можно дополнительно запускать тестовый контейнер с отключённой сетью. Ни тест, ни инструкция не требуют выключения firewall или изменения production.

python
import json
from unittest.mock import Mock, patch
from offline_client import complete

with patch("socket.socket", side_effect=AssertionError("network forbidden")), \
     patch("socket.create_connection", side_effect=AssertionError("network forbidden")), \
     patch("socket.getaddrinfo", side_effect=AssertionError("network forbidden")):
    transport = Mock(return_value=(200, b"{}"))
    assert complete(transport, "https://api.example.invalid/v1/",
                    "YOUR_API_KEY", "fixture-model", "Привет 🌍") == (200, b"{}")
    transport.assert_called_once()
    args, kwargs = transport.call_args
    request = args[0]
    assert request.method == "POST"
    assert request.url == "https://api.example.invalid/v1/chat/completions"
    assert request.headers["Authorization"] == "Bearer YOUR_API_KEY"
    assert json.loads(request.body)["messages"][0]["content"] == "Привет 🌍"
    assert kwargs == {"timeout": 20}
print("PASS: request contract without network")

Негативные проверки важнее одного happy path

Проверьте origin, origin/, origin/v1 и origin/v1/: все четыре формы должны дать ровно один /v1. Затем проверьте двойной /v1/v1, HTTP вместо HTTPS, user:password в URL, query, fragment и ключ с переводом строки. В каждом запрещённом случае transport не должен вызываться ни разу. Unicode следует проверять по разобранному JSON, а не только по внешнему виду строки: повторное декодирование должно вернуть исходный текст. Дополнительно проверяйте stream=false, n=1 и отсутствие случайного ключа в repr. Если меняется контракт тела, тест обязан объяснимо упасть, а не автоматически обновить ожидаемый snapshot.

Как подключать настоящий transport отдельно

Реальный адаптер принимает Request и timeout, возвращает HTTP-статус и ограниченный буфер bytes. Он отвечает за TLS, ограничение размера чтения, обработку редиректов и отсутствие скрытых повторов. Нельзя автоматически пересылать Authorization на другой origin при redirect. Его исключения и connect/read timeout должны быть явно описаны. В этом гайде такого сетевого адаптера нет. Не приписывайте аргументу timeout=20 гарантию двадцатисекундного общего deadline: тест проверяет передачу значения, а его смысл определяет выбранный transport. Для неизвестного исхода запроса используйте отдельную политику, не повторяйте вызов только из-за отсутствия ответа.

Где заканчивается офлайн-проверка

Успех теста означает, что ваш код собрал ожидаемый запрос и один раз передал его fake transport. Он не доказывает правильность реального ключа, наличие модели, прохождение TLS через вашу сеть, поддержку параметров шлюзом или корректность списания. Эти проверки должны быть отдельным ограниченным smoke-test с согласованным бюджетом. Разделение ускоряет обычные изменения: большая часть контрактов проверяется без секретов и платных вызовов, а live-проверка не запускается неявно в каждом unit-test. При публикации сохраняйте оба результата отдельно: «офлайн-контракт прошёл» и «live API проверен» — разные утверждения.

Источники и проверка