TDD с AI: Claude пишет тесты первым, потом реализацию
Что такое TDD с AI?
TDD с AI — это подход к разработке через тестирование, при котором AI-ассистент (например, Claude Code) сначала генерирует failing-тесты на основе текстового описания модуля, а затем пишет реализацию под них. Это устраняет главный барьер классического TDD — необходимость проектировать API и edge cases до написания кода.
TL;DR
- -TDD буксует потому что тест требует знания API до реализации — AI снимает этот барьер
- -Workflow: описываешь модуль в промпте → Claude генерирует failing тесты → Claude реализует по ним → рефакторинг
- -Ключевое правило промпта: всегда добавляй «Do not write the implementation» — иначе Claude пропускает TDD
- -AI-тесты покрывают больше edge cases чем написанные вручную: null, пустые массивы, конкурентность, таймауты
- -Тестовый файл — это спецификация: ревьюи его до реализации, а не после
По данным State of Testing Report 2024, только 23% команд практикуют TDD регулярно. Причина не в том, что разработчики не знают о пользе подхода. Причина в трении: написать тест до реализации — значит продумать API, edge cases и контракт модуля до написания первой строки логики. Это когнитивно дорого.
AI-ассистент снимает этот барьер. Claude Code превращает test-first workflow из дисциплинарного упражнения в естественный способ разработки. Тесты становятся спецификацией, а реализация генерируется по ним.
Почему TDD буксует без AI
Классический цикл TDD: Red → Green → Refactor. Написать падающий тест, сделать его зелёным минимальным кодом, отрефакторить. На практике цикл ломается на первом шаге.
Разработчик садится писать тест для нового модуля. Чтобы написать тест, нужно определить интерфейс. Чтобы определить интерфейс, нужно понять архитектуру. Чтобы понять архитектуру, нужно хотя бы мысленно написать реализацию. Круг замкнулся.
Три конкретных проблемы:
Проектирование API на пустом месте. Тест требует вызвать функцию, которой ещё нет. Какие аргументы она принимает? Что возвращает? Какие ошибки бросает? Без прототипа реализации эти вопросы висят в воздухе.
Edge cases вслепую. Хороший тест покрывает граничные случаи. Но граничные случаи видны только когда понимаешь реализацию. Пустой массив, null, таймаут, concurrent access — всё это проще увидеть, глядя на код, а не на пустой файл теста.
Мотивация. Тест, который падает с “function not found”, не даёт ощущения прогресса. Разработчик переключается на реализацию “чтобы быстро проверить идею” — и больше к test-first не возвращается.
AI снимает все три барьера. Claude генерирует тест-спецификацию на основе описания задачи, предлагает edge cases на основе типов данных и паттернов, создаёт реализацию строго по контракту тестов.
Workflow: test-first с Claude Code
Рабочий процесс состоит из четырёх шагов. Каждый шаг — конкретный промпт и ожидаемый результат.
Шаг 1. Спецификация через тест
Промпт Claude Code:
Напиши тесты для модуля parseSchedule.
Вход: строка с расписанием в свободной форме ("каждый понедельник в 10:00", "daily at 9am").
Выход: объект { dayOfWeek?: number, hour: number, minute: number, recurring: boolean }.
Покрой: валидные строки на EN/RU, невалидный вход, пустая строка, null.
Используй vitest. Не пиши реализацию.
Claude генерирует тестовый файл:
import { describe, it, expect } from 'vitest';
import { parseSchedule } from './parseSchedule';
describe('parseSchedule', () => {
describe('english input', () => {
it('parses "every Monday at 10:00"', () => {
const result = parseSchedule('every Monday at 10:00');
expect(result).toEqual({
dayOfWeek: 1,
hour: 10,
minute: 0,
recurring: true,
});
});
it('parses "daily at 9am"', () => {
const result = parseSchedule('daily at 9am');
expect(result).toEqual({
hour: 9,
minute: 0,
recurring: true,
});
});
it('parses "Friday 14:30"', () => {
const result = parseSchedule('Friday 14:30');
expect(result).toEqual({
dayOfWeek: 5,
hour: 14,
minute: 30,
recurring: false,
});
});
});
describe('russian input', () => {
it('parses "каждый понедельник в 10:00"', () => {
const result = parseSchedule('каждый понедельник в 10:00');
expect(result).toEqual({
dayOfWeek: 1,
hour: 10,
minute: 0,
recurring: true,
});
});
it('parses "ежедневно в 9:00"', () => {
const result = parseSchedule('ежедневно в 9:00');
expect(result).toEqual({
hour: 9,
minute: 0,
recurring: true,
});
});
});
describe('edge cases', () => {
it('throws on empty string', () => {
expect(() => parseSchedule('')).toThrow('Invalid schedule format');
});
it('throws on null', () => {
expect(() => parseSchedule(null as any)).toThrow();
});
it('throws on unrecognized format', () => {
expect(() => parseSchedule('maybe sometime')).toThrow(
'Invalid schedule format'
);
});
it('handles extra whitespace', () => {
const result = parseSchedule(' daily at 9am ');
expect(result).toEqual({
hour: 9,
minute: 0,
recurring: true,
});
});
});
});
Все тесты падают — parseSchedule не существует. Это нормально: Red phase завершена.
Что произошло: тесты зафиксировали контракт. Входной тип, выходная структура, поведение на граничных случаях, поддерживаемые форматы. Этот файл уже полноценная спецификация модуля.
Шаг 2. Минимальная реализация
Промпт:
Напиши реализацию parseSchedule, которая проходит все тесты в parseSchedule.test.ts.
Минимальный код. Без лишних фич. Только то, что требуют тесты.
Claude читает тестовый файл, извлекает ожидания и генерирует реализацию:
const EN_DAYS: Record<string, number> = {
sunday: 0, monday: 1, tuesday: 2, wednesday: 3,
thursday: 4, friday: 5, saturday: 6,
};
const RU_DAYS: Record<string, number> = {
воскресенье: 0, понедельник: 1, вторник: 2, среда: 3,
четверг: 4, пятница: 5, суббота: 6,
};
interface Schedule {
dayOfWeek?: number;
hour: number;
minute: number;
recurring: boolean;
}
export function parseSchedule(input: string): Schedule {
if (!input || typeof input !== 'string') {
throw new Error('Invalid schedule format');
}
const normalized = input.trim().replace(/\s+/g, ' ').toLowerCase();
if (!normalized) throw new Error('Invalid schedule format');
const recurring =
normalized.startsWith('every') ||
normalized.startsWith('каждый') ||
normalized.startsWith('каждая') ||
normalized.startsWith('каждое') ||
normalized.includes('daily') ||
normalized.includes('ежедневно');
const timeMatch = normalized.match(/(\d{1,2}):(\d{2})/);
const amPmMatch = normalized.match(/(\d{1,2})(am|pm)/);
let hour: number;
let minute: number;
if (timeMatch) {
hour = parseInt(timeMatch[1], 10);
minute = parseInt(timeMatch[2], 10);
} else if (amPmMatch) {
hour = parseInt(amPmMatch[1], 10);
minute = 0;
if (amPmMatch[2] === 'pm' && hour !== 12) hour += 12;
if (amPmMatch[2] === 'am' && hour === 12) hour = 0;
} else {
throw new Error('Invalid schedule format');
}
const allDays = { ...EN_DAYS, ...RU_DAYS };
let dayOfWeek: number | undefined;
for (const [name, num] of Object.entries(allDays)) {
if (normalized.includes(name)) {
dayOfWeek = num;
break;
}
}
const result: Schedule = { hour, minute, recurring };
if (dayOfWeek !== undefined) result.dayOfWeek = dayOfWeek;
return result;
}
Запуск тестов: все зелёные. Green phase завершена.
Шаг 3. Рефакторинг под защитой тестов
Промпт:
Отрефактори parseSchedule. Извлеки парсинг времени и дней в отдельные функции.
Тесты менять нельзя. Все должны оставаться зелёными.
Claude выполняет рефакторинг и запускает тесты после каждого изменения. Тесты — страховочная сетка: если рефакторинг что-то сломал, это видно мгновенно.
Шаг 4. Расширение через новые тесты
Нужно добавить поддержку формата “every 2 hours”? Сначала тест:
it('parses interval "every 2 hours"', () => {
const result = parseSchedule('every 2 hours');
expect(result).toEqual({
intervalHours: 2,
recurring: true,
});
});
Тест падает. Промпт Claude: “Сделай этот тест зелёным, не ломая существующие.” Цикл повторяется.
Промпты для Claude Code: шаблоны test-first
Генерация тестов для нового модуля
Напиши тесты для [модуль/функция].
Контекст: [что делает модуль, какие данные обрабатывает].
Входные данные: [типы, примеры].
Ожидаемый выход: [структура, типы].
Edge cases: [конкретные случаи или "предложи сам"].
Фреймворк: [vitest/jest/pytest].
НЕ пиши реализацию.
Генерация реализации по тестам
Напиши реализацию [модуль], которая проходит все тесты в [файл.test.ts].
Минимальный код. Без лишних абстракций. Только то, что требуют тесты.
Расширение покрытия
Проанализируй [модуль] и его тесты.
Какие сценарии не покрыты? Добавь тесты для пропущенных edge cases.
Не меняй существующие тесты.
Рефакторинг
Отрефактори [модуль]. Тесты менять нельзя.
Цель: [читаемость / производительность / расширяемость].
Запусти тесты после изменений.
Сравнение: test-first vs code-first с AI
На практике разработчики используют AI двумя способами: просят сгенерировать код, потом тесты (code-first), или наоборот (test-first). Разница в результатах существенная.
| Критерий | Code-first + AI | Test-first + AI |
|---|---|---|
| Качество тестов | Тесты подгоняются под реализацию, пропускают баги | Тесты отражают требования, ловят реальные проблемы |
| Покрытие edge cases | AI тестирует то, что написал, а не то, что нужно | Edge cases определяются из спецификации |
| Рефакторинг | Страшно менять: тесты могут сломаться | Безопасно: тесты привязаны к контракту, не к реализации |
| Скорость | Быстрее на старте | Быстрее в долгосрочной перспективе |
| Качество API | API формируется стихийно | API продумывается через тесты |
Ключевое отличие: при code-first AI генерирует тесты, которые проверяют то, что код делает. При test-first AI генерирует тесты, которые проверяют то, что код должен делать. Разница становится критичной при рефакторинге и расширении.
Реальный пример: валидатор с LLM-as-Judge
Задача из практики: модуль валидации AI-ответов через LLM-as-Judge. Нужно проверять, что ответ LLM соответствует критериям качества.
Тесты пишутся первыми:
describe('ResponseValidator', () => {
it('accepts response meeting all criteria', async () => {
const result = await validator.validate({
response: 'Paris is the capital of France.',
criteria: {
factual: true,
maxLength: 100,
language: 'en',
},
});
expect(result.passed).toBe(true);
expect(result.scores.factual).toBeGreaterThan(0.8);
});
it('rejects response failing factual check', async () => {
const result = await validator.validate({
response: 'Berlin is the capital of France.',
criteria: { factual: true },
});
expect(result.passed).toBe(false);
expect(result.failures).toContain('factual');
});
it('handles LLM provider timeout', async () => {
const result = await validator.validate({
response: 'test',
criteria: { factual: true },
timeout: 100,
});
expect(result.passed).toBe(false);
expect(result.error).toBe('timeout');
});
});
Тесты определили: интерфейс validate(), структуру критериев, формат результата, поведение при таймауте. Это полная спецификация. Claude реализует модуль строго по контракту, включая обработку ошибок, которую легко забыть при code-first подходе.
Если в проекте уже работает multi-agent code review, тесты проходят ревью вместе с кодом. Агенты проверяют, что тесты покрывают все заявленные сценарии, а реализация не выходит за рамки контракта.
Паттерны и антипаттерны
Работает хорошо
Один тест — одно поведение. Claude генерирует точную реализацию, когда каждый тест проверяет ровно один аспект. “Парсит дату в формате ISO” — хорошо. “Парсит дату и валидирует и форматирует” — плохо.
Типизированные ожидания. Вместо expect(result).toBeTruthy() — expect(result).toEqual({ ... }). Чем точнее ожидание, тем точнее реализация.
Тесты как документация. Название теста описывает поведение: it('returns empty array when no items match filter'). Claude использует его для понимания intent.
Не работает
Тесты на внутреннюю реализацию. expect(cache.store).toHaveBeenCalledWith('key', 'value') — привязка к реализации. При рефакторинге тест сломается, хотя поведение не изменилось.
Слишком много моков. Если для теста нужно замокать пять зависимостей, проблема в архитектуре, а не в тестах. Claude создаст рабочий мок, но реализация будет хрупкой.
Генерация тестов и кода одним промптом. “Напиши функцию и тесты к ней” — это code-first с иллюзией test-first. Тесты будут подогнаны под реализацию. Всегда разделять на два шага.
TDD для инфраструктурного кода
Test-first работает не только для бизнес-логики. Для инфраструктурных модулей, например circuit breaker для edge functions, тесты особенно ценны.
describe('CircuitBreaker', () => {
it('stays closed after successful calls', async () => {
const breaker = new CircuitBreaker({ failureThreshold: 3 });
await breaker.call(() => Promise.resolve('ok'));
await breaker.call(() => Promise.resolve('ok'));
expect(breaker.state).toBe('closed');
});
it('opens after reaching failure threshold', async () => {
const breaker = new CircuitBreaker({ failureThreshold: 2 });
await ignoreError(() => breaker.call(() => Promise.reject('fail')));
await ignoreError(() => breaker.call(() => Promise.reject('fail')));
expect(breaker.state).toBe('open');
});
it('rejects calls immediately when open', async () => {
const breaker = openCircuitBreaker();
await expect(breaker.call(() => Promise.resolve('ok')))
.rejects.toThrow('Circuit is open');
});
it('transitions to half-open after cooldown', async () => {
vi.useFakeTimers();
const breaker = openCircuitBreaker({ cooldownMs: 5000 });
vi.advanceTimersByTime(5000);
expect(breaker.state).toBe('half-open');
vi.useRealTimers();
});
});
State machine circuit breaker полностью описывается через тесты: переходы между состояниями, пороги, таймеры. Claude генерирует реализацию, которая корректно обрабатывает все переходы, потому что каждый переход зафиксирован в тесте.
Метрики: что измерять
Defect escape rate. Сколько багов проходит мимо тестов в продакшен. При test-first этот показатель снижается, потому что edge cases покрываются до написания кода.
Время на рефакторинг. С тестами, привязанными к контракту, рефакторинг безопасен. Без них каждое изменение требует ручной проверки.
Покрытие при первом проходе. При code-first подходе покрытие после первой итерации обычно 40-60%. При test-first — 80-90%, потому что тесты уже определяют все основные сценарии.
Количество итераций до зелёных тестов. С хорошо написанными тестами Claude проходит все тесты с первой генерации. Если требуется больше двух итераций, тесты слишком размыты или противоречивы.
С чего начать
Не нужно переводить весь проект на TDD за один день. Достаточно начать с одного нового модуля.
1. Выбрать изолированный модуль. Утилитная функция, валидатор, парсер. Модуль без тяжёлых зависимостей, где легко определить вход и выход.
2. Написать тесты промптом. Использовать шаблон из раздела выше. Описать входные данные, ожидаемый выход, edge cases. Дать Claude сгенерировать тестовый файл.
3. Убедиться, что тесты падают. Запустить тесты. Все должны быть красными. Если что-то зелёное без реализации — тест некорректен.
4. Сгенерировать реализацию. Отдельным промптом попросить Claude написать код, проходящий все тесты. Не добавлять “а ещё сделай X” — только то, что в тестах.
5. Запустить, отрефакторить, повторить. Зелёные тесты — рефакторинг. Новые требования — новые тесты. Цикл Red → Green → Refactor работает без трения, когда AI берёт на себя генерацию.
AI не заменяет TDD. AI делает TDD практичным. Когнитивная нагрузка на проектирование API через тесты падает, скорость цикла растёт, а качество контракта остаётся на уровне ручного проектирования.