Промпт-инжиниринг для тестирования API — как настроить ИИ-агента

Опубликовано 09.10.2026 04:38:00 в категории Искусственный интеллект

Тесты для API — это, пожалуй, одна из тех задач, которые разработчики любят меньше всего. Не потому, что сложно, а потому, что однообразно: подготовить данные, отправить запрос, проверить код ответа, проверить тело ответа, повторить для невалидных данных, для неавторизованного пользователя, для несуществующей записи… И так для каждой конечной точки (endpoint). Неудивительно, что именно эту работу хочется отдать ИИ-агенту (AI agent) в первую очередь.

И здесь есть хорошая новость: тестирование API подходит для агента лучше, чем многие другие задачи. Причин три.

  • Есть контракт. Спецификация OpenAPI описывает, какие маршруты существуют, что они принимают и что возвращают. Агенту не нужно угадывать намерения разработчика — ему достаточно их прочитать.
  • Есть однозначный результат. Тест либо прошёл, либо нет. Это не код-ревью и не архитектурное решение, где «правильно» зависит от контекста и вкуса.
  • Агент может проверить себя сам. Современные агенты умеют запускать команды в терминале, так что dotnet test становится для них обратной связью (feedback loop): написал, запустил, прочитал ошибку, исправил.

Плохая новость в другом. Если открыть агента и написать ему что-нибудь в духе:

Напиши тесты для OrdersController

то результат, скорее всего, будет выглядеть убедительно и при этом почти ничего не проверять. Агент откроет код контроллера, напишет пару-тройку тестов на «счастливый путь» (happy path), убедится, что ответ — 200 OK, и бодро отчитается об успехе. Причём проверять он будет то, что код делает сейчас, а не то, что код должен делать. Если в контроллере ошибка — тест её аккуратно закрепит, и в следующий раз, когда кто-то эту ошибку исправит, тест покраснеет.

Задача промпт-инжиниринга (prompt engineering) в этом контексте — не придумать «волшебную фразу», а убрать из задачи всё, что агенту пришлось бы додумывать.

Сравните с другим вариантом того же запроса:

Напиши интеграционные тесты для эндпоинтов /api/orders.
Источник истины — спецификация docs/openapi.json, а не код контроллера.
Для каждого эндпоинта покрой: успешный сценарий, ошибки валидации
(400 в формате ProblemDetails), отсутствие авторизации (401),
несуществующий ресурс (404).
Используй WebApplicationFactory и общую фикстуру из tests/Common.
Продакшн-код не меняй. Если тест падает из-за ошибки в коде —
остановись и опиши ошибку.
Готово, когда dotnet test проходит без ошибок.

Это всё ещё короткий промпт — девять строк. Но разница в результате будет огромной, потому что здесь есть всё, без чего агент работает «на глазок» или «кое-как»:

  • без источника истины,
  • без переченя сценариев,
  • без ограничений,
  • без критериев готовности.

Об этой разнице и пойдёт речь дальше. Агент — не волшебник, а очень быстрый и очень исполнительный стажёр: он сделает ровно то, что вы попросили, и додумает всё остальное так, как ему удобнее. Задача промпт-инжиниринга (prompt engineering) в этом контексте — не придумать «волшебную фразу», а убрать из задачи всё, что агенту пришлось бы додумывать. В каждом разделе ниже я буду показывать пары «Как не надо → Как надо»: плохой промпт, правильный промпт и разбор того, что между ними изменилось. Основным примером будет Claude Code, а там, где в других агентах (GitHub Copilot, Cursor) то же самое делается по-другому, я буду это отмечать отдельно.

Что агент должен знать о проекте

Первое правило, которое стоит усвоить: агент знает только то, что ему дали. Он не был на вашем планировании, не читал переписку с аналитиком и понятия не имеет, что «заказ без позиций — это ошибка, а не пустой заказ». Всё, что вы держите в голове, для агента не существует.

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

Агент Где лежат инструкции проекта
Claude Code CLAUDE.md в корне репозитория (в актуальных версиях подхватывается и AGENTS.md)
GitHub Copilot .github/copilot-instructions.md, AGENTS.md, а для отдельных папок — .github/instructions/*.instructions.md с полем applyTo
Cursor .cursor/rules/*.mdc с привязкой к маскам файлов, а также AGENTS.md

Если вы работаете с несколькими агентами в одной команде, удобнее держать основное содержимое в AGENTS.md, а остальные файлы делать короткими и ссылаться на него.

Что в этот файл нужно положить именно для тестирования API:

  • Где источник истины. Путь к спецификации OpenAPI и явное указание, что тесты пишутся по ней, а не по коду.
  • Структура тестового проекта. Где лежат интеграционные тесты, где общие фикстуры (fixtures), где генераторы тестовых данных.
  • Соглашения по именованию. Как называются классы и методы тестов. Агент отлично копирует образец — дайте ему образец.
  • Разрешённые пакеты. Иначе в проекте внезапно появится третья библиотека для проверок (assertions).
  • Команды. Как собрать, как запустить тесты, как запустить только один класс.
  • Чего делать нельзя. Это самый недооценённый пункт.

Как не надо

# Тесты
Мы используем xUnit. Пиши хорошие тесты.

Формально инструкция есть. Фактически агент из неё узнает только название фреймворка — всё остальное он угадает по ближайшему попавшемуся файлу, а «хорошие тесты» в его понимании могут сильно отличаться от ваших.

Как надо

## Тестирование API

- Источник истины для API — `docs/openapi.json`. Тесты проверяют контракт,
  а не текущую реализацию.
- Интеграционные тесты: `tests/Orders.Api.IntegrationTests/`.
  Общая инфраструктура: `tests/Orders.Api.IntegrationTests/Common/`
  (ApiFactory, TestDataBuilder). Новую инфраструктуру не создавать.
- Фреймворк: xUnit v3. Проверки — только `Assert`. Тестовые данные — Bogus.
  Другие пакеты не добавлять.
- Имя теста: `Метод_Должен_Результат_Когда_Условие`,
  например `GetOrder_Should_Return404_When_OrderNotExists`.
- Образец оформления: `tests/.../Orders/GetOrderTests.cs`.
- Ошибки API возвращаются в формате ProblemDetails (RFC 9457).
- Запуск: `dotnet test tests/Orders.Api.IntegrationTests`.
  Один класс: `dotnet test --filter "FullyQualifiedName~GetOrderTests"`.
- НЕЛЬЗЯ: менять код в `src/`, удалять или пропускать (`Skip`) упавшие тесты,
  ослаблять проверки, чтобы тест прошёл.

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

Структура промпта для тестирования

Файл инструкций — это то, что верно для проекта всегда. Промпт — это то, что верно для конкретной задачи. Хороший промпт для генерации тестов состоит из шести частей, и каждая отвечает на вопрос, который агент иначе решит за вас.


Рисунок 1. Плохой промпт содержит только задачу. Правильный — отвечает на шесть вопросов, которые агент иначе решит сам.

Пройдёмся по каждому элементу. Для наглядности — пара «Как не надо → Как надо» на каждый.

1. Роль

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

Как не надо:

Ты лучший в мире программист.

Ничего не даёт: «лучший программист» не уточняет, что именно вы от него ждёте.

Как надо:

Ты QA-инженер, который проверяет API на соответствие контракту.
Твоя цель — найти расхождения между спецификацией и поведением,
а не подтвердить, что всё работает.

Вторая фраза здесь важнее первой. Она меняет мотивацию агента: зелёный тест перестаёт быть целью сам по себе.

2. Контекст

Как не надо:

Посмотри проект и разберись.

Агент разберётся — и потратит на это половину контекстного окна (context window), прочитав всё подряд, включая миграции и Program.cs.

Как надо:

Контракт: docs/openapi.json, раздел /api/orders.
Реализация: src/Orders.Api/Endpoints/OrderEndpoints.cs.
Бизнес-правила: заказ без позиций невалиден; отменённый заказ
нельзя изменить (409 Conflict).

Явный список файлов экономит контекст, а бизнес-правила, которых нет в спецификации, агент иначе не узнает никогда.

3. Задача

Как не надо:

Протестируй заказы.

Юнит-тесты (unit tests) или интеграционные? Какие эндпоинты? Все сразу?

Как надо:

Напиши интеграционные тесты для GET /api/orders/{id}
и POST /api/orders. Остальные эндпоинты не трогай.

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

4. Ограничения

Как не надо: ничего не написать. Отсутствие ограничений агент понимает как разрешение на всё.

Как надо:

- Код в src/ не менять.
- Не добавлять NuGet-пакеты.
- Не использовать Thread.Sleep и задержки.
- Если тест падает из-за ошибки в реализации — не подгонять тест,  а остановиться и описать ошибку.

Последний пункт — самый важный во всей статье. Подробнее о нём — в разделе «Ловушки и как их закрыть промптом».

5. Формат результата

Как не надо:

Напиши тесты.

Как надо:

Сначала выведи план: таблицу «эндпоинт — сценарий — ожидаемый код
ответа». Дождись подтверждения. После подтверждения пиши код
в файлы tests/.../Orders/{Endpoint}Tests.cs, один класс на эндпоинт.
В конце — краткий отчёт: сколько тестов, что не удалось покрыть и почему.

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

6. Критерий готовности

Как не надо: не указывать. Тогда «готово» наступает, когда агент решил, что готово.

Как надо:

Готово, когда:
- dotnet test проходит без ошибок и предупреждений;
- каждый код ответа из спецификации для этих эндпоинтов покрыт
  хотя бы одним тестом;
- каждый тест проверяет и код ответа, и тело ответа.

Критерий готовности (definition of done) превращает субъективное «вроде нормально» в проверяемый список. Агент, у которого есть возможность запускать команды, будет крутиться в цикле, пока этот список не выполнится.

Стратегия покрытия, зашитая в промпт

Если не сказать агенту, какие сценарии покрывать, он покроет «счастливый путь» и, может быть, одну-две ошибки — те, что первыми пришли ему в голову. Чтобы покрытие было системным, его нужно описать как чек-лист, по которому агент пройдёт для каждого эндпоинта.

Вот категории, которые я считаю обязательными для REST API:

  • Успешный сценарий — правильный код (200, 201, 204), правильное тело, заголовок Location для созданных ресурсов.
  • Ошибки валидации — 400 с телом ProblemDetails, в котором перечислены именно те поля, которые не прошли проверку.
  • Аутентификация и авторизация — 401 без токена, 403 с токеном, у которого нет прав.
  • Несуществующий ресурс — 404, причём и для «никогда не существовал», и для «был удалён».
  • Конфликт состояний — 409, когда операция недопустима в текущем состоянии ресурса.
  • Граничные значения (boundary values) — пустые строки, максимальная длина, ноль и отрицательные числа, пустые коллекции, пагинация на первой и последней странице.
  • Идемпотентность (idempotency) — повторный PUT или DELETE даёт тот же результат, повторный POST с тем же ключом идемпотентности не создаёт дубликат.

Не каждая категория применима к каждому эндпоинту. Поэтому полезно попросить агента сначала построить матрицу покрытия.


Рисунок 2. Матрица покрытия, которую агент строит до написания кода. Пустые ячейки — осознанное решение, а не забытый сценарий.

Как не надо

Покрой все возможные случаи.

«Все возможные» для агента — это те, о которых он подумал. А думает он о самых очевидных. Кроме того, такая формулировка не даёт вам способа проверить, что «все» действительно все.

Как надо

Для каждого эндпоинта пройди по чек-листу и для каждой категории
либо напиши тест, либо явно укажи «не применимо» с причиной:
1. Успех (2xx): код, тело, заголовок Location для 201.
2. Валидация (400): каждое обязательное поле по отдельности;
   в ответе ProblemDetails проверь errors[<поле>].
3. Авторизация: 401 без токена, 403 для роли Viewer.
4. Не найдено (404): несуществующий Guid.
5. Конфликт (409): изменение заказа в статусе Cancelled.
6. Границы: строки 0 / max / max+1 символов, quantity = 0 и -1.
7. Идемпотентность: повторный DELETE возвращает 404, а не 500.
Результат оформи матрицей «эндпоинт × категория» до написания кода.

Ключевая фраза здесь — «либо явно укажи "не применимо" с причиной». Она запрещает агенту молча пропускать категории. Пропуск становится видимым решением, которое вы можете оспорить.

Практическая настройка

Теперь соберём всё вместе. Нам понадобятся три вещи: тестовая инфраструктура, которую агент будет переиспользовать, отдельный агент-тестировщик со своим системным промптом и защита от того, чтобы агент «чинил» продакшн-код.

Тестовая инфраструктура

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

// tests/Orders.Api.IntegrationTests/Common/ApiFactory.cs
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Testcontainers.PostgreSql;
using Xunit;

namespace Orders.Api.IntegrationTests.Common;

public sealed class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _database = new PostgreSqlBuilder()
        .WithImage("postgres:17-alpine")
        .Build();

    public async ValueTask InitializeAsync() => await _database.StartAsync();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");
        builder.UseSetting("ConnectionStrings:DefaultConnection",
            _database.GetConnectionString());
    }

    public override async ValueTask DisposeAsync()
    {
        await _database.DisposeAsync();
        await base.DisposeAsync();
    }
}

Тестовый контейнер (Testcontainers) поднимает настоящий PostgreSQL в Docker, так что тесты проверяют реальное поведение EntityFrameworkCore, а не поведение базы в памяти (in-memory), которая прощает многое из того, чего не простит настоящая. Не забудьте добавить в API строку public partial class Program;, иначе WebApplicationFactory<Program> не увидит класс, сгенерированный для операторов верхнего уровня (top-level statements).

Рядом с фабрикой положите построитель тестовых данных (test data builder) на Bogus — тот самый TestDataBuilder, на который ссылается CLAUDE.md. Агент будет вызывать его, а не генерировать данные в каждом тесте по-своему.

Агент-тестировщик

В Claude Code для повторяющихся задач с собственными правилами есть субагенты (subagents). Это Markdown-файл с YAML-заголовком в папке .claude/agents/: заголовок задаёт имя, описание и доступные инструменты, а тело файла становится системным промптом (system prompt) агента. Субагент работает в собственном контекстном окне и возвращает в основной диалог только итог — длинные логи dotnet test не засоряют вашу основную сессию.

---
name: api-tester
description: Пишет и исправляет интеграционные тесты API по спецификации
  OpenAPI. Использовать, когда нужно покрыть тестами эндпоинты.
tools: Read, Grep, Glob, Edit, Write, Bash
model: sonnet
---

Ты QA-инженер, который проверяет API на соответствие контракту.
Твоя цель — найти расхождения между спецификацией и поведением,
а не подтвердить, что всё работает.

Порядок работы:
1. Прочитай раздел спецификации docs/openapi.json для указанных
   эндпоинтов и бизнес-правила из задачи.
2. Построй матрицу покрытия по чек-листу из CLAUDE.md и выведи её.
3. Пиши тесты, используя только ApiFactory и TestDataBuilder
   из tests/Orders.Api.IntegrationTests/Common/.
4. Запусти dotnet test с фильтром по своему классу.
5. Если тест упал — определи причину:
   - ошибка в тесте → исправь тест и вернись к шагу 4;
   - поведение API расходится со спецификацией → НЕ меняй тест
     и НЕ меняй код в src/. Пометь тест атрибутом
     [Trait("Status", "ContractViolation")] и опиши расхождение в отчёте.
6. Не более 5 итераций исправления на один тест. Если не получилось —
   остановись и опиши, что мешает.

Каждый тест проверяет и код ответа, и тело ответа.
Отчёт в конце: матрица покрытия, список нарушений контракта,
что не удалось покрыть и почему.

Обратите внимание на шаг 5 — это развилка, на которой ломается большинство агентов-тестировщиков. Агент обязан различать «я написал неправильный тест» и «API ведёт себя неправильно». Без явной инструкции он во втором случае просто поменяет ожидаемое значение в тесте.

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


Рисунок 3. Цикл работы агента: красный тест ведёт либо к исправлению теста, либо к остановке и отчёту — но никогда к правке кода в src/.

Вызвать агента можно по имени — «используй api-tester, чтобы покрыть GET /api/orders/{id}» — или через упоминание @, если нужна гарантия, что задачу возьмёт именно он.

В других агентах. В GitHub Copilot аналог — файл .github/agents/api-tester.agent.md с тем же принципом: заголовок с описанием и инструментами, тело — инструкции. В Cursor отдельных субагентов в таком виде нет, поэтому те же инструкции оформляют правилом в .cursor/rules/api-testing.mdc с привязкой к маске tests/**, чтобы оно подключалось, когда агент работает с тестами.

Защита продакшн-кода

Инструкция «не меняй src/» в промпте — это просьба. Агент в длинной сессии может о ней «забыть», особенно когда тест упорно не проходит. Поэтому просьбу стоит подкрепить механизмом.

В Claude Code для этого есть хуки (hooks): скрипт, который выполняется перед каждым вызовом инструмента и может его заблокировать. Хук можно объявить прямо в заголовке субагента — тогда он действует, только пока работает этот агент:

hooks:
  PreToolUse:
    - matcher: "Edit|Write"
      hooks:
        - type: command
          command: "./scripts/allow-only-tests.sh"
#!/bin/bash
# scripts/allow-only-tests.sh — разрешает правки только в tests/
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [[ -n "$FILE" && "$FILE" != *"/tests/"* ]]; then
  echo "Запрещено: агент-тестировщик может менять только файлы в tests/" >&2
  exit 2
fi
exit 0

Код выхода 2 блокирует операцию, а текст из потока ошибок агент получает как объяснение причины. Теперь даже если агент решит, что «проще поправить контроллер», у него это не получится. На Windows тот же скрипт пишется на PowerShell, а в описание хука добавляется shell: powershell.

Пример от начала до конца

Возьмём эндпоинт получения заказа. Обработчик использует Calabonga.Results: метод сервиса возвращает Operation<OrderViewModel, string> — либо результат, либо текст ошибки, — а эндпоинт превращает его в HTTP-ответ.

using Calabonga.OperationResults;
using Calabonga.UnitOfWork;

public sealed class OrderService(IUnitOfWork unitOfWork)
{
    public async Task<Operation<OrderViewModel, string>> GetByIdAsync(
        Guid id, CancellationToken cancellationToken)
    {
        var order = await unitOfWork.GetRepository<Order>()
            .GetFirstOrDefaultAsync(predicate: x => x.Id == id);

        if (order is null)
        {
            return Operation.Error($"Заказ {id} не найден");
        }

        return Operation.Result(order.ToViewModel());
    }
}
app.MapGet("/api/orders/{id:guid}", async (
        Guid id, OrderService service, CancellationToken cancellationToken) =>
    {
        var (result, error) = await service.GetByIdAsync(id, cancellationToken);

        return error is null
            ? Results.Ok(result)
            : Results.Problem(detail: error, statusCode: StatusCodes.Status404NotFound);
    })
    .RequireAuthorization();

Деконструкция var (result, error) — одна из удобных возможностей Operation<T, TError>: обе ветки, успешная и ошибочная, видны в одной строке, и именно их агенту нужно покрыть тестами.

Как не надо

Промпт «Напиши тесты для GET /api/orders/{id}» почти наверняка даст что-то такое:

[Fact]
public async Task GetOrder_ReturnsOk()
{
    var client = _factory.CreateClient();

    var response = await client.GetAsync($"/api/orders/{Guid.NewGuid()}");

    Assert.NotNull(response);
}

Посмотрите внимательно: тест запрашивает случайный, заведомо несуществующий заказ, проверяет, что ответ «не null» (он никогда не бывает null), и называется ReturnsOk. Он пройдёт при любом поведении API, включая 500 Internal Server Error. Такой тест хуже, чем отсутствие теста: он создаёт ощущение, что код проверен.

Как надо

Используй api-tester.
Эндпоинт: GET /api/orders/{id}, контракт в docs/openapi.json.
Реализация возвращает Operation<OrderViewModel, string> из Calabonga.Results:
ветка Result → 200 с OrderViewModel, ветка Error → 404 с ProblemDetails.
Эндпоинт требует авторизации.
Покрой: 200 для существующего заказа (данные через TestDataBuilder,
сверить Id, Number и количество Items); 404 для несуществующего Guid
с проверкой status и detail в ProblemDetails; 401 без токена;
400 для id, который не является Guid.
Сначала матрица, потом код. Готово, когда все тесты зелёные
или нарушения контракта описаны в отчёте.

В ответ на такой промпт агент построит матрицу, вы её подтвердите, и получатся тесты вроде этих:

public sealed class GetOrderTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
    private readonly CancellationToken _ct = TestContext.Current.CancellationToken;

    [Fact]
    public async Task GetOrder_Should_Return200WithOrder_When_OrderExists()
    {
        // arrange
        var order = await TestDataBuilder.CreateOrderAsync(factory, itemsCount: 3);
        var client = factory.CreateAuthorizedClient();

        // act
        var response = await client.GetAsync($"/api/orders/{order.Id}", _ct);

        // assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var body = await response.Content.ReadFromJsonAsync<OrderViewModel>(_ct);
        Assert.NotNull(body);
        Assert.Equal(order.Id, body.Id);
        Assert.Equal(order.Number, body.Number);
        Assert.Equal(3, body.Items.Count);
    }

    [Fact]
    public async Task GetOrder_Should_Return404WithProblemDetails_When_OrderNotExists()
    {
        // arrange
        var client = factory.CreateAuthorizedClient();
        var missingId = Guid.NewGuid();

        // act
        var response = await client.GetAsync($"/api/orders/{missingId}", _ct);

        // assert
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
        var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(_ct);
        Assert.NotNull(problem);
        Assert.Equal(404, problem.Status);
        Assert.Contains(missingId.ToString(), problem.Detail);
    }

    [Fact]
    public async Task GetOrder_Should_Return401_When_NoToken()
    {
        // arrange
        var client = factory.CreateClient();

        // act
        var response = await client.GetAsync($"/api/orders/{Guid.NewGuid()}", _ct);

        // assert
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }
}

Каждый тест проверяет ровно одно поведение, у каждого понятное имя, и каждый упадёт, если API начнёт вести себя иначе, чем описано в контракте. CreateAuthorizedClient() — метод расширения из той же папки Common, который агент не писал, а нашёл и переиспользовал, потому что ему об этом сказали.

А теперь интересный момент. Тест на 400 для /api/orders/not-a-guid у агента не пройдёт: из-за ограничения маршрута {id:guid} ASP.NET Core вернёт 404, потому что маршрут просто не совпадёт. Правильно настроенный агент не станет менять ожидание на 404, а отметит в отчёте: «Спецификация обещает 400 для невалидного id, API возвращает 404 — расхождение контракта». И это ровно та находка, ради которой стоило писать тесты. Дальше решать вам: поправить спецификацию или реализацию.

Ловушки и как их закрыть промптом

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

Тест подгоняется под код

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


Рисунок 4. Тест, написанный по коду, закрепляет ошибку. Тест, написанный по контракту, её находит.

Как не надо: «Напиши тесты для OrderEndpoints.cs» — агенту дали код и ничего, кроме кода.

Как надо: «Ожидаемые значения бери только из docs/openapi.json и бизнес-правил из задачи. Код реализации читай только для того, чтобы понять, как вызвать эндпоинт». Ещё надёжнее — давать агенту сначала только спецификацию, а реализацию показывать, когда тесты уже написаны.

Несуществующие эндпоинты

Агент «помнит», что у типичного API заказов есть PATCH /api/orders/{id}/status, и пишет для него тесты. В вашем API такого нет. Тест падает с 404, агент начинает «чинить».

Как не надо: не указывать источник списка эндпоинтов.

Как надо: «Тестируй только эндпоинты, которые есть в docs/openapi.json. Если нужный сценарий требует эндпоинта, которого нет в спецификации, — не пиши тест, а упомяни это в отчёте».

Правка продакшн-кода

Тест не проходит, агент «замечает ошибку» в контроллере и исправляет её. Иногда исправление даже правильное — но вы просили тесты, а получили незапланированные изменения в коде, которые теперь нужно отдельно ревьюить.

Как не надо: надеяться, что агент сам поймёт границы задачи.

Как надо: запрет в промпте плюс хук из раздела «Практическая настройка». Промпт объясняет агенту, почему нельзя, хук гарантирует, что не получится.

Хрупкие и нестабильные тесты

Нестабильный тест (flaky test) — тот, что то проходит, то падает без изменений в коде. У агентов три любимых источника: Thread.Sleep для «ожидания», зависимость от порядка выполнения тестов и жёстко заданные данные, которые конфликтуют между тестами.

Как не надо: «Тесты должны быть стабильными».

Как надо: «Каждый тест создаёт свои данные через TestDataBuilder с уникальными значениями. Тесты не зависят друг от друга и от порядка запуска. Никаких Thread.Sleep и Task.Delay. Даты — только через инжектируемый TimeProvider». Конкретный запрет работает, общее пожелание — нет.

Секреты в тестах

Агенту нужен токен для авторизованного запроса. Он находит строку подключения или ключ в appsettings.Development.json и копирует его прямо в тест. Тест уходит в репозиторий.

Как не надо: не упоминать тему вовсе.

Как надо: «Токены получай только через factory.CreateAuthorizedClient(). Не копируй в тесты значения из appsettings*.json, переменных окружения и секретов пользователя (user secrets)». А в CLAUDE.md стоит добавить общее правило для всех задач, не только для тестов.

Как проверить, что тесты агента чего-то стоят

Допустим, агент написал сорок тестов, все зелёные, покрытие кода (code coverage) — 87%. Хорошие ли это тесты? Из этих цифр — неизвестно.

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

Честная проверка — мутационное тестирование (mutation testing). Инструмент вносит в код маленькие изменения — мутанты (mutants): меняет > на >=, == на !=, удаляет вызов метода — и запускает тесты. Если тесты упали, мутант «убит», то есть тесты заметили изменение поведения. Если прошли — мутант «выжил», и это место в коде на самом деле не проверено. В .NET для этого есть Stryker.NET:

dotnet tool install -g dotnet-stryker
cd tests/Orders.Api.IntegrationTests
dotnet stryker

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

Как не надо

Покрытие 87%, подними до 95%.

Как надо

Используй api-tester. Ниже выживший мутант из отчёта Stryker:
src/Orders.Api/Validation/CreateOrderValidator.cs, строка 18:
`x.Quantity > 0` заменено на `x.Quantity >= 0` — мутант выжил.
Напиши тест, который убивает этого мутанта, проверяя поведение
через POST /api/orders по контракту. Код в src/ не меняй.
Готово, когда повторный запуск dotnet stryker показывает,
что мутант убит.

Такой промпт невозможно выполнить формально: тест либо ловит конкретное изменение поведения, либо нет. И попутно вы узнаёте, что граничное значение quantity = 0 не было покрыто — несмотря на 87%.

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

  1. Матрица покрытия — нет ли категорий, помеченных «не применимо» без убедительной причины.
  2. Ожидаемые значения — взяты из контракта или из кода.
  3. Проверки — каждая ли проверяет тело ответа, а не только код.
  4. Отчёт о нарушениях контракта — это самое ценное, что агент может принести.

Мутационное тестирование при этом не нужно запускать на каждый коммит — оно медленное. Достаточно прогнать его на новом наборе тестов один раз после генерации и потом периодически.

Заключение

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

  • Файл инструкций проекта (CLAUDE.md, AGENTS.md, .github/copilot-instructions.md) — один раз описать источник истины, структуру, соглашения и запреты.
  • Шесть элементов промпта — роль, контекст, задача, ограничения, формат результата, критерий готовности. Нет хотя бы одного — агент решит этот вопрос сам.
  • Чек-лист покрытия с обязательным «не применимо, потому что…» вместо молчаливого пропуска.
  • План до кода — матрицу сценариев проверить проще, чем двести строк тестов.
  • Отдельный агент-тестировщик с явной развилкой «ошибка в тесте → исправить, ошибка в API → остановиться и сообщить».
  • Механическая защита продакшн-кода хуком, а не только просьбой в промпте.
  • Мутационное тестирование вместо процента покрытия как способ проверить, что тесты что-то проверяют.

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

Используемые пакеты

Пакет Назначение
Calabonga.Results Operation<T, TError> для возврата результата или ошибки из сервиса (пространство имён Calabonga.OperationResults)
Calabonga.UnitOfWork Доступ к данным через репозитории и единицу работы (Unit of Work)
Microsoft.AspNetCore.Mvc.Testing WebApplicationFactory<T> для запуска API в интеграционных тестах
Testcontainers.PostgreSql Настоящий PostgreSQL в Docker-контейнере на время тестов
Npgsql.EntityFrameworkCore.PostgreSQL Провайдер EntityFrameworkCore для PostgreSQL
xunit.v3 Тестовый фреймворк
Bogus Генерация тестовых данных в TestDataBuilder
dotnet-stryker Мутационное тестирование (Stryker.NET), устанавливается как глобальный инструмент

Полный список моих пакетов — на nuget.org.

Комментарии к статье ()

Загрузка...

Что-то пошло не по сценарию и завершилось ошибкой. Перезагрузить страницу (F5) 🗙

Переподключаем сервер...

Переподключение сломалось... Пробуем еще раз сек.

Не получилось переподключится.
Пожалуйста, обновите страницу F5

Сессия была поставлена сервером на паузу.

Восстановить сессию не получилось.
Пожалуйста, обновите страницу F5.