← Все статьи

AI в разработке и QA

Я попросил 6 AI-моделей написать API-автотесты на Python. Вот что получилось

Я дал шести топовым AI-моделям один и тот же промпт: сгенерировать API-автотесты на Python, pytest и httpx. На первый взгляд результат похож на нормальную автоматизацию, но при разборе видно: слабая проверка контракта, мягкие статус-коды, плохая диагностика, мало расширяемости и много предположений вместо требований.

тестированиеqaqa automationавтотестыавтоматизация тестированияllmнейросетиииискусственный интеллектагенты

Я попросил 6 AI-моделей написать API-автотесты на Python. Вот что получилось

Вступление

Я взял шесть разных моделей и дал им одно и то же задание. Контекст каждый раз был чистым: без истории диалога, без дополнительных пояснений, без правок после первого ответа.

Один промпт. Одна задача. Одинаковые стартовые условия.

Промпт был таким:

Напиши API-автотесты на Python для сервиса операций.

Есть эндпоинт: POST /api/v1/operations. Он создаёт банковскую операцию.

Request body:
{
  "account_id": "string",
  "amount": 100,
  "operation_type": "purchase"
}

Response 201:
{
  "operation_id": "string",
  "account_id": "string",
  "amount": 100,
  "operation_type": "purchase",
  "status": "created"
}

Нужно проверить успешное создание операции.

Используй pytest и httpx. Сделай код так, чтобы его можно было расширять дальше.

Максимально типовая задача: один API-эндпоинт, понятный контракт, позитивный сценарий.

Почти все модели сгенерировали вокруг тестов дополнительную инфраструктуру: клиентов, фабрики, модели, фикстуры, конфигурацию. Но в этой статье я буду смотреть не на весь проект, а на конечный результат — сами автотесты. Потому что именно тесты показывают, что модель реально проверяет: контракт, свои предположения или просто «что-то похожее на автотесты».

Разбор будет жёстким: я буду смотреть на эти тесты как сеньор AQA, которому потом это поддерживать, расширять, запускать в CI и разбирать падения. Поэтому буду придираться ко всему, что в реальном проекте быстро превращается в боль.

Начнём.

Модель: codex-5.3

По затратам: примерно 234,4 тыс. токенов и $0,16.

На выходе модель сгенерировала небольшой проект: базовый клиент, клиент операций, фабрику payload, фикстуры и сами тесты.

Финальный тестовый файл получился таким:

from __future__ import annotations

from typing import Any

import pytest

from clients.operations import OperationsClient
from tests.factories.operations import build_create_operation_payload


def assert_error_response(body: dict[str, Any] | list[Any] | None) -> None:
    assert body is not None, "Expected error body, got empty response"
    if isinstance(body, list):
        assert body, "Expected non-empty error list"
        return

    assert isinstance(body, dict), f"Unexpected error body type: {type(body)}"
    assert any(key in body for key in ("detail", "message", "error", "errors")), (
        f"Expected standard error key in body, got: {body}"
    )


def test_create_operation_success(operations_client: OperationsClient) -> None:
    payload = build_create_operation_payload(amount=100, operation_type="purchase")

    response = operations_client.create_operation_raw(payload)

    assert response.status_code == 201
    assert response.json is not None
    assert isinstance(response.json, dict)

    body = response.json
    assert body["operation_id"]
    assert body["account_id"] == payload["account_id"]
    assert body["amount"] == payload["amount"]
    assert body["operation_type"] == payload["operation_type"]
    assert body["status"] == "created"


@pytest.mark.parametrize(
    ("case_name", "payload"),
    [
        (
            "missing_account_id",
            {"amount": 100, "operation_type": "purchase"},
        ),
        (
            "negative_amount",
            build_create_operation_payload(amount=-10),
        ),
        (
            "invalid_operation_type",
            build_create_operation_payload(operation_type="invalid_type"),
        ),
        (
            "empty_body",
            {},
        ),
    ],
)
def test_create_operation_validation_errors(
    operations_client: OperationsClient,
    case_name: str,
    payload: dict[str, Any],
) -> None:
    response = operations_client.create_operation_raw(payload)

    assert response.status_code in (400, 422), f"Case '{case_name}' failed: {response.json}"
    assert_error_response(response.json)

На первый взгляд всё нормально. Но если смотреть внимательнее, запах появляется почти сразу.

Первое — контракт нормально не валидируется

В позитивном тесте проверяются отдельные поля:

assert body["operation_id"]
assert body["account_id"] == payload["account_id"]
assert body["amount"] == payload["amount"]
assert body["operation_type"] == payload["operation_type"]
assert body["status"] == "created"

Это лучше, чем просто проверить 201. Но это всё ещё не полноценная контрактная проверка. Нет jsonschema. Нет Pydantic-модели ответа. Нет проверки полной структуры ответа. Нет нормальной проверки типов.

Например, operation_id проверяется только на true/false-значение. Если API вернёт не строку, а число, тест может это пропустить. Для контрактного теста это слабовато.

Второе — негативные проверки слишком мягкие

assert response.status_code in (400, 422)

Тест говорит: верните 400 или 422, мне всё равно.

С ошибками то же самое:

assert any(key in body for key in ("detail", "message", "error", "errors"))

Тест принимает почти любой популярный формат ошибки. Есть detail — хорошо. Есть message — тоже хорошо. Есть error или errors — и так сойдёт. То есть модель не проверяет контракт ошибки. Она проверяет, что в ответе есть что-то похожее на ошибку.

Третье — клиент выглядит как абстракция, но пользы от неё мало

В базовом клиенте модель заворачивает ответ в свой DTO:

@dataclass(slots=True)
class ApiResponse:
    status_code: int
    json: dict[str, Any] | list[Any] | None

Но дальше тесты всё равно работают с response.json как с обычным словарём.

В итоге появился промежуточный объект, который почти ничего не даёт. Он не хранит text, headers, url, elapsed, исходный httpx.Response, request context и другую информацию, которая нужна при разборе падений.

Наблюдаемости у таких тестов почти нет. Нет логов, нет отчёта, нет трейсинга, нет нормального диагностического контекста. Для маленького примера это можно пережить. Для расширяемой тестовой базы — уже проблема.

Четвёртое — клиент операций одновременно и типизированный, и сырой

def create_operation(
    self,
    *,
    account_id: str,
    amount: int | float,
    operation_type: str,
) -> ApiResponse:
    payload = {
        "account_id": account_id,
        "amount": amount,
        "operation_type": operation_type,
    }
    return self.post(self.PATH, json=payload)

def create_operation_raw(self, payload: dict[str, Any] | None) -> ApiResponse:
    return self.post(self.PATH, json=payload)

Метод create_operation руками собирает JSON. Но в тестах используется create_operation_raw.

То есть модель вроде бы создала более аккуратный метод клиента, но основной тестовый сценарий всё равно пошёл через сырой dict. Это частый признак сгенерированной архитектуры: слои есть, но ответственность между ними не очень понятна.

Пятое — тесты не размечены

Нет маркеров уровня api, operations, smoke, regression. Нет понятной группировки для запуска. Есть только параметризация. Для проекта, который «можно расширять дальше», уже сомнительно.

Итог по codex-5.3: результат выглядит прилично, пока не начинаешь читать проверки

Модель сделала код, похожий на API-автотесты. Но контракт зафиксирован частично, негативные сценарии проверяются слишком мягко, клиентская обвязка даёт мало пользы, а диагностика почти отсутствует.

Моя оценка: 4/10

Модель: composer-2.5

По затратам: примерно 104,1 тыс. токенов и $0,17.

На выходе модель сгенерировала небольшой проект: базовый HTTP-клиент, клиент операций, настройки, модели запроса и ответа, фабрику payload, фикстуры, pytest.ini и тесты.

Финальный тестовый файл получился таким:

import pytest

from models.operations import CreateOperationResponse
from tests.factories.operations import build_create_operation_payload


@pytest.mark.positive
def test_create_operation_success(operations_client, valid_operation_payload):
    response = operations_client.create_operation(valid_operation_payload)

    assert response.status_code == 201

    body = CreateOperationResponse.from_dict(response.json())
    assert body.operation_id
    assert body.account_id == valid_operation_payload.account_id
    assert body.amount == valid_operation_payload.amount
    assert body.operation_type == valid_operation_payload.operation_type
    assert body.status == "created"


@pytest.mark.negative
@pytest.mark.parametrize(
    ("payload_overrides", "expected_status"),
    [
        pytest.param({"account_id": ""}, 400, id="empty_account_id"),
        pytest.param({"amount": 0}, 400, id="zero_amount"),
        pytest.param({"amount": -100}, 400, id="negative_amount"),
        pytest.param({"operation_type": "unknown_type"}, 400, id="invalid_operation_type"),
    ],
)
def test_create_operation_invalid_field_values(
    operations_client,
    payload_overrides,
    expected_status,
):
    payload = build_create_operation_payload(**payload_overrides)

    response = operations_client.create_operation(payload)

    assert response.status_code == expected_status


@pytest.mark.negative
@pytest.mark.parametrize(
    ("missing_field", "expected_status"),
    [
        pytest.param("account_id", 422, id="missing_account_id"),
        pytest.param("amount", 422, id="missing_amount"),
        pytest.param("operation_type", 422, id="missing_operation_type"),
    ],
)
def test_create_operation_missing_required_fields(
    operations_client,
    missing_field,
    expected_status,
):
    payload = build_create_operation_payload().to_dict()
    payload.pop(missing_field)

    response = operations_client.create_operation(payload)

    assert response.status_code == expected_status


@pytest.mark.negative
def test_create_operation_invalid_amount_type(operations_client):
    payload = build_create_operation_payload().to_dict()
    payload["amount"] = "not-a-number"

    response = operations_client.create_operation(payload)

    assert response.status_code == 422

С виду даже лучше, чем предыдущий вариант. Есть маркеры positive и negative. Есть разделение негативных проверок на invalid values и missing fields. Есть модель ответа. Есть фабрика данных. Есть pytest.ini. Но дальше опять начинается магия внешней приличности.

Первое — модель ответа не валидирует контракт

В тесте написано так:

body = CreateOperationResponse.from_dict(response.json())

А сама модель выглядит так:

from dataclasses import asdict, dataclass
from typing import Any


@dataclass
class CreateOperationResponse:
    operation_id: str
    account_id: str
    amount: int | float
    operation_type: str
    status: str

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "CreateOperationResponse":
        return cls(
            operation_id=data["operation_id"],
            account_id=data["account_id"],
            amount=data["amount"],
            operation_type=data["operation_type"],
            status=data["status"],
        )

Это не валидация. Это перекладывание значений из словаря в dataclass. Type hints тут ничего не проверяют. Если operation_id придёт числом, dataclass спокойно его примет. Если amount придёт строкой, dataclass тоже не развалится. Если в ответе появятся лишние поля, модель их просто проигнорирует. То есть визуально у нас как будто есть модель ответа. По факту контракт она почти не защищает.

Второе — response.json() используется напрямую

body = CreateOperationResponse.from_dict(response.json())

Если API вернёт не JSON, пустое тело, HTML-ошибку от прокси или кривой ответ, тест упадёт где-то на парсинге JSON. Будешь дебажить по стеку JSONDecodeError и гадать, что там реально приехало.

Третье — негативные сценарии построены на предположениях модели

pytest.param({"account_id": ""}, 400, id="empty_account_id")
pytest.param({"amount": 0}, 400, id="zero_amount")
pytest.param({"amount": -100}, 400, id="negative_amount")
pytest.param({"operation_type": "unknown_type"}, 400, id="invalid_operation_type")

Откуда модель взяла, что пустой account_id, нулевая сумма, отрицательная сумма и неизвестный тип операции должны возвращать именно 400? Из промпта этого нет.

Дальше то же самое:

pytest.param("account_id", 422, id="missing_account_id")
pytest.param("amount", 422, id="missing_amount")
pytest.param("operation_type", 422, id="missing_operation_type")

Почему missing fields — это 422, а invalid values — это 400? Может быть так. Может быть не так. Но это уже не проверка контракта из задачи. Это модель сама придумала правила API и тут же написала под них тесты.

Четвёртое — негативные тесты почти ничего не проверяют кроме статуса

assert response.status_code == expected_status

И всё. Нет проверки тела ошибки. Нет проверки поля, по которому произошла ошибка. Нет проверки сообщения. Нет проверки структуры error response. То есть если API вернёт нужный статус, но внутри будет вообще не тот формат ошибки, тесты останутся зелёными. Для негативных сценариев это слабовато.

Пятое — фабрика payload сделана криво

from models.operations import CreateOperationRequest


def build_create_operation_payload(**overrides) -> CreateOperationRequest:
    payload = CreateOperationRequest(
        account_id="acc-123456",
        amount=100,
        operation_type="purchase",
    )
    for field, value in overrides.items():
        setattr(payload, field, value)
    return payload

Это выглядит удобно, но по факту это мутатор объекта через setattr.

  • Можно передать amount="not-a-number" — объект станет невалидным.
  • Можно передать operation_type=[] — объект станет невалидным.
  • Можно передать поле, которого нет в контракте, — dataclass без slots=True спокойно добавит новый атрибут, а asdict() потом его проигнорирует.

То есть часть ошибок может просто исчезнуть при сериализации.

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

Шестое — клиент тоже пытается усидеть на двух стульях

class OperationsClient(BaseHTTPClient):
    CREATE_OPERATION_PATH = "/api/v1/operations"

    def create_operation(
        self,
        payload: CreateOperationRequest | dict[str, Any],
    ) -> httpx.Response:
        body = payload.to_dict() if isinstance(payload, CreateOperationRequest) else payload
        return self.request("POST", self.CREATE_OPERATION_PATH, json=body)

Метод принимает и CreateOperationRequest, и обычный dict.

  • Для позитивного сценария используется объект.
  • Для missing fields используется сырой словарь.
  • Для invalid values снова объект, но уже сломанный через setattr.

Архитектурно это мутно. Если клиент принимает что угодно, это не очень-то типизированный клиент. Если нужен raw-режим для негативных тестов, его лучше явно отделять. А тут всё свалено в один метод.

Седьмое — расширяемость есть только на уровне внешнего вида

Да, есть pytest.ini, фикстуры, клиент, настройки и маркеры. Это плюс.

Но при этом:

account_id="acc-123456"
amount=100
operation_type="purchase"

данные захардкожены прямо в фабрике. Стратегии тестовых данных нет. Нормальной генерации нет. Валидации моделей нет. Диагностики нет. Негативные сценарии держатся на предположениях.

То есть проект выглядит разложенным по папкам, но внутри всё ещё обычный черновик.

Итог по composer-2.5: результат визуально приличный

Тут есть все привычные декорации нормального тестового проекта: модели, клиент, фикстуры, маркеры, настройки. Но если читать код, становится видно: контракт не валидируется, dataclass используется как муляж Pydantic, негативные проверки основаны на догадках, response.json() небезопасен, фабрика через setattr — сомнительное решение, а расширяемость в основном нарисована структурой папок.

Моя оценка: 3/10

Модель: gemini-3.1-pro

По затратам: примерно 70,4 тыс. токенов и $0,14.

На выходе модель сгенерировала небольшой проект: базовый клиент, клиент операций, фикстуры, pytest.ini, requirements.txt и тесты.

Финальный тестовый файл получился таким:

import pytest
import uuid

def test_create_operation_success(operations_client):
    """
    Позитивный сценарий: Успешное создание операции.
    Проверяем статус 201 и корректность возвращаемых данных.
    """
    # Arrange
    payload = {
        "account_id": str(uuid.uuid4()),
        "amount": 100,
        "operation_type": "purchase"
    }

    # Act
    response = operations_client.create_operation(payload)

    # Assert
    assert response.status_code == 201, f"Expected 201, got {response.status_code}. Response: {response.text}"
    
    data = response.json()
    assert "operation_id" in data
    assert isinstance(data["operation_id"], str)
    assert data["account_id"] == payload["account_id"]
    assert data["amount"] == payload["amount"]
    assert data["operation_type"] == payload["operation_type"]
    assert data["status"] == "created"


@pytest.mark.parametrize(
    "payload_update, expected_status",
    [
        # Отсутствие обязательных полей
        ({"account_id": None}, 422),
        ({"amount": None}, 422),
        ({"operation_type": None}, 422),
        
        # Невалидные типы данных
        ({"amount": "string_instead_of_number"}, 422),
        ({"operation_type": 123}, 422),
        
        # Невалидные значения
        ({"amount": -50}, 422),  # Предполагаем, что сумма не может быть отрицательной
        ({"operation_type": "unknown_type"}, 422),  # Несуществующий тип операции
    ],
    ids=[
        "missing_account_id",
        "missing_amount",
        "missing_operation_type",
        "invalid_amount_type",
        "invalid_operation_type_type",
        "negative_amount",
        "unknown_operation_type"
    ]
)
def test_create_operation_validation_errors(operations_client, payload_update, expected_status):
    """
    Негативные сценарии: Ошибки валидации (422 Unprocessable Entity / 400 Bad Request).
    """
    # Arrange
    payload = {
        "account_id": str(uuid.uuid4()),
        "amount": 100,
        "operation_type": "purchase"
    }
    
    # Применяем изменения к валидному пейлоаду (удаляем ключ, если значение None, иначе обновляем)
    for key, value in payload_update.items():
        if value is None:
            payload.pop(key, None)
        else:
            payload[key] = value

    # Act
    response = operations_client.create_operation(payload)

    # Assert
    # Проверяем, что API возвращает ожидаемую ошибку.
    # В зависимости от фреймворка (FastAPI/Django) код может быть 422 или 400
    assert response.status_code in [400, 422], f"Expected validation error, got {response.status_code}. Response: {response.text}"
    
    # Дополнительно можно проверить структуру ошибки:
    # error_data = response.json()
    # assert "detail" in error_data

Тут всё ещё проще, чем у предыдущих моделей.

Нет моделей запроса. Нет моделей ответа. Нет фабрики данных. Нет нормального слоя тестовых данных. Всё собирается прямо в тесте через обычный dict. То есть выглядит так, будто это будут единственные два теста в проекте.

Первое — контракт опять проверяется частично

В позитивном сценарии есть базовые проверки:

assert "operation_id" in data
assert isinstance(data["operation_id"], str)
assert data["account_id"] == payload["account_id"]
assert data["amount"] == payload["amount"]
assert data["operation_type"] == payload["operation_type"]
assert data["status"] == "created"

Это не ужасно. Даже проверка типа operation_id есть. Но полноценной проверки контракта всё равно нет. Нет проверки полной структуры ответа. Нет проверки лишних полей. Нет jsonschema. Нет Pydantic. Нет отдельной модели ответа. Модель проверяет несколько удобных полей и на этом заканчивает.

Второе — response.json() опять вызывается напрямую

data = response.json()

Если сервис вернёт 201, но тело будет пустым, битым или не JSON, тест упадёт на парсинге.

Да, в проверке статуса есть response.text:

assert response.status_code == 201, f"Expected 201, got {response.status_code}. Response: {response.text}"

Но если статус правильный, а тело кривое, это сообщение уже не поможет. Пойдёшь читать JSONDecodeError.

Третье — параметр expected_status просто бесполезен

В параметризации модель пишет:

"payload_update, expected_status",
[
    ({"account_id": None}, 422),
    ({"amount": None}, 422),
    ({"operation_type": None}, 422),
]

А потом в тесте делает вот так:

assert response.status_code in [400, 422]

То есть expected_status передали, но не использовали. Это прям классика сгенерированного кода: выглядит осмысленно, но по факту часть конструкции декоративная.

Четвёртое — контракт ошибки вообще не проверяется

В конце теста модель сама оставила комментарий:

# Дополнительно можно проверить структуру ошибки:
# error_data = response.json()
# assert "detail" in error_data

То есть модель буквально понимает, что структуру ошибки можно было бы проверить, но не проверяет. В результате негативные тесты проверяют только одно: API вернул какой-то 400 или 422.

Пятое — модель сама размывает проверку

В комментарии написано:

# В зависимости от фреймворка (FastAPI/Django) код может быть 422 или 400

Это плохой признак. Тест не должен подстраховываться под FastAPI, Django, Flask или что угодно ещё. Тест должен фиксировать поведение конкретного API.

А тут модель говорит: ну пусть будет 400 или 422, какая разница. Разница есть. Это и есть контракт.

Шестое — негативные сценарии снова придуманы моделью

({"amount": -50}, 422)
({"operation_type": "unknown_type"}, 422)

В коде даже есть комментарий:

# Предполагаем, что сумма не может быть отрицательной

Вот именно. Предполагаем. Из промпта этого нет. Может быть, отрицательная сумма в этом API запрещена. А может быть, это сторнирование, возврат, корректировка или ещё какая-то доменная операция. Модель не знает домен, но уже пишет тест.

Седьмое — None используется как магический сигнал для удаления поля

for key, value in payload_update.items():
    if value is None:
        payload.pop(key, None)
    else:
        payload[key] = value

То есть None тут означает не null в JSON, а «удали поле».

Из-за этого нельзя нормально проверить сценарий, где поле есть, но значение равно null. Для API это разные случаи:

{}

и

{"account_id": null}

А модель смешала это в один механизм.

Восьмое — клиент почти ничего не добавляет поверх httpx

Клиент операций выглядит так:

class OperationsClient(BaseClient):
    def create_operation(self, payload: Dict[str, Any]) -> httpx.Response:
        return self.post("/api/v1/operations", json=payload)

Это тонкая обёртка над httpx. Просто post с захардкоженным URL.

Девятое — тесты не размечены

Нет api, operations, smoke, regression, positive, negative. Есть только параметризация. Для маленького файла пережить можно. Для расширяемого проекта — мимо.

Итог по gemini-3.1-pro: это самый простой и самый сырой вариант из уже рассмотренных

Код не разваливается с виду. Есть клиент, фикстуры, параметризация, pytest.ini. Но по сути это два теста на обычных словарях, без моделей, без фабрик, без нормальной валидации контракта и без проверки ошибок.

Больше всего раздражает expected_status, который передали и тут же проигнорировали. Это хорошо показывает весь уровень результата: структура вроде есть, смысла внутри мало.

Моя оценка: 1/10

Модель: gpt-5.5

По затратам: примерно 350,2 тыс. токенов и $0,53.

На выходе модель сгенерировала небольшой проект: базовый клиент, клиент операций, конфиг через pytest, фабрику payload, pytest.ini, requirements.txt и тесты.

Финальный тестовый файл получился таким:

import pytest

from factories.operations import (
    DEFAULT_ACCOUNT_ID,
    DEFAULT_AMOUNT,
    DEFAULT_OPERATION_TYPE,
    operation_payload,
    operation_payload_without,
)


def test_create_operation_success(operations_client) -> None:
    payload = operation_payload()

    response = operations_client.create_operation(payload)

    assert response.status_code == 201
    data = response.json()
    assert data["operation_id"]
    assert isinstance(data["operation_id"], str)
    assert data["account_id"] == DEFAULT_ACCOUNT_ID
    assert data["amount"] == DEFAULT_AMOUNT
    assert data["operation_type"] == DEFAULT_OPERATION_TYPE
    assert data["status"] == "created"


@pytest.mark.parametrize(
    ("case_name", "payload"),
    [
        ("missing_account_id", operation_payload_without("account_id")),
        ("missing_amount", operation_payload_without("amount")),
        ("missing_operation_type", operation_payload_without("operation_type")),
        ("empty_account_id", operation_payload(account_id="")),
        ("negative_amount", operation_payload(amount=-100)),
        ("zero_amount", operation_payload(amount=0)),
        ("amount_as_string", operation_payload(amount="100")),
        ("unsupported_operation_type", operation_payload(operation_type="refund")),
    ],
)
def test_create_operation_validation_errors(
    operations_client,
    case_name: str,
    payload: dict,
) -> None:
    response = operations_client.create_operation(payload)

    assert 400 <= response.status_code < 500, case_name
    assert response.status_code != 404, case_name

Тут всё ещё проще. Есть клиент. Есть фабрика. Есть конфиг через --base-url и переменную окружения. Это нормальный минимум.

Но сами тесты — прям очень слабые.

Первое — контракт опять не валидируется

В позитивном сценарии есть несколько проверок:

assert data["operation_id"]
assert isinstance(data["operation_id"], str)
assert data["account_id"] == DEFAULT_ACCOUNT_ID
assert data["amount"] == DEFAULT_AMOUNT
assert data["operation_type"] == DEFAULT_OPERATION_TYPE
assert data["status"] == "created"

Проверка operation_id на строку — плюс. Но это всё равно не контрактная проверка. Нет jsonschema. Нет Pydantic. Нет проверки полной структуры ответа. Нет проверки лишних полей. Нет нормальной модели ответа.

Тест проверяет несколько полей и надеется, что остальное тоже нормально.

Второе — response.json() вызывается напрямую

data = response.json()

Если API вернёт не JSON, пустое тело или HTML-страницу с ошибкой, получим падение на парсинге. Никакого нормального диагностического контекста нет. Ни тела ответа в сообщении, ни headers, ни URL, ни request payload.

Сломалось — иди разбирайся сам.

Третье — негативные проверки почти бесполезные

Вот это главный треш:

assert 400 <= response.status_code < 500, case_name
assert response.status_code != 404, case_name

То есть для любого негативного сценария подходит почти любой 4xx.

  • 400 — окей.
  • 401 — окей.
  • 403 — окей.
  • 405 — окей.
  • 409 — окей.
  • 418 — тоже окей.

Главное, чтобы не 404. Для API-автотеста это очень слабо.

Четвёртое — тело ошибки не проверяется вообще

В негативных тестах нет ни одной проверки ответа.

response = operations_client.create_operation(payload)

assert 400 <= response.status_code < 500, case_name
assert response.status_code != 404, case_name

И всё. Нет проверки структуры ошибки. Нет проверки поля, которое сломалось. Нет проверки сообщения. Нет проверки формата error response.

API может вернуть пустое тело. Может вернуть HTML. Может вернуть ошибку авторизации. Может вернуть бизнес-ошибку вообще про другое. Тест всё равно будет зелёный, если статус попал в диапазон.

Пятое — модель опять сама придумала негативные сценарии

("zero_amount", operation_payload(amount=0))
("unsupported_operation_type", operation_payload(operation_type="refund"))

Откуда взялось, что amount=0 запрещён? Откуда взялось, что refund неподдерживаемый тип операции?

Из промпта этого нет.

Может быть, refund — вполне валидная банковская операция. Может быть, нулевая сумма запрещена. А может быть, нет. Модель не знает, но тест уже написала.

Шестое — фабрика простая, но слишком свободная

Фабрика выглядит так:

from typing import Any


DEFAULT_ACCOUNT_ID = "account-test-001"
DEFAULT_AMOUNT = 100
DEFAULT_OPERATION_TYPE = "purchase"


def operation_payload(**overrides: Any) -> dict[str, Any]:
    payload = {
        "account_id": DEFAULT_ACCOUNT_ID,
        "amount": DEFAULT_AMOUNT,
        "operation_type": DEFAULT_OPERATION_TYPE,
    }
    payload.update(overrides)
    return payload


def operation_payload_without(*fields: str) -> dict[str, Any]:
    payload = operation_payload()
    for field in fields:
        payload.pop(field, None)
    return payload

Работает. Но это просто словарь с Any.

То есть если написать:

operation_payload_without("accountd_id")

тест не упадёт на подготовке данных. Он просто отправит payload с account_id, хотя сценарий должен был быть про отсутствующее поле.

Седьмое — клиент почти ничего не добавляет

Клиент операций:

class OperationsClient(BaseClient):
    def create_operation(self, payload: dict[str, Any]) -> httpx.Response:
        return self.request(
            "POST",
            "/api/v1/operations",
            json=payload,
        )

Это тонкая обёртка над httpx. Просто спрятали POST /api/v1/operations в метод. Спасибо, конечно, но это не архитектура.

Восьмое — тесты не размечены

Нет api, operations, smoke, regression, positive, negative.

Есть только параметризация. Даже case_name используется только как текст в assert, а не как нормальные ids для pytest.

Для проекта, который должен расширяться дальше, это сразу минус.

Итог по gpt-5.5: результат выглядит аккуратно, но внутри пустовато

Модель сделала минимальный тестовый проект, но сами проверки очень слабые. Позитивный сценарий ещё хоть как-то проверяет тело ответа. Негативные сценарии по сути говорят: «любой 4xx, кроме 404, меня устраивает».

Моя оценка: 1/10

Модель: opus-4.8

По затратам: примерно 810,1 тыс. токенов и $1,07.

Тут цена уже неприятная. Но результат сильно лучше, чем у предыдущих моделей.

На выходе модель сгенерировала проект с базовым клиентом, клиентом операций, Pydantic-моделями, enum-ами, настройками через pydantic-settings, фикстурами, pytest.ini и тестами.

Финальный тестовый файл получился таким:

import pytest

from clients.operations import OperationsClient
from models.operations import (
    CreateOperationRequest,
    OperationResponse,
    OperationStatus,
)

pytestmark = pytest.mark.operations


@pytest.mark.positive
class TestCreateOperationPositive:
    def test_create_operation_returns_201(
        self,
        operations_client: OperationsClient,
        create_operation_request: CreateOperationRequest,
    ) -> None:
        response = operations_client.create_operation_api(create_operation_request)

        assert response.status_code == 201

    def test_create_operation_response_body(
        self,
        operations_client: OperationsClient,
        create_operation_request: CreateOperationRequest,
    ) -> None:
        response = operations_client.create_operation_api(create_operation_request)
        operation = OperationResponse.model_validate(response.json())

        assert operation.operation_id
        assert operation.account_id == create_operation_request.account_id
        assert operation.amount == create_operation_request.amount
        assert operation.operation_type == create_operation_request.operation_type
        assert operation.status == OperationStatus.CREATED


@pytest.mark.negative
class TestCreateOperationNegative:
    @pytest.mark.parametrize(
        "missing_field",
        ["account_id", "amount", "operation_type"],
    )
    def test_missing_required_field(
        self,
        operations_client: OperationsClient,
        create_operation_request: CreateOperationRequest,
        missing_field: str,
    ) -> None:
        payload = create_operation_request.model_dump(mode="json")
        payload.pop(missing_field)

        response = operations_client.create_operation_api(payload)

        assert response.status_code == 422

    @pytest.mark.parametrize(
        "amount",
        [0, -1, -100.5],
        ids=["zero", "negative_int", "negative_float"],
    )
    def test_non_positive_amount_is_rejected(
        self,
        operations_client: OperationsClient,
        create_operation_request: CreateOperationRequest,
        amount: float,
    ) -> None:
        payload = create_operation_request.model_dump(mode="json")
        payload["amount"] = amount

        response = operations_client.create_operation_api(payload)

        assert response.status_code == 422

    @pytest.mark.parametrize(
        "amount",
        ["not-a-number", None, []],
        ids=["string", "null", "list"],
    )
    def test_invalid_amount_type_is_rejected(
        self,
        operations_client: OperationsClient,
        create_operation_request: CreateOperationRequest,
        amount: object,
    ) -> None:
        payload = create_operation_request.model_dump(mode="json")
        payload["amount"] = amount

        response = operations_client.create_operation_api(payload)

        assert response.status_code == 422

    def test_unknown_operation_type_is_rejected(
        self,
        operations_client: OperationsClient,
        create_operation_request: CreateOperationRequest,
    ) -> None:
        payload = create_operation_request.model_dump(mode="json")
        payload["operation_type"] = "definitely-not-valid"

        response = operations_client.create_operation_api(payload)

        assert response.status_code == 422

    def test_empty_account_id_is_rejected(
        self,
        operations_client: OperationsClient,
        create_operation_request: CreateOperationRequest,
    ) -> None:
        payload = create_operation_request.model_dump(mode="json")
        payload["account_id"] = ""

        response = operations_client.create_operation_api(payload)

        assert response.status_code == 422

    def test_empty_body_is_rejected(
        self,
        operations_client: OperationsClient,
    ) -> None:
        response = operations_client.create_operation_api({})

        assert response.status_code == 422

Сразу видно: это уже не два теста на словарях.

Есть классы тестов. Есть маркеры operations, positive, negative. Есть Pydantic-модели. Есть enum-ы. Есть настройки. Есть фикстуры. Есть нормальная попытка отделить клиент от тестов.

Это уже похоже на правду. Но есть нюанс.

Первое — позитивный сценарий зачем-то разбит на два разных теста

def test_create_operation_returns_201(...):
    response = operations_client.create_operation_api(create_operation_request)

    assert response.status_code == 201

И отдельно:

def test_create_operation_response_body(...):
    response = operations_client.create_operation_api(create_operation_request)
    operation = OperationResponse.model_validate(response.json())

    assert operation.operation_id
    assert operation.account_id == create_operation_request.account_id
    assert operation.amount == create_operation_request.amount
    assert operation.operation_type == create_operation_request.operation_type
    assert operation.status == OperationStatus.CREATED

То есть статус проверяется в одном запросе, а тело ответа — в другом. Это странно.

Во втором тесте модель вообще не проверяет, что статус 201. Она сразу парсит тело и валидирует ответ. Если API вернёт ошибку в JSON-формате, тест упадёт на Pydantic-валидации, но причина будет уже менее очевидна.

Нормальный позитивный сценарий должен проверять статус и тело одного и того же ответа. А тут один сценарий разрезали на два запроса, зачем — не понятно.

Второе — опять этот response.json()

operation = OperationResponse.model_validate(response.json())

Да, дальше есть Pydantic. Это хорошо. Но response.json() всё равно вызывается напрямую.

Если сервис вернёт не JSON, пустое тело или HTML от прокси, тест упадёт на парсинге. Без нормального сообщения, без тела ответа в ошибке, без headers, без URL, без request payload.

Pydantic спасает только после того, как JSON уже распарсился. А если не распарсился — опять иди копайся сам.

Третье — Pydantic есть, но контракт всё равно не зафиксирован жёстко

Модель ответа выглядит так:

class OperationResponse(BaseModel):
    """Response body returned on successful operation creation (201)."""

    model_config = ConfigDict(use_enum_values=True)

    operation_id: str
    account_id: str
    amount: float
    operation_type: OperationType
    status: OperationStatus

Это уже намного лучше, чем dataclass из предыдущего примера. Но всё равно есть нюансы.

Нет extra="forbid". Значит, если API начнёт возвращать лишние поля, Pydantic их просто проигнорирует. Для некоторых проектов это нормально. Но если мы говорим про проверку контракта, то лишние поля тоже могут быть изменением контракта.

Плюс amount почему-то float, хотя в промпте был пример 100. Может быть, сумма действительно может быть дробной. А может быть, модель просто решила так сама.

Четвёртое — модель сама придумала enum-ы

class OperationType(str, Enum):
    PURCHASE = "purchase"
    REFUND = "refund"
    TRANSFER = "transfer"
    WITHDRAWAL = "withdrawal"

В промпте был только purchase. Откуда взялись refund, transfer, withdrawal?

Может быть, такие типы действительно существуют. А может быть, нет. Но модель уже добавила их в контракт.

С одной стороны, код выглядит типизированным, но с другой стороны, часть типов придумана из головы.

Пятое — негативные сценарии снова держатся на предположениях

[0, -1, -100.5]

Модель решила, что нулевая и отрицательная сумма должны отклоняться.

Возможно. Но в промпте этого нет.

payload["operation_type"] = "definitely-not-valid"

Окей, это нормальный негативный кейс. Но опять же тест проверяет только статус.

assert response.status_code == 422

И всё. Нет проверки тела ошибки. Нет проверки, что ошибка именно по operation_type. Нет проверки структуры error response. Нет проверки сообщения. Просто 422.

Для негативных тестов это слабовато.

Шестое — магические статус-коды

assert response.status_code == 201
assert response.status_code == 422

Работает. Но если уж модель строит более аккуратный проект с Pydantic, enum-ами и настройками, то странно видеть голые числа в тестах.

Хотя бы так:

from http import HTTPStatus

assert response.status_code == HTTPStatus.CREATED
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY

Седьмое — клиент снова принимает и модель, и сырой dict

def create_operation_api(
    self, payload: CreateOperationRequest | dict
) -> httpx.Response:
    if isinstance(payload, CreateOperationRequest):
        json_body = payload.model_dump(mode="json")
    else:
        json_body = payload

    return self.post(self.PATH, json=json_body)

Архитектурно опять мутно. Для позитивного сценария есть типизированная модель. Для негативных сценариев туда же прокидывается сырой dict. Один метод делает и typed-запрос, и raw-запрос.

Если нужен raw-режим для негативных тестов — сделай его явно. Например, create_operation_raw. Тогда сразу понятно, где мы проверяем нормальный контракт, а где специально ломаем тело запроса.

Восьмое — базовый клиент слишком декоративный

class BaseClient:
    def get(self, url: str, **kwargs) -> httpx.Response:
        return self._http_client.get(url, **kwargs)

    def post(self, url: str, **kwargs) -> httpx.Response:
        return self._http_client.post(url, **kwargs)

    def patch(self, url: str, **kwargs) -> httpx.Response:
        return self._http_client.patch(url, **kwargs)

    def delete(self, url: str, **kwargs) -> httpx.Response:
        return self._http_client.delete(url, **kwargs)

Это просто прокси к httpx. В комментарии модель пишет, что тут потом можно настроить auth, logging, retries. Но по факту она этого не сделала. То есть наблюдаемость опять почти нулевая.

Итог по opus-4.8: это лучший вариант на текущий момент

Тут уже видно попытку сделать нормальную тестовую заготовку. Pydantic реально улучшает ситуацию, enum-ы тоже помогают, настройки через окружение — плюс.

Но за такие деньги получить просто «похоже на правду» — слабовато.

Моя оценка: 6/10

Модель: sonnet-4.6

По затратам: примерно 245,7 тыс. токенов и $0,38.

На выходе модель сгенерировала совсем минимальный проект: базовый клиент, клиент операций, фабрику payload, фикстуры и тесты.

Финальный тестовый файл получился таким:

import pytest

from clients.operations import OperationsClient
from factories.operations import create_operation_payload


@pytest.fixture
def client(base_url: str) -> OperationsClient:
    with OperationsClient(base_url) as c:
        yield c


class TestCreateOperationSuccess:
    def test_returns_201(self, client: OperationsClient) -> None:
        response = client.create(create_operation_payload())
        assert response.status_code == 201

    def test_response_contains_operation_id(self, client: OperationsClient) -> None:
        response = client.create(create_operation_payload())
        body = response.json()
        assert "operation_id" in body
        assert body["operation_id"]

    def test_response_echoes_account_id(self, client: OperationsClient) -> None:
        payload = create_operation_payload(account_id="acc-xyz-999")
        body = client.create(payload).json()
        assert body["account_id"] == "acc-xyz-999"

    def test_response_echoes_amount(self, client: OperationsClient) -> None:
        payload = create_operation_payload(amount=250)
        body = client.create(payload).json()
        assert body["amount"] == 250

    def test_response_echoes_operation_type(self, client: OperationsClient) -> None:
        payload = create_operation_payload(operation_type="purchase")
        body = client.create(payload).json()
        assert body["operation_type"] == "purchase"

    def test_initial_status_is_created(self, client: OperationsClient) -> None:
        body = client.create(create_operation_payload()).json()
        assert body["status"] == "created"


class TestCreateOperationMissingFields:
    def test_missing_account_id_returns_4xx(self, client: OperationsClient) -> None:
        payload = create_operation_payload()
        del payload["account_id"]
        response = client.create(payload)
        assert response.status_code in (400, 422)

    def test_missing_amount_returns_4xx(self, client: OperationsClient) -> None:
        payload = create_operation_payload()
        del payload["amount"]
        response = client.create(payload)
        assert response.status_code in (400, 422)

    def test_missing_operation_type_returns_4xx(self, client: OperationsClient) -> None:
        payload = create_operation_payload()
        del payload["operation_type"]
        response = client.create(payload)
        assert response.status_code in (400, 422)

    def test_empty_body_returns_4xx(self, client: OperationsClient) -> None:
        response = client.create({})
        assert response.status_code in (400, 422)


class TestCreateOperationInvalidValues:
    def test_negative_amount_returns_4xx(self, client: OperationsClient) -> None:
        response = client.create(create_operation_payload(amount=-1))
        assert response.status_code in (400, 422)

    def test_zero_amount_returns_4xx(self, client: OperationsClient) -> None:
        response = client.create(create_operation_payload(amount=0))
        assert response.status_code in (400, 422)

    def test_unknown_operation_type_returns_4xx(self, client: OperationsClient) -> None:
        response = client.create(create_operation_payload(operation_type="unknown_type"))
        assert response.status_code in (400, 422)

    def test_empty_account_id_returns_4xx(self, client: OperationsClient) -> None:
        response = client.create(create_operation_payload(account_id=""))
        assert response.status_code in (400, 422)

    def test_string_amount_returns_4xx(self, client: OperationsClient) -> None:
        response = client.create(create_operation_payload(amount="not_a_number"))
        assert response.status_code in (400, 422)

Тут всё совсем прямолинейно. Модель не стала делать Pydantic-модели, enum-ы, DTO, jsonschema или хотя бы нормальные типы. Просто клиент, словарь, POST, assert.

Первое — успешный сценарий зачем-то размазан на шесть тестов

def test_returns_201(...)
def test_response_contains_operation_id(...)
def test_response_echoes_account_id(...)
def test_response_echoes_amount(...)
def test_response_echoes_operation_type(...)
def test_initial_status_is_created(...)

Это всё один контракт успешного создания операции. Но модель разнесла его на шесть отдельных тестов. В итоге вместо одного запроса к API будет шесть запросов.

Для маленькой демки не страшно. Для реального проекта — фатальное решение. Чем больше таких тестов, тем медленнее прогон, тем больше тестовых данных, тем больше мусора в системе, тем сложнее разбирать побочные эффекты.

И самое главное: эти проверки не независимые. Если создание операции сломалось, посыпется сразу пачка тестов, хотя проблема одна.

Второе — часть позитивных тестов вообще не проверяет статус

Например:

body = client.create(payload).json()
assert body["account_id"] == "acc-xyz-999"

Тут нет проверки, что сервер вернул 201. Тест сразу лезет в JSON. Если API вернёт ошибку, HTML, пустое тело или не тот формат, всё упадёт где-то в этой строке.

И вот это особенно криво:

body = client.create(payload).json()

В одной строке сразу и запрос, и парсинг ответа.

  • Ошибка может быть на отправке запроса.
  • Ошибка может быть на получении ответа.
  • Ошибка может быть на .json().
  • Ошибка может быть дальше на доступе к ключу.

А в стектрейсе всё будет указывать на одну строку. Спасибо, очень удобно дебажить.

Третье — контракта ответа нет

В позитивном сценарии модель проверяет отдельные поля:

assert body["account_id"] == "acc-xyz-999"
assert body["amount"] == 250
assert body["operation_type"] == "purchase"
assert body["status"] == "created"

Но нормальной валидации контракта нет. Если API вернёт amount строкой в одном сценарии, часть тестов может этого даже не заметить. Если появятся лишние поля, тесты тоже промолчат.

Четвёртое — негативные проверки опять максимально мягкие

assert response.status_code in (400, 422)

Это повторяется в каждом негативном тесте. То есть модели всё равно: 400 или 422.

Тело ошибки не проверяется вообще. Ни структура, ни поле, ни сообщение, ни код ошибки. API может вернуть пустой ответ, HTML-страницу, ошибку авторизации или вообще левую бизнес-ошибку — тесты всё равно будут зелёные, если статус попал в (400, 422).

Пятое — негативные сценарии снова придуманы моделью

create_operation_payload(amount=-1)
create_operation_payload(amount=0)
create_operation_payload(operation_type="unknown_type")
create_operation_payload(account_id="")
create_operation_payload(amount="not_a_number")

Часть сценариев нормальная как идея. Но из промпта не следует, что amount=0 запрещён, что отрицательная сумма запрещена, что формат account_id именно такой, что все такие ошибки должны возвращать 400 или 422.

Шестое — клиент вообще без типизации

class OperationsClient(BaseClient):
    _PREFIX = "/api/v1/operations"

    def create(self, payload: dict) -> httpx.Response:
        return self._client.post(self._PREFIX, json=payload)

Это одна из самых бедных реализаций среди всех моделей. payload: dict — и дальше делай что хочешь. Просто httpx.post, завернутый в метод create.

Седьмое — фабрика payload тоже слабая

from typing import Any


def create_operation_payload(
    account_id: str = "acc-test-001",
    amount: int = 100,
    operation_type: str = "purchase",
    **overrides: Any,
) -> dict:
    payload: dict = {
        "account_id": account_id,
        "amount": amount,
        "operation_type": operation_type,
    }
    payload.update(overrides)
    return payload

Это функция, которая возвращает словарь. Через overrides можно передать любое поле. И всё это спокойно улетит в API.

Умные люди уже давно придумали Pydantic, схемы, фабрики моделей и нормальную валидацию тестовых данных. Но тут модель просто сделала dict.update() и пошла дальше.

Восьмое — нет маркировок

Нет api, operations, positive, negative, smoke, regression. Есть классы, но классы в pytest — это не стратегия запуска. Как потом запускать только негативные тесты? Как выделить smoke? Как отделить операции от других ресурсов? Никак. Дописывай сам.

Девятое — наблюдаемости опять нет

Нет логов. Нет отчётов. Нет трейсинга. Нет нормального контекста при падении. Нет вывода request payload и response body в ошибках.

В маленьком примере это выглядит терпимо. В реальном проекте такие тесты быстро превращаются в «упало где-то там, пойду руками повторять запрос».

Итог по sonnet-4.6: это не расширяемые API-автотесты

Это набор прямолинейных проверок на словарях. Без моделей, без нормальной валидации, без типизации, без наблюдаемости и с кучей отдельных запросов там, где хватило бы одного нормального контрактного теста.

Выглядит как код, который пишет человек, который вчера понял, как вызвать httpx.post, и сегодня решил сделать тестовый проект.

Моя оценка: 1/10

Заключение

Итог получился довольно простой.

Все модели смогли сгенерировать код, который внешне похож на API-автотесты. Если смотреть быстро, особенно не погружаясь, можно подумать: ну вот, автотесты написаны. Но если читать код как тестовый проект, который потом нужно поддерживать, всё становится грустнее.

Я специально сделал промпт не полностью однозначным. Не расписал все требования до последнего поля. Не сказал, какие должны быть логи, отчёты, схемы, Pydantic, маркировки, фикстуры, стратегия данных и обработка ошибок.

Потому что именно так чаще всего и будет работать человек без нормального опыта в автоматизации. Он не знает, что нужно попросить. Он не знает, где модель халтурит. Он не знает, что 400 или 422 — это уже слабая проверка. Он не знает, почему response.json() прямо в тесте потом превратится в боль. Он не знает, зачем нужны нормальные фабрики, схемы, конфиги, маркировки и диагностический контекст.

Он просто попросит:

«напиши API-автотесты, чтобы можно было расширять дальше».

И получит вот это.

И ещё раз: это были не какие-то слабые модели с помойки. Это одни из самых сильных моделей на момент написания статьи, июнь 2026 года.

Мой общий вывод простой: если вы не понимаете автоматизацию тестирования, AI не сделает за вас нормальную тестовую архитектуру. Он просто быстро и уверенно нагенерирует черновик, который потом будет выглядеть как проект, запускаться как проект, падать как проект, но поддерживаться будет как боль.

Все исходники эксперимента лежат в отдельном репозитории: https://github.com/Nikita-Filonov/ai-api-tests