1. «Тест» — это ещё не ответ
Слово «тест» само по себе почти ничего не говорит. Когда вы только начинаете работать с тестами, кажется, что тест — это любой файл с assert. Но один и тот же баг проверяется на разных уровнях, и от уровня зависит всё: скорость, надёжность, стоимость поддержки и то, поймаете вы реальную проблему или красиво промахнётесь мимо неё.
Когда приоритетный сценарий уже выбран и TDD-ритм понятен, следующий вопрос неизбежен: на каком уровне ставить проверку. Один риск, разные способы, разная цена сигнала.
Возьму Commerce OS и знакомый возврат заказа. Представим, что вы правите логику в refunds. Есть правило «после 14 дней возврат запрещён», есть сервис, создающий заявку, есть база, есть endpoint POST /api/orders/{id}/refund, которым пользуется админка. На бумаге — одна задача. Внутри — три разных вопроса.
Иногда вы спрашиваете: «Можно ли вообще делать возврат после 14 дней?» Это вопрос к чистому бизнес-правилу. Иногда другой: «Не создаёт ли сервис две заявки, если пользователь нажал кнопку дважды?» Риск живёт на стыке сервиса и хранилища. А иногда важнее всего: «Что увидит клиент, если отправит отрицательную сумму?» Это уже вопрос к HTTP-контракту.
Удобно держать это в голове как маленькую карту:
| Вопрос | Лучший первый уровень |
|---|---|
| Разрешён ли возврат после 14 дней? | Unit |
| Создаётся ли только одна заявка при повторном запросе? | Integration |
| Вернёт ли endpoint 400 Bad Request на отрицательную сумму? | API |
Здесь и кроется главная практическая мысль лекции. Нет «самого правильного» уровня теста вообще. Есть уровень, который лучше отвечает на конкретный риск. Проверять простое правило возврата через HTTP, базу и половину Spring Boot — как ехать за хлебом на грузовике: доедете, но слишком дорого. Свести же всё к unit-тестам — и вы не заметите, как на реальном стыке компонентов система разваливается бодро и совсем не по unit-правилам.
2. Unit-тест: проверка правила, а не всего мира
Unit-тест особенно хорош там, где нужно проверить изолированное правило, а не весь мир. Поэтому с них удобно начинать: компактные, честные по формулировке вопроса. Бизнес-правила, валидаторы, расчёты, небольшая логика без базы, сети и HTTP — их территория. Нужен точный ответ на один вопрос — unit обычно первый хороший инструмент.
Допустим, в Commerce OS есть класс RefundPolicy, который решает, можно ли одобрить возврат. Чистая логика: сколько дней прошло, был ли возврат раньше, не истёк ли период. Если баг в том, что возврат разрешался на пятнадцатый день, поднимать HTTP, JSON, контроллер и базу незачем. Нужен короткий тест на само правило.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class RefundPolicyTest {
@Test
void forbidsRefundAfter14Days() {
assertFalse(RefundPolicy.canRefund(15, false)); // после 14 дней возврат запрещён
}
}
Этот пример хорош не потому, что он маленький, а потому, что точно отвечает на вопрос. Сломается правило — увидите сразу. Тест быстрый, не зависит от состояния среды, не заставляет гадать, где проблема: в логике, в JSON или в настройке Spring. Для TDD — подарок: failing test, правка, зелёный, дальше.
Но у unit-теста есть честные ограничения. О том, как логика живёт внутри приложения, он почти ничего не скажет: не знает, как данные читаются из базы, не видит транзакцию, не видит сериализацию JSON, не заметит, что сервис создал две записи вместо одной. Отсюда частая ошибка: разработчик или Claude пишет прекрасный unit-тест на «математику», а настоящий баг сидит на стыке сервиса и репозитория. Формально тест есть — риск не закрыт.
И тут полезно помнить простое правило. Unit-тест — это не «самый лёгкий тест, который удалось написать», а тест на изолированное правило. Вопрос уже не про правило, а про взаимодействие компонентов — честно поднимайтесь уровнем выше. Claude обожает предлагать unit-тесты: их легче генерировать. Тем важнее ваш контроль — вы проверяете не красоту синтаксиса, а соответствие реальному риску.
3. Integration-тест: стык важнее отдельной детали
Если риск живёт на стыке компонентов, unit-теста уже мало. Как только баг сидит не в одной функции, а в связке нескольких частей системы, unit слишком сильно упрощает реальность — тут и выходят integration-тесты. Они проверяют, как вместе работают сервис, репозиторий, транзакция, маппинг, тестовая база. Не «одна формула», а маленький кусок живого приложения.
Представим, что пользователь отправляет запрос на возврат дважды. Бизнес-правило может быть идеальным, а система всё равно создаст две одинаковые заявки. Причина уже не в RefundPolicy, а в связке RefundService + RefundRequestRepository. Замокаете репозиторий полностью — тест выйдет удобным, но опасно приглаженным: он проверит то, что вы сами придумали для mock-объекта, а не поведение на реальном стыке.
Здесь integration-тест даёт более честный сигнал:
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class RefundServiceTest {
@Autowired RefundService refundService;
@Autowired RefundRequestRepository repository;
@Test
void doesNotCreateDuplicateRequest() {
refundService.requestRefund(101L, new BigDecimal("50.00"));
refundService.requestRefund(101L, new BigDecimal("50.00"));
assertEquals(1, repository.count()); // дубль не создаётся
}
}
Да, такой тест тяжелее unit-теста: поднимает контекст, требует времени и аккуратности. Но проверяет ровно то место, где живёт риск. Дубль проскакивает из-за особенностей репозитория, транзакции или уникального ограничения — unit об этом не узнает. Integration узнает, потому что смотрит на настоящий стык, а не на учебную картинку.
При этом integration-тест не обязан поднимать «весь мир». Это ещё одна любимая крайность новичков и иногда самого Claude: раз уж мы делаем integration, давайте поднимем всё приложение, все модули и, возможно, Луну. Не надо. Хороший integration-тест узкий — конкретная связка, конкретный риск. Если проект уже использует test slice, Testcontainers, тестовый профиль или отдельную базу для integration-проверок, следуйте существующему шаблону, а не приносите новый ради красоты. Иначе вы не тесты пишете, а устраиваете маленькую миграцию фреймворков прямо внутри PR.
4. API-тест: смотреть глазами клиента
API-тест защищает внешний контракт приложения. Здесь вас интересует не только внутренняя логика, но и то, что реально увидит клиент: URL, статус ответа, JSON, валидация, иногда авторизация. Integration спрашивает «работают ли вместе сервис и репозиторий», API — «Что именно получает внешний потребитель и не сломали ли мы обещанный контракт?»
Вернёмся к POST /api/orders/{id}/refund. Пусть у вас уже есть корректное бизнес-правило и сервис создаёт ровно одну заявку. Но фронтенд всё равно может прислать отрицательную сумму. Если endpoint по контракту обязан вернуть 400 Bad Request, это стоит защитить отдельным тестом. Важно не то, какой метод контроллера вызвался внутри, а какой HTTP-результат вышел наружу.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(RefundController.class)
class RefundControllerTest {
@Autowired MockMvc mvc;
@Test
void returnsBadRequestForNegativeAmount() throws Exception {
mvc.perform(post("/api/orders/101/refund")
.contentType("application/json")
.content("{\"amount\":\"-10.00\"}"))
.andExpect(status().isBadRequest()); // 400 Bad Request
}
}
Такой тест особенно полезен, когда endpoint уже кто-то использует: админка, фронтенд, внешний интегратор, мобильное приложение. В таких случаях поломка контракта часто больнее поломки внутренней детали. Сервис идеален, а клиент получает 500 вместо ожидаемого 400 — реальный баг, который unit и integration порой просто не замечают.
Здесь есть свой типичный промах. Новичок пишет API-тест, а внутри проверяет не контракт, а внутренности: какой сервисный метод вызвался, какой private mapper сработал, какой DTO собрался. Это не API-тест, а попытка одной проверкой охватить всё на свете. API-тест должен смотреть глазами клиента. Статус, тело ответа, формат ошибки, заголовки, доступность маршрута — вот его территория. Полезли внутрь контроллера — значит, тест выбрал не свою работу.
5. Выбор уровня и дисциплина Claude в стеке
На практике один тест на задачу нужен почти никогда. Обычно нужен набор датчиков на разных уровнях, и каждый ловит свой тип поломки. Хороший тестовый дизайн — не фан-клуб одного вида тестов, а аккуратная система проверок на разных границах.
Можно держать в голове очень простую эвристику. Вопрос звучит как «верно ли это правило?» — начинайте с unit. Как «правильно ли работают вместе две части системы?» — это integration. Как «что увидит внешний клиент?» — нужен API-тест. Одна задача нередко требует нескольких уровней сразу. Для refund-сценария это естественно: unit защищает правило 14 дней, integration следит за отсутствием дублей, API страхует контракт 400 Bad Request.
В работе с Claude здесь особенно важна дисциплина. Скажете просто «напиши тесты для refund» — модель попробует сделать всё подряд, а заодно притащит новый helper, новую библиотеку и новый стиль именования, потому что ей так удобнее. Сначала заземляйте Claude в существующий проектный паттерн. Не в абстрактное «как правильно в Java», а именно в то, как это устроено в вашем Commerce OS.
Хороший запрос выглядит так:
Сначала прочитай тесты в src/test/java/com/acme/refunds/.
Добавь integration-тест для RefundService в существующем стиле.
Не меняй production-код.
Не добавляй новые тестовые библиотеки и новые helper-классы без согласования.
Здесь сильна не магия формулировки, а границы. Вы не просите «сделать красиво». Вы говорите, где смотреть, какой уровень нужен, чего трогать нельзя и что считается нарушением дисциплины. Золотое правило: сначала читать тесты рядом, потом писать свои. Не наоборот.
Ещё лучше, когда это знание закреплено в CLAUDE.md проекта, а не живёт только у вас в голове. Тогда любая новая сессия Claude получает одни и те же рамки:
## Тестовый стек Commerce OS
- Backend: JUnit 5 + Spring Boot Test + MockMvc
- Сначала читать тесты в том же пакете и копировать их стиль
- Не добавлять новые библиотеки и новые test helpers без согласования
- Если риск на стыке компонентов, не упрощать его до unit-теста
Это маленький фрагмент, но он делает важную вещь: не даёт Claude устроить локальную революцию в тестовом стеке. Где-то уже MockMvc, где-то RestAssured, где-то специализированные test slices. Задача модели — не принести «лучший в мире» стиль, а встроиться в существующий. Новичку особенно трудно заметить, что AI красиво написал рабочий тест… просто в чужом фреймворке и мимо принятого стиля команды.
Если у команды уже есть Workflow Kit, эту логику можно упаковать ещё строже — например, в отдельный агент или skill для тестов. Но даже без сложной автоматизации правило остаётся тем же. Сначала выбираете уровень теста по риску. Потом заставляете Claude читать существующие примеры. И только потом разрешаете писать код.
Хороший набор тестов в Commerce OS в итоге — не склад случайных проверок, а система точных датчиков. Unit ловит поломку правила, integration — поломку стыка, API — поломку обещания клиенту. Когда вы начинаете думать именно так, Claude перестаёт быть генератором «ещё одного тестового файла» и становится нормальным инженерным помощником, который пишет тесты туда и на том уровне, где они действительно защищают поведение.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ