Нужен ли TDD, когда код пишут агенты?
Узнайте, как разработка через тестирование помогает контролировать ИИ-агентов: сначала пишите тесты Vitest, защищайте их и проверяйте код в CI.
Да, и, пожалуй, даже больше, чем раньше, но уже как контракт, а не как ритуал. Когда реализацию пишет кодинг-агент, тесты, написанные заранее, становятся спецификацией: они определяют, что значит «готово», ещё до появления кода, и ловят агента, если он отчитывается о завершении задачи, которая на самом деле не выполнена.
Большинство разработчиков, поручающих реализацию Claude Code, Cursor или GitHub Copilot, с этим уже сталкивались. Агент сообщает, что всё проходит, а потом фича ломается при первом же использовании. Или тестовый файл оказывается втихую переписан так, чтобы соответствовать новому коду.
В этой статье разобран test-first-процесс для работы с агентами: реальный файл Vitest, точная инструкция, которую к нему стоит приложить, способы, которыми агенты обходят тесты, и защитные механизмы, закрывающие эти лазейки.
Основные выводы
- Тесты, написанные до начала работы агента, служат исполняемой спецификацией: задача агента — добиться их прохождения, а не решать, что считать прохождением.
- Тест, написанный после кода, обычно описывает то, что код уже делает, поэтому он проходит по построению и ничего не проверяет.
- Сообщение агента о том, что все тесты проходят, — это лишь заявление. Доказательство — прогон CI на запушенном коммите.
- Если один дифф меняет и реализацию, и проверяющие её ассерты, значит, агент сам себе поставил оценку. Поэтому ревьюйте изменения тестов отдельно от изменений кода.
- Не используйте test-first для одноразовых прототипов и чисто визуальных правок, где ожидаемый результат невозможно описать, пока вы его не увидите.
Что такое red-green-refactor?
Red-green-refactor — это базовый цикл TDD: вы пишете падающий тест, описывающий одно поведение (red), затем пишете минимальный код, который заставляет его пройти (green). Когда весь набор тестов зелёный, вы приводите код в порядок, не меняя его поведения (refactor). Более подробное напоминание с примерами на React — в статье TDD vs BDD in React.
Как устроен процесс TDD с ИИ?
TDD с ИИ работает, когда человек пишет или утверждает падающие тесты, а агенту поручают добиться их прохождения, не изменяя их. Тесты содержат требования. Агент отвечает только за реализацию. В примерах ниже используется Vitest 5.
Начните с заглушки, чтобы тестовый файл импортировал что-то реально существующее:
// src/cart/applyCoupon.ts
export type Coupon =
| { code: string; kind: 'percent'; value: number }
| { code: string; kind: 'fixed'; valueCents: number };
export function applyCoupon(
subtotalCents: number,
code: string,
coupons: Coupon[],
): number {
throw new Error('not implemented');
}
Затем напишите спецификацию:
// src/cart/applyCoupon.test.ts
import { describe, it, expect } from 'vitest';
import { applyCoupon, type Coupon } from './applyCoupon';
const coupons: Coupon[] = [
{ code: 'SAVE10', kind: 'percent', value: 10 },
{ code: 'FIVEOFF', kind: 'fixed', valueCents: 500 },
];
describe('applyCoupon', () => {
it('takes a percentage off the subtotal', () => {
expect(applyCoupon(2000, 'SAVE10', coupons)).toBe(1800);
});
it('takes a fixed amount off the subtotal', () => {
expect(applyCoupon(2000, 'FIVEOFF', coupons)).toBe(1500);
});
it('never returns a negative total', () => {
expect(applyCoupon(300, 'FIVEOFF', coupons)).toBe(0);
});
it('matches codes case-insensitively', () => {
expect(applyCoupon(2000, 'save10', coupons)).toBe(1800);
});
it('rejects unknown codes', () => {
expect(() => applyCoupon(2000, 'BOGUS', coupons)).toThrow('Unknown coupon');
});
});
Поскольку заглушка существует и выбрасывает исключение, все пять тестов падают во время выполнения, а не из-за отсутствующего импорта. Это чистая red-фаза. Последний тест заслуживает отдельного внимания: заглушка действительно выбрасывает исключение, но его сообщение не совпадает с 'Unknown coupon', поэтому тест всё равно падает по правильной причине. Каждый тест фиксирует одно правило (процентная или фиксированная скидка, нижняя граница в ноль, обработка регистра), которое иначе агенту пришлось бы угадывать.
Затем передайте задачу агенту с явными ограничениями:
Implement applyCoupon in src/cart/applyCoupon.ts so every test in
src/cart/applyCoupon.test.ts passes.
Rules:
- Do not create, edit, rename, skip or delete any *.test.ts file.
- Do not add .skip, .only, or mocks of applyCoupon.
- If a test looks wrong, stop and explain why instead of changing it.
- When finished, run `npx vitest run src/cart` and paste the full output.
Строка «остановись и объясни» даёт агенту легитимный выход, помимо изменения теста. Вставленный вывод экономит вам один шаг, но ничего не доказывает. Другие приёмы составления промптов, которые хорошо вписываются в этот цикл, — в статье 5 Tips and Tricks for AI-Assisted Coding.
Что может пойти не так при test-first с агентами?
Агенты склонны искать кратчайший путь к зелёному прогону, и три таких пути полностью обходят спецификацию.
Редактирование или удаление тестов. Когда реализация даётся тяжело, изменить ожидание проще, чем ему соответствовать. Изменённый toBe(1800), новый .skip или исчезнувший тестовый файл — всё это даёт зелёный прогон, из которого убрано само требование.
Слабые ассерты. Тест, написанный после кода, обычно подтверждает то, что код делает, а не то, что он должен делать:
// Confirms the code ran, not that it's right
it('applies coupon', () => {
const result = applyCoupon(2000, 'SAVE10', coupons);
expect(result).toBeDefined();
expect(typeof result).toBe('number');
});
// Pins the behaviour
it('takes 10% off with SAVE10', () => {
expect(applyCoupon(2000, 'SAVE10', coupons)).toBe(1800);
});
Слабый тест с toBeDefined() пройдёт, даже если applyCoupon вернёт 0, 2000 или 999999. Именно поэтому важен порядок написания: тесты, которые агент пишет после собственного кода, как правило, похожи на первый вариант. Кроме того, агенты раздувают наборы тестов множеством подобных поверхностных проверок — этот паттерн разобран в статье the smoke tests agents keep writing.
Мок тестируемого кода. Если замокать тестируемый модуль, тест будет проверять мок и останется зелёным независимо от того, что делает реальная реализация:
import { vi } from 'vitest';
vi.mock('./applyCoupon', () => ({ applyCoupon: () => 1800 }));
// Every assertion now tests the mock.
Vitest поднимает (hoisting) vi.mock выше импортов, поэтому фабрика подменяет модуль ещё до загрузки реального кода. Настоящий applyCoupon так и не выполняется. Мокайте границы системы (сеть, хранилище, время), а не тот модуль, который специфицируете.
Какие защитные механизмы удерживают спецификацию неизменной?
Защитные механизмы работают, когда они механические. Инструкции снижают вероятность подмены тестов, а CI и ревью ловят то, что инструкции пропускают.
Ревьюйте диффы тестов отдельно. Просматривайте изменения в *.test.ts в рамках отдельного ревью, до реализации. На GitHub CODEOWNERS с обязательным ревью от владельца кода гарантирует, что любое изменение в путях с тестами одобрит конкретный человек.
Заблокируйте или запретите агенту редактировать тесты.
- Claude Code: добавьте в
.claude/settings.jsonправилоpermissions.deny, например"Edit(**/*.test.ts)". Запрещающие правила Edit распространяются на встроенные файловые инструменты Claude, а также на распознаваемые им файловые команды в Bash (например,sedиtee) и цели перенаправления вывода. Они не распространяются на скрипты и другие подпроцессы, которые записывают файлы опосредованно. Считайте это правило препятствием, а не гарантией. - Cursor: пропишите правило в project rules в
.cursor/rules. - GitHub Copilot: поместите его в
.github/copilot-instructions.md. И Cursor, и Copilot также читаютAGENTS.md.
Пусть «готово» определяет CI. Запускайте npx vitest run на запушенном коммите. В CI Vitest проваливает прогон, если находит .only, если только вы не передали --allowOnly или не задали allowOnly: true. .skip он не ловит, поэтому добавьте проверку диффа на ветках с реализацией:
changed=$(git diff --name-only origin/main...HEAD -- '*.test.ts' '*.spec.ts')
if [ -n "$changed" ]; then
echo "Test files changed on an implementation branch:"
echo "$changed"
exit 1
fi
В checkout должно быть достаточно истории, чтобы origin/main успешно разрешался.
Проверяйте, что ассерты тестируют поведение. Для каждого ассерта спросите себя: может ли неправильная реализация всё равно его пройти? Точные значения, выбрасываемые ошибки и видимый результат переживают рефакторинг. Счётчики вызовов внутренних хелперов и проверки типов — нет.
Даже у безупречного набора тестов есть предел. Прохождение юнит-тестов показывает, что код обрабатывает случаи, которые кто-то записал. Оно не показывает, что реальный пользователь сможет пройти весь сценарий в браузере, — и записи пользовательских сессий (session replay) по уже выпущенной фиче вполне могут показать сломанный чекаут при зелёном CI.
Когда TDD не стоит усилий?
Test-first обходится дороже, чем окупается, когда ожидаемый результат невозможно сформулировать заранее.
| Ситуация | Почему стоит отказаться от test-first |
|---|---|
| Одноразовые прототипы | Код нужен, чтобы ответить на вопрос, после чего его удалят |
| Визуальные правки UI (отступы, цвета, раскладка) | Правильность результата оценивается на глаз, поэтому проверять пока нечего |
Если прототип выжил, напишите тесты до того, как агент превратит его в продакшен-код.
Заключение
Когда код пишут агенты, спецификация — это то, что можете предоставить только вы, а тесты, написанные заранее, делают её исполняемой. В следующей задаче для агента сначала напишите падающий файл Vitest, дайте агенту инструкцию не редактировать тесты и мержите только после того, как пройдут и CI, и отдельное ревью диффа тестов.
Часто задаваемые вопросы
Может ли линтер поймать пропущенные тесты, добавленные агентом?
Да. В пакете @vitest/eslint-plugin есть правило vitest/no-disabled-tests, которое помечает test.skip, it.skip и describe.skip, и правило vitest/no-focused-tests, которое помечает .only. Рекомендуемая конфигурация лишь выдаёт предупреждение об отключённых тестах, поэтому установите для этого правила уровень 'error' и запускайте ESLint в CI. В проектах на Jest правила с такими же названиями предоставляет eslint-plugin-jest. Линтер может пропустить вызовы через алиасы, поэтому проверку git-диффа тоже стоит оставить.
Может ли падающие тесты написать ИИ-агент вместо человека?
Да, если написание тестов и написание реализации — это отдельные этапы. Пусть одна сессия агента подготовит черновик тестов для заглушки, затем вы сами проверите ассерты и закоммитите их до начала реализации. Сессия, которая пишет реализацию, должна видеть только тесты, которые ей запрещено менять. Если одна и та же сессия пишет и то и другое, тесты в итоге описывают её собственный код, а не требования.
Достаточно ли подробной письменной спецификации без предварительного написания тестов?
Нет. Текстовая спецификация или заглушка с doc-комментариями говорит агенту, что нужно построить, но ничто не сверяет результат с ней. Падающий тест нельзя удовлетворить правдоподобной на вид реализацией: он либо проходит, либо нет. Используйте и то и другое. Требования и ограничения поместите в промпт или файл инструкций, а обязательные случаи закодируйте в виде тестов, которые CI запускает на каждом коммите.
Что делать, если агент говорит, что тест неверен?
Сами проверьте объяснение агента и, если тест действительно неверен, исправьте его вне ветки с реализацией. Влейте исправленный тест в main отдельным коммитом или пул-реквестом, прошедшим ревью по CODEOWNERS, если вы их используете, а затем сделайте rebase ветки с реализацией. Проверка диффа сравнивает ветку с origin/main, поэтому исправление теста, уже попавшее в main, её не срабатывает, а агент так и не редактирует спецификацию.