Почему AI-код требует особого подхода к тестированию
Claude Code, Cursor 3.0, GitHub Copilot в 2026 году пишут код, который компилируется, проходит линтер и выглядит именно так, как должен выглядеть хороший код. Это и есть проблема.
Когда код написан руками, ты держишь в голове контракт каждой функции. Ты помнишь, что parseUserInput() бросает исключение на пустую строку, потому что сам это решил пять минут назад. При vibe coding этого не происходит. Ты описал задачу, получил 80 строк, пробежал глазами, увидел знакомые паттерны. Всё. Контракт нигде не зафиксирован, и в голове его нет.
Дальше начинается интересное. AI оптимизирует код под "выглядит правильно", а не под "работает правильно под нагрузкой в пятницу вечером". Три провала повторяются настолько регулярно, что я уже считаю их сигнатурой AI-кода.
Первый: тихое поглощение исключений. Сгенерированный код оборачивает вызов в try/catch, логирует ошибку в console.error и возвращает null. Тест проходит. В продакшне сервис молча возвращает пустой ответ, и никто не понимает почему.
Второй: граничные условия. AI видел миллионы примеров с "нормальными" данными. Пустой массив, null внутри объекта, строка из 10 000 символов, это места, где логика ломается тихо или не тихо, но всегда неожиданно.
Третий: race conditions в async-коде. Это самый коварный тип. Сгенерированный async-код часто выглядит корректно и работает корректно в тестах с одним запросом. Под реальной нагрузкой два параллельных вызова начинают делить состояние, которое не должны делить.
Отдельная история с тестами, которые генерирует Claude Code. Заметная часть автоматически сгенерированных юнит-тестов проверяет не логику, а моки. Тест утверждает, что fetchUser был вызван один раз с правильным аргументом. Это тест того, что мок работает как мок. Реальное поведение функции при этом не проверяется вообще.
Именно поэтому AI-код требует не просто "тоже писать тесты", а другой стратегии проверки с самого начала. И это не абстрактная осторожность: вайбкодинг в продакшене ломается по вполне конкретным сценариям, и большинство из них можно было поймать тестами раньше.

Код выглядит аккуратно, компилируется без ошибок, но внутри прячутся логические баги, которые тесты не поймают без целенаправленной проверки.
TDD наоборот: пишем тесты до того, как просим AI написать код
Классический TDD требует дисциплины: написать тест, увидеть красный, написать минимальный код, увидеть зелёный. С AI этот цикл можно вывернуть в другую сторону, и получится интереснее.
Идея простая. Ты пишешь failing tests, которые описывают контракт функции, а потом скармливаешь их в промпт вместе с задачей. AI получает не размытое «напиши функцию для парсинга ввода», а конкретные ожидания, зафиксированные в коде.
// Сначала пишешь контракт
describe('parseUserInput', () => {
it('возвращает null для пустой строки', () => {
expect(parseUserInput('')).toBeNull();
});
it('обрезает пробелы перед парсингом', () => {
expect(parseUserInput(' 42 ')).toBe(42);
});
it('бросает TypeError для не-строки', () => {
expect(() => parseUserInput(null)).toThrow(TypeError);
});
});
// Потом просишь AI реализовать parseUserInput под эти тесты
Промпт для Claude Code выглядит буквально так: «Вот тесты для функции parseUserInput. Реализуй функцию так, чтобы все тесты проходили. Не меняй тесты.» Последняя фраза критична. Без неё модель с ненулевой вероятностью начнёт редактировать сами тесты, когда не может с ходу выдать рабочую реализацию.
После того как получил код, запускаешь тесты. Не читаешь реализацию в поисках логики. Запускаешь тесты. Если все зелёные, смотришь код уже с позиции ревью, а не отладки. Если красные, возвращаешь вывод npx jest обратно в чат и просишь починить.
Почему это работает лучше, чем просто попросить AI написать функцию? Потому что у AI нет телепатии. Когда ты говоришь «парси пользовательский ввод», модель делает предположения: нужна ли trim, что возвращать для null, как обрабатывать дроби. Каждое предположение может не совпасть с тем, что ты имел в виду. Тесты убирают пространство для угадывания.
Три теста выше задают четыре решения сразу: пустая строка даёт null (а не 0, не '', не undefined), пробелы обрезаются до парсинга, null бросает именно TypeError (а не просто ошибку), и функция возвращает number, а не строку. AI вынужден реализовать именно это.
Есть одно честное ограничение. Подход работает только если ты сам понимаешь требования достаточно, чтобы записать их в виде тестов. Если задача размытая («сделай умный парсер»), то failing tests написать не получится, и AI-driven TDD здесь не поможет. Это не недостаток метода, это сигнал: требования нужно прояснить раньше, чем трогать клавиатуру.
Cursor в 2026 году умеет запускать тесты прямо в агентном режиме: ты даёшь тесты, он итерируется до зелёных без лишних промежуточных шагов. Claude Code делает то же самое через инструмент bash. В обоих случаях цикл «красный → зелёный» происходит внутри сессии, и тебе не нужно вручную копировать вывод туда-обратно.

Цикл TDD с AI-ассистентом: сначала пишешь падающий тест, потом формулируешь промпт, получаешь код и проверяешь, что тест прошёл.
Как читать AI-сгенерированный код перед тем, как писать тесты
Прежде чем писать первый describe, я трачу 10-15 минут на чтение того, что сгенерировал инструмент. Без этого шага тесты часто покрывают не баги, а иллюзию корректной работы.
Первое, что открываю: git diff --stat. Не сам diff, а статистику. Если я попросил Claude Code добавить валидацию в одну функцию, а --stat показывает изменения в восьми файлах, это сигнал остановиться. Cursor при многофайловом редактировании тихо правит соседние функции, и без полной картины изменений ты пишешь тесты для кода, который уже не тот, что был вчера. Потом смотрю сам diff целиком, не только нужный файл.
Дальше у меня есть короткий чеклист. Четыре пункта.
Пустые catch-блоки. Claude Code делает это почти всегда при первой генерации. Выглядит примерно так:
try {
await saveUser(data);
} catch (e) {
console.log(e);
}
Функция вернёт undefined, вызывающий код решит, что всё прошло нормально, пользователь не сохранится. Это не теоретическая проблема: реальный случай с SQL-миграцией, которая удалила прод-таблицу, начался именно с того, что обработка ошибок была проглочена.
Входные данные без проверки. AI предполагает, что аргументы пришли валидные. Функция получает userId, сразу идёт в базу, никакого if (!userId). Перед тестами отмечаю эти места явно и пишу тесты на null, undefined, пустую строку и число там, где ожидается строка.
Магические числа. if (status === 3) или timeout(5000) без константы и без комментария. Тут важно не просто отметить число, а понять: это бизнес-логика или случайное значение? Если не понимаешь, тест написать правильно невозможно.
Закомментированный код. AI иногда оставляет старую реализацию закомментированной рядом с новой. Это означает, что генератор не был уверен в своём решении. Я читаю оба варианта и решаю сам, какой корректен.
Отдельно про паттерн, который я называю "оптимистичный return". AI склонен возвращать успешный результат даже когда что-то пошло не так частично. Классический пример: функция обрабатывает массив из 10 записей, три упали с ошибкой, функция возвращает { success: true, processed: 10 }. Технически исключения не выброшено, всё "работает". Поэтому я явно ищу функции, которые агрегируют результаты или итерируются по коллекциям, и первые тесты пишу именно на частичный сбой: что происходит, если 30% элементов упадут.
Весь этот чеклист занимает меньше времени, чем дебаг падающего теста, причина которого находится не в тесте.

Два самых частых паттерна в AI-коде, пустой catch, который глотает ошибку без следа, и отсутствующая проверка входных данных перед использованием.
Юнит-тесты для AI-кода: что тестировать в первую очередь
Когда я смотрю на код, написанный Claude или GPT-4o, первое, что меня беспокоит: он выглядит слишком уверенно. Нет защитных проверок на входе, нет обработки крайних случаев, зато есть чистая структура и читаемые названия переменных. Это опасная комбинация. Код производит впечатление надёжного, а на деле рассыпается на первом же null.
Поэтому тесты для AI-кода я начинаю не с happy path, а с граничных условий.
Приоритет первый: входные данные, которые никто не ждёт
Пустой массив. Ноль. Отрицательное число. null там, где ожидается объект. Number.MAX_SAFE_INTEGER там, где ожидается небольшой id. Языковые модели обучены на примерах, где функциям передают "нормальные" данные. Граничные случаи они часто молча игнорируют или обрабатывают с тихой ошибкой.
Прогони getUser(0), getUser(null), getUser(Number.MAX_SAFE_INTEGER). Получишь неожиданные результаты заметно чаще, чем хотелось бы.
Приоритет второй: пути с ошибками
AI-код обычно имеет отличный основной поток и практически нулевую устойчивость к сбоям. API недоступен. Ответ пришёл с битым JSON. Запрос завис на 30 секунд. Именно здесь нужны тесты с моками, которые возвращают 500, network error, пустое тело ответа.
Приоритет третий: контракт возвращаемого значения
Функция должна возвращать объект с полем name типа string? Проверяй именно это. Тип, структуру, наличие обязательных полей. Потому что AI легко переименует поле между итерациями промпта, и если ты это не тестируешь, узнаешь о проблеме в продакшне.
Изоляция состояния
Есть одна неочевидная особенность AI-кода: он часто содержит зависимости от глобального состояния. Переменные в верхнем скоупе, синглтоны, закешированные подключения. Ставь beforeEach/afterEach везде, где есть хоть намёк на внешнее состояние. Даже если тебе кажется, что его нет.
Главный антипаттерн: тестировать реализацию
На Reddit в треде про Claude Code кто-то похвастался 400 строками тестов. При проверке выяснилось, что все эти тесты проверяли, какие методы вызывает функция внутри. Не что она возвращает, а именно как работает. Это бесполезно и хрупко.
// Плохой тест: тестирует реализацию, а не поведение
it('вызывает fetchUser с правильным id', () => {
const spy = jest.spyOn(api, 'fetchUser');
getUser(42);
expect(spy).toHaveBeenCalledWith(42); // бесполезно
});
// Хороший тест: тестирует поведение
it('возвращает объект пользователя с полем name', async () => {
const user = await getUser(42);
expect(user).toHaveProperty('name');
expect(typeof user.name).toBe('string');
});
it('бросает ошибку при несуществующем id', async () => {
await expect(getUser(-1)).rejects.toThrow('User not found');
});
Первый тест сломается, как только ты переименуешь внутренний метод. Второй и третий переживут любой рефакторинг, пока поведение остаётся прежним.
Правило простое: тест должен отвечать на вопрос "что делает эта функция?", а не "как она это делает?". Для AI-кода это особенно критично, потому что модель может перегенерировать реализацию с нуля. Если тесты привязаны к внутренностям, они гарантированно станут красными.
Мутационное тестирование: проверяем качество самих тестов
100% покрытие строк ничего не гарантирует. Тест может пройти по каждой строке кода и при этом ничего не проверять. С AI-генерированными тестами это случается особенно часто: инструмент видит код, пишет тест, который запускает функцию и проверяет, что она "не падает". Формально покрытие есть. Фактически защиты нет.
Мутационное тестирование решает ровно эту проблему. Инструмент сам вносит баги в твой код: меняет > 0 на >= 0, удаляет return-выражения, переворачивает логические условия. Потом запускает тесты. Если тест упал, мутант убит, всё хорошо. Если тест прошёл, значит этот тест не поймал бы реальный баг. Такой тест бесполезен.
Для TypeScript и JavaScript под это есть Stryker. Для Java - Pitest. Для.NET - Stryker.NET, который с.NET 6+ интегрируется нативно и генерирует HTML-отчёт с полным списком выживших мутантов, удобно смотреть на ревью.
Конфиг для TypeScript-проекта выглядит так:
// stryker.config.mjs
export default {
packageManager: 'npm',
reporters: ['html', 'clear-text', 'progress'],
testRunner: 'jest',
coverageAnalysis: 'perTest',
thresholds: {
high: 80,
low: 60,
break: 50 // упадёт CI если mutation score ниже 50%
}
};
coverageAnalysis: 'perTest' сокращает время прогона: Stryker запускает только те тесты, которые покрывают изменённый участок, а не весь suite целиком.
Про пороги. Mutation score выше 80% считается хорошим результатом. 60-80% - приемлемый диапазон, есть что улучшить. Ниже 60% - скорее всего, тесты покрывают код формально, но реальной защиты мало. Конкретные числа зависят от проекта и типа кода, но порог break: 50 в конфиге выше означает, что CI сломается, если мутантов убито меньше половины. Это жёсткий минимум, ниже которого тесты создают ложное ощущение безопасности.
Запускать Stryker после каждого коммита избыточно, прогон занимает время. Разумная практика такая: запускаешь после каждой крупной сессии с Copilot, Cursor или любым другим инструментом, который генерировал тесты. Получил 200 новых строк тестов от AI - прогони мутационный анализ до мержа. Именно тут он ловит то, что code review не поймает.

Отчёт Stryker наглядно показывает, какие мутанты выжили: именно они указывают на места, где тесты не проверяют реальное поведение кода.
Интеграционные тесты и контрактное тестирование AI-сгенерированных API
Cursor переименовал поле user_id в userId при рефакторинге под camelCase. Документация осталась со старым именем. Фронтенд сломался в продакшне. AI-агент, который писал эту часть, не имел понятия, что три других сервиса зависят от этого поля.
Это типичная история. AI-агенты хорошо пишут код в изоляции, но плохо держат в голове контракты между сервисами. Рефакторинг для них - это локальная операция: переименовать, привести к единому стилю, сделать "чище". Что сломается снаружи - не их забота. Подробнее о том, что реально выживает после двух недель работы с Cursor в реальном проекте, - отдельная тема.
Consumer-driven contract testing решает именно этот класс проблем. Pact фиксирует ожидания каждого потребителя API в виде контракта, и при каждом изменении провайдера этот контракт прогоняется заново. Не "мы думаем, что API возвращает такую структуру", а "вот конкретный JSON, который ожидает сервис авторизации, и провайдер обязан его вернуть".
# Потребитель фиксирует контракт
from pact import Consumer, Provider
pact = Consumer("auth-service").has_pact_with(Provider("user-api"))
pact.given("user with id 42 exists").upon_receiving(
"a request for user profile"
).with_request(
method="GET", path="/users/42"
).will_respond_with(
status=200,
body={"user_id": Like(42), "email": Like("test@example.com")}
)
Теперь если Claude Code решит переименовать user_id в userId, CI упадёт ещё до мержа. Не в продакшне, не через час после деплоя.
Отдельно про базовые вещи, которые AI-агенты пропускают при генерации REST-эндпоинтов. Статус-коды: Claude часто возвращает 200 с {"error": "not found"} вместо 404. Обработка невалидных входных данных: что вернёт эндпоинт на пустое тело запроса? На строку вместо числа в id? На отрицательный limit в пагинации? Тестируй именно эти кейсы - они предсказуемо отсутствуют в AI-сгенерированном коде.
@pytest.mark.parametrize("invalid_payload,expected_status", [
({}, 422),
({"email": "not-an-email"}, 422),
({"email": "ok@test.com", "age": -1}, 422),
(None, 400),
])
def test_create_user_validation(client, invalid_payload, expected_status):
response = client.post("/users", json=invalid_payload)
assert response.status_code == expected_status
Теперь про базы данных. Правило простое: если Claude Code написал слой работы с БД, нужен интеграционный тест с реальной БД. Не с моком, не с SQLite вместо PostgreSQL. С реальным PostgreSQL.
Testcontainers запускает настоящий контейнер в тесте и убивает его после:
from testcontainers.postgres import PostgresContainer
import pytest
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:16") as pg:
yield pg.get_connection_url()
def test_user_repository_creates_record(postgres):
repo = UserRepository(database_url=postgres)
user = repo.create(email="test@example.com")
fetched = repo.get_by_id(user.id)
assert fetched.email == "test@example.com"
SQLAlchemy-запросы с ORM особенно часто содержат N+1. Claude генерирует красивый читаемый код, который делает 50 запросов там, где нужен один с joinedload. Это не видно в unit-тестах с моками, но сразу видно в интеграционных - если логировать SQL.
def test_get_orders_with_items_no_n_plus_one(postgres):
# Создаём 10 заказов с позициями
# ...
with count_queries() as counter:
orders = repo.get_orders_with_items(user_id=1)
assert counter.count == 1 # Ровно один запрос с JOIN
assert len(orders) == 10
Такой тест сразу ловит то, что AI-агент написал for order in orders: order.items вместо жадной загрузки. В 2026 году инструменты типа pytest-sqlalchemy-mock делают подсчёт запросов тривиальным - нет причин не делать это для каждого критичного метода репозитория.
Транзакции - отдельная история. Claude Code часто пишет код, который не атомарен: запись в одну таблицу прошла, запись во вторую упала, данные рассогласованы. Unit-тест с моком не покажет это никогда. Интеграционный тест с реальной БД покажет при первом запуске.
Как заставить AI итерировать по результатам упавших тестов
Базовый цикл выглядит просто: запускаешь тесты, копируешь вывод упавших, вставляешь в Claude Code, просишь починить. На практике половина этого цикла ломается из-за того, что не прописано в промпте.
Самое нужное ограничение в промпте: "Не меняй тесты. Не добавляй новые тесты. Исправь только реализацию." Без этого Claude Code с высокой вероятностью перепишет тест так, чтобы он прошёл, и технически задача будет "выполнена". Семь слов спасают часовой дебаг.
Файл CLAUDE.md решает проблему системно
Вместо того чтобы каждый раз диктовать правила в промпте, фиксируй их в CLAUDE.md в корне проекта. Агент читает его автоматически при старте сессии. Вот рабочий шаблон:
# CLAUDE.md — правила для агента
## Обязательные правила
- После каждого изменения кода запускай: npm test
- Не изменяй файлы в директории __tests__/
- Если тест падает, исправляй реализацию, а не тест
- Перед коммитом проверь: npm run test:coverage -- --threshold 70

*Когда несколько промисов запускаются параллельно, один необработанный rejection способен положить всю цепочку, и AI об этом не предупреждает.*
## Запрещено
- Добавлять .skip() к тестам
- Мокировать модули без явного разрешения
- Возвращать захардкоженные значения для прохождения тестов
Пункт про .skip() добавил после того, как поймал агента на том, что он просто скипнул три теста вместо того, чтобы разбираться с багом в парсере. Всё стало зелёным. Красиво.
Итерация через план в.md-файле
Есть приём, который хорошо работает на задачах с несколькими шагами. Пишешь план прямо в файле (назови его PLAN.md или как угодно), и каждый шаг завершается командой проверки:
## Шаг 1: реализовать парсер CSV
- Создать src/parser.ts
- Покрыть граничные случаи: пустые строки, кавычки внутри поля
- Команда проверки: npm test -- --testPathPattern=parser

*Чеклист после AI-сессии с конкретными командами и оценкой времени помогает не пропустить ревью под давлением дедлайна.*
## Шаг 2: интегрировать парсер в pipeline
- Подключить в src/pipeline.ts
- Команда проверки: npm test
Агент идёт по шагам, запускает тесты сам, видит результ





