Промпт-инжиниринг для тестирования API — как настроить ИИ-агента
Тесты для 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%.
И последнее звено — человек. Агент генерирует тесты быстрее, чем вы успеваете их читать, и в этом главный соблазн: принять всё не глядя. На ревью я смотрю на четыре вещи:
- Матрица покрытия — нет ли категорий, помеченных «не применимо» без убедительной причины.
- Ожидаемые значения — взяты из контракта или из кода.
- Проверки — каждая ли проверяет тело ответа, а не только код.
- Отчёт о нарушениях контракта — это самое ценное, что агент может принести.
Мутационное тестирование при этом не нужно запускать на каждый коммит — оно медленное. Достаточно прогнать его на новом наборе тестов один раз после генерации и потом периодически.
Заключение
ИИ-агент действительно может снять с вас большую часть рутины в тестировании 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.