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. Зупиніться й заведiть окрему bugfix- або feature-задачу. Менш романтично, ніж «я все одно вже тут був». Зате безпечніше.
3. Спочатку спостережувана поведінка, потім check
Найчастіша помилка в цій темі — попросити Claude Code «напиши characterization tests», не визначивши, що саме ви захищаєте. Прилетить набір пристойних тестів, половина з яких перевіряє внутрішності. А потрібна externally visible behavior — те, що бачить зовнішній споживач методу. Для локального refactor його зручно записати коротким планом:
Behavior X: оплачене замовлення за наявності товару отримує FINALIZED
Behavior Y: оплачена замовлення при stock = 0 отримує REJECTED / OUT_OF_STOCK
Перевірка: два unit-тести в OrdersUnitTest
Поза межами: змінювати контракт методу, тексти помилок, правила 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» — і він піде широко, щедро та місцями творчо. А потрібно вузько і строго.
Хороший запит виглядає так:
Перед refactoring OrderService.finalizeOrder запропонуй 1-2 lightweight
characterization tests для поточної спостережуваної поведінки.
Фокус:
- paid + in-stock -> FINALIZED
- paid + stock=0 -> REJECTED / OUT_OF_STOCK
Поки що не змінюй production code.
Не вважай, що ця поведінка правильна.
Зверніть увагу на дві фрази, без яких запит гірший. 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 спостережувані сценарії] --> B[Додали lightweight checks]
B --> C[Запустили їх на поточному коді]
C --> D[Зробили один маленький refactor]
D --> E[Запустили цільові перевірки та 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 перестає бути стрибком у темряву. Він стає звичайною інженерною роботою: локальною та передбачуваною настільки, щоб кожне перейменування не перетворювалося на маленький трилер.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ