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

Опубликовано 08.10.2026 05:43:00 в категории Искусственный интеллект

Попросить ИИ-агента написать тесты — дело одной строки. Получить тесты, которые действительно что-то проверяют, гораздо сложнее. В этой статье разберём, как сформулировать задачу, какие инструкции один раз положить в репозиторий и как выстроить процесс, чтобы агент писал полезные unit-тесты, а не просто зелёные. Примеры будут для Claude Code на стеке xUnit + Moq + AutoFixture. При этом всё сказанное применимо к любому агенту: GitHub Copilot, Cursor, Codex и другим. Меняются только имена файлов и команды.

Почему «напиши тесты» не работает

Давайте начнём с эксперимента. Возьмём простой сервис оформления заказа. Он отклоняет пустой заказ, начисляет скидку в зависимости от суммы и сохраняет заказ в базу данных. В примере используются две мои сборки:

  • Calabonga.Results — реализация паттерна Result (Result Pattern). Сервис возвращает Operation<T, TError>: у результата есть признак успеха Ok, значение Result и ошибка Error, а создаётся он через Operation.Result(...) или Operation.Error(...).
  • Calabonga.UnitOfWork — реализация паттерна «Единица работы» (Unit of Work) для EntityFrameworkCore. Репозиторий берётся через GetRepository<T>(), изменения сохраняются через SaveChangesAsync(). Важная деталь: SaveChangesAsync() не выбрасывает исключение при ошибке, а записывает его в IUnitOfWork.Result, поэтому успех сохранения проверяется через Result.Ok.
using Calabonga.OperationResults;
using Calabonga.UnitOfWork;

public sealed class OrderService(IUnitOfWork unitOfWork)
{
    public async Task<Operation<Order, string>> PlaceOrderAsync(Order order, CancellationToken cancellationToken)
    {
        if (order.Items.Count == 0)
        {
            return Operation.Error("Пустой заказ");
        }

        var discountRate = order.Total switch
        {
            >= 50_000m => 0.10m,
            >= 10_000m => 0.05m,
            _ => 0m
        };

        order.ApplyDiscount(order.Total * discountRate);

        await unitOfWork.GetRepository<Order>().InsertAsync(order, cancellationToken);
        await unitOfWork.SaveChangesAsync();

        if (!unitOfWork.Result.Ok)
        {
            return Operation.Error("Не удалось сохранить заказ");
        }

        return Operation.Result(order);
    }
}

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

[Fact]
public async Task PlaceOrderAsync_Test()
{
    var repository = new Mock<IRepository<Order>>();
    var unitOfWork = new Mock<IUnitOfWork>();
    unitOfWork.Setup(x => x.GetRepository<Order>(It.IsAny<bool>())).Returns(repository.Object);
    unitOfWork.Setup(x => x.Result).Returns(new SaveChangesResult());
    var sut = new OrderService(unitOfWork.Object);
    var order = new Order([new OrderItem("SKU-1", price: 12_000m, quantity: 1)]);

    var result = await sut.PlaceOrderAsync(order, CancellationToken.None);

    var expected = order.Total >= 10_000m ? order.Total * 0.05m : 0m;
    Assert.NotNull(result);
    Assert.Equal(expected, result.Result.Discount);
    unitOfWork.Verify(x => x.SaveChangesAsync(), Times.Once);
}

Посмотрите внимательно. Ожидаемое значение здесь вычисляется той же формулой, что и в коде. Если в сервисе ошибка, она один в один повторяется в тесте, и тест остаётся зелёным. Assert.NotNull не проверяет вообще ничего: Operation<T, TError> — структура, и null она не бывает никогда. Признак успеха Ok при этом не проверен. Имя теста ничего не говорит о проверяемом поведении. Граничные значения 10 000 и 50 000 не проверяются вовсе. Ветка с ошибкой сохранения не проверена: агент настроил успешный SaveChangesResult просто для того, чтобы тест не упал. И ещё четыре строки шаблонного кода настройки моков будут повторяться в каждом тесте.

Это не значит, что модель «глупая». Мы дали ей код и не дали ничего, кроме кода. Агент честно прочитал реализацию и написал тесты, которые эту реализацию повторяют. О том, каким должно быть поведение, он не знает: мы ему этого не сказали. Отсюда главный тезис статьи: качество сгенерированных тестов определяется постановкой задачи, а не моделью. Промпт-инжиниринг (prompt engineering) в этом контексте — способ передать агенту то, что знаем мы, и не знает он.

Каким должен быть хороший unit-тест

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

  • Структура Arrange / Act / Assert (AAA). Подготовка, действие, проверка. Три блока, визуально разделённые. Тест читается сверху вниз без усилий.
  • Один тест — одно поведение. Не «тест метода», а «тест сценария»: пустой заказ отклоняется; заказ на 10 000 получает скидку 5%. Если тест упал, по его имени уже понятно, что сломалось.
  • Говорящее имя. Формат Метод_Условие_ОжидаемыйРезультат: PlaceOrderAsync_EmptyOrder_ReturnsFailure. Можно спорить о конкретном формате, но он должен быть один на весь проект.
  • Тест по спецификации, а не по реализации. Ожидаемое значение — это конкретное число из требований: «500», а не «total * 0.05». Тест не должен знать, как устроен код. Он должен знать, что код обязан сделать.
  • Изоляция. Unit-тест не ходит в базу, сеть и файловую систему. Внешние зависимости подменяются. При этом мокаются именно внешние зависимости, а не всё подряд.
  • Детерминизм. Тест даёт одинаковый результат при каждом запуске: никаких DateTime.Now, Guid.NewGuid() и случайных чисел без контроля.
  • Граничные значения. Если в требованиях есть порог, тест проверяет значение до порога, на пороге и после. Именно на границах живёт большинство ошибок.

Анатомия промпта для тестов

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


Блоки промпта: что агент узнаёт из каждого из них

  1. Контекст и стек. Тестовый фреймворк, библиотеки, расположение тестового проекта. Без этого агент выберет то, что встречал чаще всего, например FluentAssertions и NSubstitute, а у вас Moq и AutoFixture.
  2. Тестируемый объект (system under test, SUT). Какой класс и какой метод тестируем, где он лежит, какие у него зависимости.
  3. Спецификация поведения. Самый важный блок. Что метод должен делать, сформулированное как требования, а не как пересказ кода.
  4. Сценарии. Основной сценарий (happy path), граничные случаи (edge cases), ошибки и исключения. Граничные значения лучше перечислить явно.
  5. Ограничения. Что не мокать, что не тестировать, чего не делать.
  6. Формат результата и следующий шаг. Куда положить файл, что сделать после генерации, например запустить тесты.

Вот как выглядит промпт для нашего OrderService, если собрать все блоки вместе:

Напиши unit-тесты для OrderService.PlaceOrderAsync
(src/Shop.Application/Orders/OrderService.cs).

Спецификация (тесты пишем по ней, а не по коду):
- пустой заказ → Operation с Ok == false и Error == "Пустой заказ",  в репозиторий ничего не добавляется;
- сумма заказа меньше 10 000 → скидки нет;
- сумма от 10 000 → скидка 5% от суммы;
- сумма от 50 000 → скидка 10% от суммы;
- корректный заказ добавляется в репозиторий (InsertAsync) ровно один раз;
- CancellationToken передаётся в InsertAsync без изменений;
- сохранение не удалось (IUnitOfWork.Result.Ok == false) → Operation с Ok == false и Error == "Не удалось сохранить заказ".

Обязательно проверь граничные значения: 9 999, 10 000, 49 999, 50 000.
Ожидаемые значения скидки указывай числами, не вычисляй их формулой.
Order и OrderItem не мокай — создавай настоящие объекты.

Файл: tests/Shop.Application.UnitTests/Orders/OrderServiceTests.cs.
Конвенции — раздел «Тестирование» в CLAUDE.md.
После генерации запусти dotnet test и покажи результат.

Обратите внимание: в промпте нет ни слова про AAA, именование и AutoFixture. Всё это общие правила проекта, и в каждом промпте им не место. Они живут в файле инструкций, о котором пойдёт речь в следующем разделе. В промпте остаётся только то, что относится к конкретной задаче: объект, спецификация, границы.

Постоянные инструкции агента

Если каждый раз писать в промпте «используй xUnit, Moq и AutoFixture, называй тесты так-то, не мокай сущности», через неделю вы начнёте это пропускать. Агент тут же вернётся к своим привычкам по умолчанию. Правила, общие для всего проекта, нужно записать один раз в файл, который агент читает автоматически.

В Claude Code это файл CLAUDE.md в корне репозитория. У других агентов есть свои аналоги:

Агент Файл инструкций
Claude Code CLAUDE.md
GitHub Copilot .github/copilot-instructions.md
Cursor .cursor/rules/*.mdc
Codex и другие AGENTS.md

AGENTS.md постепенно становится общим стандартом. Если в команде используют разных агентов, удобно держать правила в нём, а в CLAUDE.md просто подключить его строкой @AGENTS.md: Claude Code поддерживает импорт файлов через @.

Раздел «Тестирование» в CLAUDE.md

Вот пример раздела, который закрывает большую часть типовых проблем:

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

### Стек
- xUnit, Moq, AutoFixture (+ AutoFixture.AutoMoq, AutoFixture.Xunit2)
- Проверки — стандартный Assert из xUnit, без сторонних assertion-библиотек
- Результаты операций — Operation<T, TError> из Calabonga.Results
  (namespace Calabonga.OperationResults)
- Доступ к данным — IUnitOfWork и IRepository<T> из Calabonga.UnitOfWork

### Структура
- Тесты лежат в tests/<Проект>.UnitTests и повторяют структуру папок src
- Один тестовый класс на один тестируемый класс: OrderService → OrderServiceTests

### Правила
- Имя теста: Метод_Условие_ОжидаемыйРезультат
- Тело теста разделено комментариями // Arrange, // Act, // Assert
- SUT и его зависимости создаёт AutoFixture через [AutoMoqData];
  мок, который нужно настроить или проверить, получаем через [Frozen] Mock<T>
- Для сервисов на IUnitOfWork используем UnitOfWorkCustomization<TEntity>
  и атрибуты на её основе (например, [OrderAutoData] для Order).
  GetRepository<T>() и Result вручную в тестах не настраиваем
- IUnitOfWork.SaveChangesAsync() не бросает исключений: ошибку сохранения
  моделируем через IUnitOfWork.Result с заполненным Exception
- Мокаем только внешние зависимости: IUnitOfWork, IRepository<T>, HTTP-клиенты,
  шину сообщений. Доменные сущности и value objects создаём настоящие
- Однотипные сценарии с разными входными данными — [Theory] + [InlineAutoMoqData]
  (или [InlineOrderAutoData] и аналоги)
- Значения decimal в атрибутах передаём как int или double и приводим в тесте
- Ожидаемые значения — литералы из требований, не формулы из кода
- Для Operation всегда сначала проверяем Ok, затем Result или Error.
  Assert.NotNull для Operation не используем: это структура
- Verify используем только тогда, когда вызов зависимости и есть ожидаемое поведение
- Время — только через TimeProvider, в тестах — FakeTimeProvider
- Приватные методы напрямую не тестируем

### Если тест падает
- НЕ меняй ожидаемое значение, чтобы тест прошёл
- Сначала сообщи: какой тест упал, что ожидалось, что получено
  и в чём, по-твоему, ошибка — в тесте или в коде

Атрибуты AutoFixture

Правила ссылаются на атрибуты [AutoMoqData] и [InlineAutoMoqData]. Их стоит один раз добавить в тестовый проект, чтобы агент не изобретал их заново в каждом файле:

public sealed class AutoMoqDataAttribute() : AutoDataAttribute(
    () => new Fixture().Customize(new AutoMoqCustomization()));

public sealed class InlineAutoMoqDataAttribute(params object[] values)
    : InlineAutoDataAttribute(new AutoMoqDataAttribute(), values);

AutoMoqCustomization заставляет AutoFixture подставлять моки Moq вместо интерфейсов. Поэтому SUT можно просто принять параметром теста, и конструктор со всеми зависимостями соберётся сам. Шаблонного кода с new Mock<...>() и ручным вызовом конструктора становится заметно меньше.

Кастомизация для Calabonga.UnitOfWork

С IUnitOfWork одних атрибутов мало. Сервис вызывает GetRepository<Order>() и читает Result, а пустой мок вернёт там null. Если ничего не сделать, агент в каждом тесте будет заново писать те четыре строки настройки, которые вы видели в «плохом» тесте. Правильнее один раз оформить это как кастомизацию (customization) AutoFixture:

public sealed class UnitOfWorkCustomization<TEntity> : ICustomization
    where TEntity : class
{
    public void Customize(IFixture fixture)
    {
        var repository = fixture.Freeze<Mock<IRepository<TEntity>>>();
        var unitOfWork = fixture.Freeze<Mock<IUnitOfWork>>();

        unitOfWork
            .Setup(x => x.GetRepository<TEntity>(It.IsAny<bool>()))
            .Returns(repository.Object);

        // по умолчанию сохранение проходит успешно
        unitOfWork
            .Setup(x => x.Result)
            .Returns(new SaveChangesResult());
    }
}

public sealed class OrderAutoDataAttribute() : AutoDataAttribute(
    () => new Fixture()
        .Customize(new AutoMoqCustomization())
        .Customize(new UnitOfWorkCustomization<Order>()));

public sealed class InlineOrderAutoDataAttribute(params object[] values)
    : InlineAutoDataAttribute(new OrderAutoDataAttribute(), values);

Моки IUnitOfWork и IRepository<Order> здесь «заморожены» (frozen). Поэтому тест, который принимает [Frozen] Mock<IRepository<Order>> или [Frozen] Mock<IUnitOfWork>, получает те же самые экземпляры, что и OrderService.

Обратите внимание: это знание о вашей инфраструктуре, а не об xUnit или Moq. Агент не может его угадать, зато может прочитать в CLAUDE.md. Именно поэтому в разделе «Тестирование» есть отдельные строки про UnitOfWorkCustomization и про то, что SaveChangesAsync() не бросает исключений.

Разрешения на запуск

Чтобы агент мог сам запускать сборку и тесты, не спрашивая каждый раз подтверждения, добавьте разрешения в .claude/settings.json:

{
  "permissions": {
    "allow": [
      "Bash(dotnet build:*)",
      "Bash(dotnet test:*)"
    ]
  }
}

Процесс как skill

Когда процесс устоится, его можно оформить как skill: файл .claude/skills/unit-tests/SKILL.md. Он вызывается командой /unit-tests и описывает не правила оформления (они уже есть в CLAUDE.md), а порядок работы:

---
name: unit-tests
description: Генерация unit-тестов для указанного класса по спецификации.
  Используй, когда просят написать или дополнить unit-тесты.
---

Тестируемый объект: $ARGUMENTS

1. Прочитай класс и его зависимости.
2. Если спецификация поведения не передана — спроси её. Не выводи требования из кода.
3. Составь таблицу сценариев: условие → ожидаемый результат.
   Отдельно перечисли граничные значения. Покажи таблицу и дождись подтверждения.
4. Сгенерируй тесты по утверждённой таблице, соблюдая раздел «Тестирование» в CLAUDE.md.
5. Запусти dotnet test. При падениях действуй по правилу «Если тест падает».
6. Выведи итог: сколько тестов, какие сценарии покрыты, что не покрыто.


Три слоя: правила проекта, процесс и конкретная задача

В итоге получается три слоя. CLAUDE.md отвечает на вопрос «как у нас принято». Skill отвечает на вопрос «в каком порядке работаем». Промпт отвечает на вопрос «что тестируем сейчас». Каждый слой меняется со своей скоростью, и ни один не дублирует другой.

Сначала сценарии, потом код

Самый полезный приём из всей статьи — разделить генерацию тестов на два шага. Вы уже видели его в skill выше, теперь разберём, почему он работает.


Человек проверяет сценарии, а не код тестов

Шаг первый: агент выдаёт таблицу сценариев. Не код, а список «условие → ожидаемый результат»:

№ Условие Ожидаемый результат
1 Заказ без позиций Ошибка «Пустой заказ», InsertAsync не вызван
2 Сумма 9 999 Скидка 0
3 Сумма 10 000 Скидка 500
4 Сумма 49 999 Скидка 2 499,95
5 Сумма 50 000 Скидка 5 000
6 Корректный заказ InsertAsync вызван один раз с этим заказом
7 Передан CancellationToken Тот же токен передан в InsertAsync
8 IUnitOfWork.Result.Ok == false Ошибка «Не удалось сохранить заказ»

Шаг второй: вы правите таблицу, и только потом агент пишет код.

Почему это работает? Проверить таблицу из восьми строк можно за минуту, а проверить тридцать тестов на двести строк — нет. На этапе таблицы сразу видно, что агент забыл границу 49 999 или решил, что скидки суммируются. Ошибка в понимании требований исправляется до того, как она превратилась в код.

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

По этой таблице агент генерирует, например, такие тесты:

public class OrderServiceTests
{
    [Theory, OrderAutoData]
    public async Task PlaceOrderAsync_EmptyOrder_ReturnsFailureAndDoesNotInsert(
        [Frozen] Mock<IRepository<Order>> repository,
        OrderService sut)
    {
        // Arrange
        var order = new Order([]);

        // Act
        var result = await sut.PlaceOrderAsync(order, CancellationToken.None);

        // Assert
        Assert.False(result.Ok);
        Assert.Equal("Пустой заказ", result.Error);
        repository.Verify(
            x => x.InsertAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()),
            Times.Never);
    }

    [Theory, OrderAutoData]
    public async Task PlaceOrderAsync_SaveFailed_ReturnsFailure(
        [Frozen] Mock<IUnitOfWork> unitOfWork,
        OrderService sut)
    {
        // Arrange
        var order = new Order([new OrderItem("SKU-1", price: 1_000m, quantity: 1)]);
        unitOfWork
            .Setup(x => x.Result)
            .Returns(new SaveChangesResult { Exception = new InvalidOperationException() });

        // Act
        var result = await sut.PlaceOrderAsync(order, CancellationToken.None);

        // Assert
        Assert.False(result.Ok);
        Assert.Equal("Не удалось сохранить заказ", result.Error);
    }

    [Theory]
    [InlineOrderAutoData(9_999, 0)]
    [InlineOrderAutoData(10_000, 500)]
    [InlineOrderAutoData(49_999, 2_499.95)]
    [InlineOrderAutoData(50_000, 5_000)]
    public async Task PlaceOrderAsync_TotalAtThreshold_AppliesExpectedDiscount(
        int total,
        double expectedDiscount,
        OrderService sut)
    {
        // Arrange
        var order = new Order([new OrderItem("SKU-1", price: total, quantity: 1)]);

        // Act
        var result = await sut.PlaceOrderAsync(order, CancellationToken.None);

        // Assert
        Assert.True(result.Ok);
        Assert.Equal((decimal)expectedDiscount, result.Result.Discount);
    }
}

Сравните с тестом из раздела «Почему «напиши тесты» не работает». Ожидаемые значения — литералы. Границы перечислены явно. По имени теста понятно, что сломалось. Ветка с ошибкой сохранения теперь проверена отдельным тестом. Ни в одном тесте нет ручной настройки GetRepository<Order>(): это сделала кастомизация. А в тесте скидки моки не упоминаются вовсе, потому что проверять их в этом сценарии незачем.

Небольшое техническое замечание. Тип decimal нельзя передать в атрибут C#, поэтому значения приходят как int и double и приводятся внутри теста. Это правило уже записано в CLAUDE.md, иначе агент будет каждый раз заново натыкаться на ошибку компиляции.

Цикл «сгенерировал → запустил → исправил»

Агент, который умеет запускать dotnet test, гораздо полезнее агента, который только пишет код. Он сам видит ошибки компиляции, падения и исправляет их, пока всё не соберётся и не позеленеет. Разрешения на запуск мы уже дали в разделе «Постоянные инструкции агента».


Падение теста — повод остановиться и спросить, а не подогнать assert

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

  1. исправить тест, если ошибка в тесте;
  2. изменить ожидаемое значение, если ошибка в коде.

Второй вариант выглядит как исправление, но на деле закрепляет баг. Представьте, что в сервисе вместо >= 10_000m написано > 10_000m. Тест для суммы 10 000 ожидает скидку 500 и падает. Агент видит, что код вернул 0, «исправляет» ожидание на 0 и рапортует: все тесты зелёные. Тест, который должен был поймать ошибку, теперь её охраняет.

Именно для этого в разделе «Тестирование» есть правило «Если тест падает». Агент не меняет ожидаемое значение молча. Он сообщает, что упало, что ожидалось и что получено, и высказывает предположение, где ошибка. Решение принимаете вы. Так падающий тест превращается из досадной помехи в то, ради чего тесты и пишутся: в найденный дефект.

Пара практических советов для этого цикла:

  • Ограничьте область запуска. dotnet test --filter "FullyQualifiedName~OrderServiceTests" работает быстрее полного прогона и не засоряет контекст агента чужими падениями.
  • Просите итоговую сводку. Сколько тестов добавлено, какие сценарии из таблицы покрыты, какие нет. Отсутствующий сценарий из сводки заметить проще, чем из кода.

Типичные ошибки агента

Даже с хорошими инструкциями агент время от времени возвращается к своим привычкам по умолчанию. Вот ошибки, которые встречаются чаще всего, и как их предупредить.

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

Проверка вызовов вместо результата. Verify на каждый вызов каждой зависимости. Такой тест проверяет не поведение, а реализацию, и ломается при любом рефакторинге. Проверка взаимодействия (interaction testing) уместна, когда вызов и есть результат: «заказ сохранён», «заказ не сохранён», «сообщение отправлено в шину». Во всех остальных случаях проверяем состояние (state testing), то есть возвращённый результат.

Недетерминизм. Если код использует DateTime.Now, тест будет зависеть от времени запуска. Лечится это на стороне кода, а не теста: время внедряется через TimeProvider, а в тестах используется FakeTimeProvider из пакета Microsoft.Extensions.TimeProvider.Testing:

var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 11, 27, 10, 0, 0, TimeSpan.Zero));
fixture.Inject<TimeProvider>(timeProvider);

Если агент видит в коде DateTime.Now, пусть сообщит об этом, а не пытается обойти проблему в тесте.

Копипаста вместо параметризации. Пять одинаковых [Fact], которые различаются одной цифрой. Правило про [Theory] + [InlineAutoMoqData] в CLAUDE.md решает это почти полностью.

Тесты на фреймворк. «Проверим, что AutoFixture создал объект» или «что List.Add добавил элемент». Такие тесты проверяют не ваш код, а чужой, который уже протестирован. Достаточно строки в инструкциях: «Не тестируй поведение фреймворков и библиотек».

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

Как проверить, что тесты действительно тестируют

Тесты сгенерированы, всё зелёное. Как понять, что они чего-то стоят?

Покрытие кода

Первое, что приходит в голову, — покрытие кода (code coverage). Собирается оно стандартно, через coverlet:

dotnet test --collect:"XPlat Code Coverage"

Покрытие полезно, чтобы найти непокрытый код. Но о качестве покрытого кода оно не говорит ничего. Тест из раздела «Почему «напиши тесты» не работает» даёт почти 100% покрытия метода PlaceOrderAsync, при этом не ловит ни одной реальной ошибки. Строка выполнена — это не то же самое, что строка проверена.

Мутационное тестирование

Гораздо честнее мутационное тестирование (mutation testing). Идея простая. Инструмент вносит в код маленькие изменения, мутанты: меняет >= на >, + на -, удаляет вызов метода. После каждого изменения он прогоняет тесты. Если тесты упали, мутант «убит», и это хорошо. Если тесты остались зелёными, мутант «выжил», а значит, такую ошибку тесты не заметили бы.

Для .NET есть Stryker.NET:

dotnet tool install -g dotnet-stryker
cd tests/Shop.Application.UnitTests
dotnet stryker

Для нашего примера всё наглядно. Stryker заменит >= 10_000m на > 10_000m. Набор тестов без граничного значения 10 000 этого не заметит, и мутант выживет. Набор с [InlineOrderAutoData(10_000, 500)] его убьёт. Это та же ошибка, которую мы разбирали в разделе «Цикл «сгенерировал → запустил → исправил»», только теперь её находит инструмент, а не случай.

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

Чек-лист ревью сгенерированных тестов

Перед тем как принять тесты от агента, пройдитесь по короткому списку:

  •  Ожидаемые значения — литералы, а не формулы из кода
  •  Каждый порог из требований проверен до, на и после границы
  •  Имя каждого теста описывает сценарий и ожидаемый результат
  •  Моки только для внешних зависимостей
  •  Verify только там, где вызов и есть проверяемое поведение
  •  Нет DateTime.Now, Guid.NewGuid() и случайных данных без контроля
  •  Однотипные сценарии объединены в [Theory]
  •  Ни одно ожидаемое значение не было изменено, чтобы тест прошёл

Заключение

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

Коротко о том, что стоит сделать:

  1. Записать правила тестирования один раз — в CLAUDE.md или AGENTS.md.
  2. Добавить в тестовый проект атрибуты [AutoMoqData] и [InlineAutoMoqData], а для сервисов на IUnitOfWork — кастомизацию UnitOfWorkCustomization<TEntity>.
  3. Разрешить агенту запуск dotnet build и dotnet test.
  4. В промпте передавать спецификацию и граничные значения, а не просто имя класса.
  5. Сначала утверждать таблицу сценариев, потом генерировать код.
  6. Запретить агенту менять ожидаемые значения без вашего решения.
  7. Проверять результат мутационным тестированием, а не только покрытием.

И итоговый шаблон промпта, который можно взять как отправную точку:

Напиши unit-тесты для <Класс>.<Метод> (<путь к файлу>).

Спецификация (тесты пишем по ней, а не по коду):
- <условие> → <ожидаемый результат>;
- ...

Граничные значения: <список>.
Сначала покажи таблицу сценариев и дождись подтверждения.
Конвенции — раздел «Тестирование» в CLAUDE.md.

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

Использованные сборки

Сборка Версия Назначение
Calabonga.Results 2.0.1 Результат операции Operation<T, TError> (Result Pattern)
Calabonga.UnitOfWork 10.0.3 Unit of Work и репозитории для EntityFrameworkCore
xunit 2.9.3 Тестовый фреймворк
Moq 4.21.0 Моки внешних зависимостей
AutoFixture 4.18.1 Генерация тестовых данных и SUT
AutoFixture.AutoMoq 4.18.1 Автоматическая подстановка моков Moq
AutoFixture.Xunit2 4.18.1 Атрибуты [AutoData] и [InlineAutoData] для xUnit
Microsoft.Extensions.TimeProvider.Testing 10.10.0 FakeTimeProvider для детерминированного времени
coverlet.collector 10.1.0 Сбор покрытия кода
dotnet-stryker 5.0.0 Мутационное тестирование (глобальный инструмент)

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

Загрузка...

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

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

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

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

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

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