1. Перед refactor нужны отдельные проверки
На бумаге всё выглядит просто: у вас уже есть тесты, значит можно рефакторить. На практике всё веселее. «Есть тесты — рефакторить безопасно» часто врёт. Тесты бывают слишком широкими, хрупкими или смотрят не туда: гоняют весь сценарий checkout, а вам нужно вынуть один кусок логики из OrderService.finalizeOrder. Формально страховка есть. По факту — мелким шрифтом.
Особенно это заметно в Commerce OS на сервисных методах, которые давно живут и постепенно обрастают условиями. finalizeOrder проверяет оплату, наличие товара, формирует результат и годами обрастает условиями. Хочется почистить: убрать вложенные if, вынести валидацию в private helper, переименовать нечитаемое. Но полный integration-тест слишком груб — скажет «зелёное» или «упало» и не даст уверенности, что поведение узкой ветки не сдвинулось на полсантиметра в сторону.
Именно здесь появляется lightweight characterization. Не замена harness, не замена test strategy. Прицельная проверка: «Вот это поведение функция демонстрирует сейчас. Давайте сначала зафиксируем его честно, а форму кода улучшим потом». Скромно — и убирает истории вида «я всего лишь вынес проверку в helper, а почему отказ по stock теперь обрабатывается иначе — узнаем завтра утром».
2. Что фиксирует characterization-тест
Слово characterization звучит так, будто мы собираемся писать психологический портрет метода — на деле всё намного проще. Characterization test фиксирует, что система делает сейчас в конкретном наблюдаемом сценарии. Не «поведение идеальное и одобрено всеми на планете», а «на текущем коде при таких входных данных — такой результат».
Удобная аналогия здесь — фотография. Regression test — охранник: не пускает известный баг обратно. Contract/unit-тест — спецификация: проверяет поведение по договорённости. Characterization — снимок перед ремонтом. Вы ещё не спорите, красивы ли обои. Вы фиксируете, как комната выглядела, пока не начали двигать мебель.
Вот компактное сравнение трёх видов проверок:
| Вид проверки | Главный вопрос | Что даёт | Чего не доказывает |
|---|---|---|---|
| Contract / обычный unit-тест | «Как функция должна работать?» | Проверку согласованного поведения | Что текущая legacy-реализация вообще этому соответствует |
| Regression test | «Не вернулся ли известный баг?» | Защиту от повторения конкретной ошибки | Что остальное поведение не изменилось |
| Lightweight characterization | «Что код делает сейчас в этом узком сценарии?» | Локальную страховку перед refactor | Что текущее поведение правильно с точки зрения бизнеса |
Из этого вытекает очень важная вещь. Если во время refactor вы понимаете, что текущее поведение неправильное, — это не повод «заодно исправить». Это уже не refactor. Остановитесь и заведите отдельную bugfix- или feature-задачу. Менее романтично, чем «я всё равно уже тут был». Зато безопаснее.
3. Сначала наблюдаемое поведение, потом check
Самая частая ошибка в этой теме — попросить Claude Code «напиши characterization tests», не определив, что именно вы защищаете. Прилетит набор приличных тестов, половина которых проверяет внутренности. А нужен externally visible behavior — то, что видит внешний потребитель метода. Для локального refactor его удобно записать коротким планом:
Behavior X: оплаченный заказ при наличии товара получает FINALIZED
Behavior Y: оплаченный заказ при stock = 0 получает REJECTED / OUT_OF_STOCK
Check: два unit-теста в OrdersUnitTest
Out of scope: менять контракт метода, тексты ошибок, правила stock
Здесь важен сам принцип. Сначала словами называете поведение, потом подбираете check. Иначе тест диктует, что важно, а решать должен человек. Claude помогает — но не назначается начальником смысла.
Чтобы не съехать во внутренние детали, держите перед глазами простое различие:
| Имеет смысл фиксировать | Лучше не фиксировать |
|---|---|
| статус результата, код причины, наблюдаемый побочный эффект, внешний ответ метода | имя private helper, значение локальной переменной, количество вызовов внутреннего метода, порядок мелких шагов внутри реализации |
Если вы рефакторите finalizeOrder, то вас интересует, вернул ли метод FINALIZED или REJECTED, а не то, вызвался ли validateRefundEligibility ровно один раз. Helper завтра станет двумя или исчезнет. Observable behavior при хорошем refactor не меняется.
4. Commerce OS: два сценария finalizeOrder
Теперь переведём всё это из слов в маленький, но вполне реальный пример. В Commerce OS вы выносите часть логики OrderService.finalizeOrder в читаемый helper. Перед этим фиксируете два сценария: «оплаченный заказ с товаром проходит» и «оплаченный заказ без остатка отклоняется по причине OUT_OF_STOCK». Малый объём принципиален: не энциклопедия на 27 кейсов, а два честных маяка.
Первый тест может выглядеть так:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Test
void finalizeOrderReturnsFinalizedForPaidInStockOrder() {
OrderResult result = orderService.finalizeOrder(paidInStockOrder());
assertEquals(OrderStatus.FINALIZED, result.status());
assertEquals("Заказ подтвержден", result.message());
}
Здесь важно заметить, что тест проверяет именно то, что видит внешний код: статус и сообщение. Не какой helper сработал и как разложен if. Для характеризации это и нужно.
Второй тест — на границу, которую вы не хотите случайно потерять при refactor:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Test
void finalizeOrderRejectsOrderWhenStockIsZero() {
OrderResult result = orderService.finalizeOrder(paidOrderWithZeroStock());
assertEquals(OrderStatus.REJECTED, result.status());
assertEquals("OUT_OF_STOCK", result.reason());
}
Если оба теста зелёные на текущем коде, у вас есть минимальная safety net для этой функции. Она не доказывает, что вся логика заказов прекрасна, и не заменяет integration-тесты на checkout. Задача одна: не дать refactor тихо изменить два наблюдаемых исхода, которые вы обещали сохранить.
Если в вашем проекте принят другой стиль тестов — например, AssertJ вместо assertEquals, — следуйте стилю проекта. Мы боремся не за красоту синтаксиса, а за ясность намерения. Ясность важнее «идеального» оформления.
Когда маяки зелёные, safety net перестаёт быть абстракцией: следующий refactor делается под их защитой и гоняется вместе с existing harness.
5. Как просить Claude, не отдавая ему решение
И вот тут начинается та часть, где Claude Code действительно удобен. Он быстро предложит 1–2 characterization tests, найдёт похожие паттерны, подскажет существующие factory-методы и test fixtures. Но ему нужна рамка. «Напиши тесты перед refactor» — и он пойдёт широко, щедро и местами творчески. А нужно узко и строго.
Хороший запрос выглядит так:
Before refactoring OrderService.finalizeOrder, propose 1-2 lightweight
characterization tests for current observable behavior.
Фокус:
- paid + in-stock -> FINALIZED
- paid + stock=0 -> REJECTED / OUT_OF_STOCK
Do not modify production code yet.
Do not assume this behavior is correct.
Обратите внимание на две фразы, без которых запрос хуже. Do not modify production code yet: пока только фиксируем поведение. Do not assume this behavior is correct: защита от путаницы characterization с contract test. Claude не «улучшает смысл» — он фиксирует факт.
Дальше вступает в игру человеческий review. Даже если тесты предложил ваш tester-agent из Workflow Kit, вы открываете assertions и читаете их глазами. Вопрос простой: тест проверяет наблюдаемое поведение или внутреннюю механику? «Проверим, что вызвался private helper», «сравним внутреннее поле» — это не тот тест перед refactor.
Полезно зафиксировать намерение прямо в PR description или заметке рядом с задачей:
Наблюдаемое поведение зафиксировано:
- paid + in-stock -> FINALIZED
- paid + stock=0 -> REJECTED / OUT_OF_STOCK
We do not claim this behavior is correct.
Purpose: protect a local refactor in OrderService.finalizeOrder.
Такая маленькая пометка кажется бюрократией ровно до того момента, пока кто-нибудь на ревью не спросит: «Почему мы закрепили именно это поведение?» Вместо философского разговора — честный ответ: «Не потому, что это священная истина. Потому, что мы защищаем локальный behavior-preserving refactor».
6. Что делать, когда проверки позеленели
Когда эти маленькие characterization tests прошли на текущем коде, начинается самая приятная часть: рефакторить можно не вслепую. И здесь легко сорваться в «раз всё зелёное, давайте заодно ещё вот это подправим». Маленькая проверка даёт небольшой кредит доверия, а не ипотеку на переписывание половины сервиса. Вы используете эти 1–2 проверки как узкий safety net внутри того же incremental loop: маленькое изменение, targeted checks, diff.
Рабочий цикл здесь выглядит так:
flowchart TD
A[Выбрали 1-2 observable сценария] --> B[Добавили lightweight checks]
B --> C[Прогнали их на текущем коде]
C --> D[Сделали один маленький refactor]
D --> E[Запустили targeted checks и harness]
E --> F{Поведение сохранилось?}
F -- Да --> G[Коммит]
F -- Нет --> H[Стоп, откат или отдельная bug/feature-задача]
Ключевое правило на этом этапе очень простое: если после refactor характеристический тест упал, действие по умолчанию — остановиться, а не «чуть ослабить assert». Ослабление теста — скользкая дорожка. Пять минут назад вы сами решили сохранить этот observable outcome. Изменился — сначала признайте, потом разбирайтесь, было ли изменение допустимым.
Ещё одна частая ловушка: во время refactor вы внезапно натыкаетесь на настоящий баг. Claude Code тут ещё сильнее провоцирует на героизм: «О, я же рядом, сейчас поправлю». Не надо. Настоящий баг заслуживает своего regression test и отдельной задачи. Иначе behavior-preserving change превращается в смесь refactor, bugfix и маленькой внутренней революции. Такие коктейли на ревью заканчиваются плохо.
7. Где приём перестаёт работать
На этом этапе легко влюбиться в приём и начать применять его вообще ко всему подряд. Но граница чёткая: он работает на одной функции, одном узком flow, одной-двух проверках. Прикрываете им крупный сервис с очередями, базой, письмами и тремя интеграциями — и он перестаёт быть лёгким и честным. Начинается либо лавина тестов, либо ложное чувство безопасности.
Полезно быстро проверять себя такой таблицей:
| Ситуация | Lightweight characterization подходит? |
|---|---|
| Один метод, два понятных исхода, локальный refactor | Да |
| Один сервисный flow, где нужен один граничный случай и один основной сценарий | Да, если границы узкие |
| Большой модуль с несколькими интеграциями и побочными эффектами | Скорее нет |
| Refactor затрагивает несколько пакетов и внешний контракт | Нет, нужно сужать задачу |
| Вы хотите «заодно» поменять бизнес-правило | Нет, это уже не characterization для refactor |
Если вам страшно трогать код без пяти дополнительных проверок, это не повод писать ещё пять «маленьких» тестов и звать всё lightweight. Это сигнал: либо refactor слишком широкий, либо safety net должна быть глубже.
Именно в этом и ценность приёма. Он не серебряная пуля. Он честно закрывает дыру между «у нас есть общий harness» и «мы хотим безопасно почистить вот этот кусок метода». Две маленькие, понятные, прочитанные глазами проверки на текущем поведении — и refactor перестаёт быть прыжком в темноту. Он становится обычной инженерной работой: локальной и предсказуемой настолько, чтобы каждое переименование не превращалось в маленький триллер.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ