1. Тестов много, а уверенности всё равно мало
Много тестов ещё не означает много уверенности — и с этого места обычно начинается взрослая инженерная жизнь. Вы открываете PR, видите десять новых тестов, три фикстуры и пару зелёных галочек — а покоя внутри нет. Это правильное ощущение. Оно значит, что вы перестали путать количество тестов с качеством проверки.
К этому моменту у вас уже есть почти вся конструкция: риск выбран, первый сценарий гоняется через red → green → refactor, уровень проверки понятен, роль smoke/regression/E2E в suite тоже. И всё равно этого мало, если тесты кормят случайными данными, граничные случаи выбраны наугад, а сами проверки никто не ревьюит как отдельный артефакт.
Проблема почти всегда одна и та же: тесты есть, но они не складываются в доказательство. Один проверяет слишком счастливый сценарий, другой смотрит на внутренний метод вместо поведения, третий берёт данные, которых в реальной задаче не бывает, а четвёртый остаётся зелёным, пока баг спокойно живёт своей лучшей жизнью.
Полезно держать в голове очень простую цепочку:
риск изменения → сценарий → тестовые данные → assertion → review самого теста → evidence в PR
Если где-то в этой цепочке дырка, уверенность падает. Возьмём Commerce OS: вы правите endpoint возврата денег POST /api/orders/{id}/refund. Проверили только «оператор с полными правами отправил корректную сумму по валидному заказу» — у вас есть тест, но нет картины риска. Баг прячется в повторном запросе, в истёкшем окне возврата, в неверной сумме, в ситуации без прав, на границе дат. И вот тогда десять зелёных тестов внезапно оказываются декоративной гирляндой.
Поэтому сегодняшняя тема не про «написать ещё». Она про то, как отделить шум от сигнала. Хороший тест — это инженерное утверждение: при таких данных, в таком сценарии система обязана повести себя вот так и не сделать вот этого. Не читается утверждение — тест слабый, даже если сборка его любит.
2. Edge cases: граничные случаи без энциклопедии боли
Когда вы впервые начинаете думать о граничных случаях системно, хочется протестировать вообще всё. Пустые значения, null, отрицательные числа, огромные числа, неправильные роли, два запроса подряд, три подряд, таймаут, полночь, високосный год, ретроградный Меркурий. Вот здесь важно остановиться: мы не пишем энциклопедию боли, мы уменьшаем риск.
Для этого удобно брать не бесконечный список, а рабочую таксономию категорий — и выбирать только те случаи, которые действительно связаны с текущим изменением. Для возврата денег в Commerce OS это выглядит так:
| Категория | О чём спросить себя | Пример для refund flow | Что именно доказываем |
|---|---|---|---|
| Пустые и неверные входные данные | Что будет, если вход формально есть, но бизнес-смысла в нём нет? | amount = 0, отрицательная сумма, пустой reason | Валидация срабатывает, мусор не проходит |
| Граница значения | Где проходит тонкая граница «можно/нельзя»? | сумма равна полной стоимости заказа, возврат в последний день окна | На границе система не ошибается на единицу |
| Права доступа | Кто вообще имеет право делать это действие? | оператор без роли REFUND_MANAGER | Ошибка доступа и отсутствие побочных эффектов |
| Повторный запрос | Что будет при двойном клике или retry? | один и тот же возврат отправили дважды | Нет дубля, операция идемпотентна |
| Внешняя зависимость | Что произойдёт, если внешний сервис подведёт? | платёжный провайдер вернул timeout | Система корректно сообщает об ошибке и не «теряет» состояние |
| Время и дата | Не прячется ли баг на календарной границе? | заказ оформлен поздно вечером, окно возврата считается по UTC | Политика возврата считается одинаково и предсказуемо |
Заметьте важную вещь: это не список «что протестировать всегда». Это вопросы под конкретное изменение. PR не трогает расчёт времени — тест на timezone здесь лишний. А если вы как раз меняли бизнес-правило возврата по сроку — timezone внезапно становится главным героем вечера.
Именно здесь Claude может быть по-настоящему полезен. Не как автомат с кнопкой «сгенерируй двадцать тестов», а как помощник в сборе кандидатов. В Workflow Kit можно дать ему такой запрос:
Прочитай TASK_SPEC.md и согласованную test strategy.
Для endpoint POST /api/orders/{id}/refund перечисли кандидатов в граничные случаи по категориям:
неверный ввод, границы значений, права, повторный запрос, timeout, время и дата.
Не пиши тестовый код.
Верни таблицу: сценарий -> риск -> рекомендуемый уровень теста.
Это хороший запрос, потому что он просит сначала думать, а не печатать. Скажете сразу «сгенерируй тесты на edge cases» — Claude это и сделает, и половина будет дублировать друг друга или проверять второстепенное. Модель в этом месте честно исполняет заказ. Просто заказ был слишком широким.
3. Negative scenario: контракт, а не ошибка
С отрицательными сценариями у новичков обычно происходит одна и та же маленькая трагикомедия. Появляется тест, который проверяет, что API вернул 400 Bad Request, — и всем кажется, что работа сделана. Но отрицательный сценарий — это не только код ошибки. Это ещё и вопрос: что система не сделала, пока возвращала эту ошибку.
Представьте, что в Commerce OS возврат на сумму 0 должен быть отклонён. Тест только на статус ответа лучше, чем ничего. Но он не заметит неприятную вещь: сервер вернул 400, а запись о возврате всё равно успел создать. Такой баг особенно любит жить там, где валидация и побочный эффект встали в неправильном порядке.
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
long before = refundRepository.count();
mockMvc.perform(post("/api/orders/42/refund")
.contentType("application/json")
.content("{\"amount\":0}"))
.andExpect(status().isBadRequest());
assertThat(refundRepository.count()).isEqualTo(before); // лишнюю запись не создали
Здесь тест уже заметно сильнее: он проверяет и внешний контракт, и отсутствие побочного эффекта. Это и есть инженерная зрелость отрицательного сценария — вы фиксируете, что должно быть запрещено, и одновременно следите, чтобы система не натворила дел по дороге.
Тот же подход работает и для прав доступа. Оператор без нужной роли пробует сделать возврат — отрицательный сценарий отвечает минимум на два вопроса: какой статус получил клиент и что не произошло в системе. Не ушёл ли запрос во внешний сервис? Не создалась ли запись? Не поменялся ли статус заказа?
Иногда здесь очень помогает короткий сервисный тест:
import static org.mockito.Mockito.verifyNoInteractions;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
assertThatThrownBy(() -> refundService.requestRefund(orderId, 5000, userWithoutRole))
.isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(paymentProvider); // во внешний сервис не ходили
Обратите внимание, насколько полезнее тест стал после второй строки. Первая говорит: «ошибка есть». Вторая: «и наружу мы по дороге ничего не сломали». Это уже проверка контракта безопасности.
Именно поэтому формулировка «negative scenario» полезнее, чем «тест на ошибку». Ошибка — видимая часть. Контракт — это и внешний ответ, и внутренние запреты, и отсутствие нежелательных последствий.
4. Claude как помощник по данным, не фабрика
Когда разговор заходит о тестовых данных, очень хочется поручить ему вообще всё. Имена заказов, суммы, email, статусы, даты, фикстуры JSON, значения для параметризованных тестов. В этом желании нет ничего плохого — ровно до момента, пока вы не начинаете принимать всё сгенерированное без ревью.
У тестовых данных две обязанности. Быть достаточно реалистичными, чтобы сценарий был узнаваем. И достаточно чистыми, чтобы не тащить в репозиторий продовые хвосты, реальные письма, токены, внутренние ID и прочий цифровой мусор из логов.
Поэтому хорошие фикстуры обычно выглядят скучновато. И это комплимент.
{
"orderId": "ord-test-1042",
"customerEmail": "demo@example.com",
"amount": 5000,
"currency": "USD",
"reason": "damaged_item"
}
Такой файл не пытается выглядеть «почти как production». Он понятный, безопасный, стабильный. А anna.petrova@real-shop-client.com или номер настоящего заказа из лога — это уже не реализм, а утечка дисциплины.
Когда базовый red → green → refactor уже зафиксирован в памяти проекта, поверх него полезно добавить ещё один слой — правила для test data и согласования сценариев. Не замена базовым TDD-правилам, а расширение. Сама test strategy может жить в отдельном файле, секции TASK_SPEC.md или в описании PR — важны согласованные сценарии, а не имя документа.
Очень полезно закрепить это в CLAUDE.md проекта, чтобы правило жило не только в вашей памяти:
## Правила тестов
- Сначала перечисли сценарии и риски, потом пиши тесты.
- Не используй реальные email, токены и номера заказов из production.
- Для bugfix сохраняй базовый ритм: сначала падающий regression test.
- Если сценарий не одобрен в согласованной test strategy, не добавляй его автоматически.
Такая секция делает две важные вещи. Ограничивает избыточную инициативу Claude. И превращает качество тестовых данных из «настроения разработчика» в правило репозитория.
Ещё один практичный приём — просить у Claude сначала только перечень кандидатов, а код фикстур отдельно. Это уменьшает соблазн проглотить всё оптом: сначала список сценариев по категориям риска, затем выбираете шесть нужных, и только потом — «под эти шесть сценариев предложи фикстуры». Ритм чуть медленнее, качество заметно выше. Как и почти всё в инженерии, это обмен: минус пять минут сейчас, зато потом гораздо меньше стыда.
5. Test quality review: проверяем сами тесты
Это, пожалуй, самый важный кусок сегодняшней лекции. Когда тесты уже написаны, у многих разработчиков щёлкает странный внутренний выключатель: «ну тесты же есть, дальше ревьюим только production code». Нет. Тесты — сами код, а в AI-assisted разработке ещё и отдельный объект риска. Их тоже ревьюят.
У этого review один главный вопрос, который стоит повесить себе на монитор: Может ли этот тест пройти, даже если баг всё ещё жив?
Если ответ «да», тест слабый. Иногда не бесполезный, но слабый: он не отличает «до» от «после», а значит, не даёт уверенности в фиксе.
Удобно разложить такой review на несколько вопросов:
| Вопрос к тесту | Зачем он нужен | Красный флаг |
|---|---|---|
| Проверяется ли наблюдаемое поведение? | Мы хотим ловить реальный риск, а не внутреннюю реализацию | Тест смотрит на приватный метод или формат лога |
| Может ли тест пройти при живом баге? | Он должен отличать старое поведение от нового | Тест зелёный и до фикса, и после |
| Не замокали ли мы то, что обязаны были проверить? | Излишний mock убивает достоверность | Integration-тест «проверяет» то, что сам же подменил |
| Есть ли осмысленная проверка | Тест должен что-то доказывать | Проверка сводится к «не упало исключение» |
| Не прячется ли flaky-источник? | Шум разрушает доверие ко всей suite | Зависимость от времени, сети, random или порядка запуска |
| Имя теста объясняет сценарий? | Через месяц вы должны понять, зачем он был нужен | testRefund1 и друзья |
На практике очень полезно иметь отдельный review-шаблон именно для тестов в Workflow Kit. Не общий «посмотри PR», а узкий и сфокусированный. Кусок REVIEW_CHECKLIST.md может быть таким:
## Тесты в этом PR
- [ ] Есть assertions на наблюдаемое поведение
- [ ] Для bugfix видно: падал до фикса, проходит после фикса
- [ ] Нет mock'ов на то, что должно проверяться напрямую
- [ ] Нет зависимости от времени, сети и random без контроля
- [ ] Фикстуры не содержат production-данных
Да, внутри кода это список. Но как артефакт review он работает прекрасно: короткий, проверяемый, не превращает проверку в философский семинар на два часа.
Иногда разницу между слабым и сильным тестом проще всего почувствовать на коротком контрасте. Слабый вариант:
import static org.mockito.Mockito.verify;
refundService.requestRefund(orderId, 5000);
verify(refundRepository).save(any()); // тест зелёный, даже если сохранили не то
Сильный:
import static org.assertj.core.api.Assertions.assertThat;
refundService.requestRefund(orderId, 5000);
refundService.requestRefund(orderId, 5000);
assertThat(refundRepository.findByOrderId(orderId)).hasSize(1); // дубль не появился
Первый тест доказывает только, что кто-то что-то куда-то сохранил. Второй уже проверяет важное бизнес-поведение: повторный запрос не создал дубликат. Именно второй отвечает на риск. Первый в лучшем случае смотрит на движение рук.
6. Тестовый дифф: сокращать, а не хвалить
Иногда Claude приносит вам большой и аккуратный тестовый diff, и рука сама тянется похвалить его за усердие. Но подарок это далеко не всегда.
Представьте два PR. В первом — шестнадцать новых тестов на возврат денег: null, пустая строка, пробел, два пробела, отрицательная сумма, ноль, огромная сумма, сумма на единицу больше, запрос без прав, запрос после срока, таймаут, повторный запрос, три повтора подряд, странная дата, другая странная дата, ещё одна странная дата на всякий случай. Во втором — шесть тестов, но каждый завязан на конкретный риск из TEST_STRATEGY.md, использует чистые данные и читается как ясное утверждение. Угадайте, какой PR легче понять и какому вы скорее поверите.
Иногда полезно прямо сравнить:
| Шум | Evidence |
|---|---|
| Много однотипных тестов с почти одинаковыми assertions | Несколько сценариев, каждый связан с конкретным риском |
| Реальные данные из логов и БД | Санитизированные фикстуры с понятными значениями |
| Проверка save() или calledOnce() | Проверка статуса, побочного эффекта и бизнес-результата |
| Зелёный тест после случайного rerun | Детерминированный тест без flaky-источников |
Хороший набор тестов не обязан быть большим. Он обязан быть читаемым. Когда вы открываете тестовый diff в PR, вы должны видеть не «ещё пачку файлов», а аргумент. Вот этот сценарий опасен. Вот такие данные его воспроизводят. Вот такой результат система обязана показать. Вот что она не должна была сделать по дороге.
Читается эта история — перед вами evidence. Не читается — перед вами просто очень старательный шум. И в этом смысле test quality review не бюрократия, а последний фильтр, который превращает набор зелёных галочек в инженерное доказательство.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ