12k
All articles

Нужен ли TDD, когда код пишут агенты?

Узнайте, как разработка через тестирование помогает контролировать ИИ-агентов: сначала пишите тесты Vitest, защищайте их и проверяйте код в CI.

OpenReplay Team
OpenReplay Team
Нужен ли TDD, когда код пишут агенты?

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

Большинство разработчиков, поручающих реализацию 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 с обязательным ревью от владельца кода гарантирует, что любое изменение в путях с тестами одобрит конкретный человек.

Заблокируйте или запретите агенту редактировать тесты.

Пусть «готово» определяет 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, её не срабатывает, а агент так и не редактирует спецификацию.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.