Базовые приёмы под зелёным
Каталог маленьких рефакторингов под зелёными тестами, по одному за раз — выделение, встраивание, переименование, перемещение, изменение объявления, инкапсуляция переменной. Ритм: крошечный шаг, прогон тестов, коммит, повтор; не смешивай рефакторинг с изменением поведения.
У тебя есть функция на 200 строк, которую нужно перекроить: переименовать вводящую в заблуждение переменную, вынести два блока в именованные хелперы и переместить функцию туда, где ей место. Инстинкт — сделать всё это одной героической правкой, прогнать тесты в конце и помолиться. Потом тест краснеет, и ты понятия не имеешь, какое из одиннадцати изменений его сломало — поэтому ты тратишь час, бисектя собственный диф.
Senior-приём — противоположность. Рефакторинг — не творческий акт; это каталог именованных, сохраняющих поведение преобразований, каждое настолько маленькое, что проверяется за секунды. Ты применяешь одно, прогоняешь тесты, коммитишь и повторяешь. Набор тестов становится храповиком: он может щёлкать только вперёд. Этот урок — про каталог и про ритм, который делает его безопасным.
После этого урока ты можешь назвать и применить базовые приёмы — выделение/встраивание функции, выделение/встраивание переменной, переименование, перемещение, изменение объявления функции, инкапсуляция переменной — каждый как шаг с сохранением поведения; ты можешь крутить цикл крошечный-шаг / прогон-тестов / коммит так, что каждый рефакторинг откатывается независимо; и ты можешь объяснить, почему смешивание рефакторинга с изменением поведения в одном коммите уничтожает твою способность бисектить падение.
Рефакторинг по определению сохраняет поведение — если наблюдаемое поведение изменилось, это был не рефакторинг. У слова точный смысл: изменение внутренней структуры кода, не меняющее его внешнего поведения. Те же входы, те же выходы, те же побочные эффекты, те же ошибки. Это не приятное свойство, которое ты стараешься удержать; это определение. В тот миг, когда «рефакторинг» заодно чинит баг или добавляет фичу, он перестаёт быть рефакторингом и становится двумя изменениями под одним именем.
Именно эта точность позволяет набору тестов быть твоей страховочной сеткой. Если поведение не изменилось, зелёный набор до правки обязан остаться зелёным после неё. Красный тест поэтому означает ровно одно: ты нарушил инвариант — твой шаг не сохранил поведение. Чем уже шаг, тем точнее красный указывает на ошибку.
Базовый каталог — это горстка маленьких, именованных, обратимых приёмов. Ты не изобретаешь рефакторинги; ты распознаёшь ситуацию и применяешь подходящий приём. Повседневный набор:
// Extract Function: именованный блок становится функцией
// до
const tax = order.lines.reduce((s, l) => s + l.qty * l.price, 0) * 0.2;
// после
const tax = subtotal(order.lines) * 0.2;
function subtotal(lines: Line[]) {
return lines.reduce((s, l) => s + l.qty * l.price, 0);
}
// Extract Variable: дать имя подвыражению
// до
if (order.total > 100 && order.lines.length > 3) { /* ... */ }
// после
const isBulkOrder = order.total > 100 && order.lines.length > 3;
if (isBulkOrder) { /* ... */ }Inline Function и Inline Variable — точные обратные приёмы — применяются, когда имя больше не оправдывает себя. Rename меняет имя везде, где оно связано. Move переносит функцию/поле в тот модуль, которому оно действительно принадлежит. Каждый обратим, и именно это делает весь процесс малорискованным: любой шаг, о котором ты пожалел, отменяется его обратным приёмом, без всякого детективного расследования.
Change Function Declaration и Encapsulate Variable — два приёма, что трогают границу, — делай их микрошагами. Переименование функции, добавление/удаление параметра или перестановка аргументов (Change Function Declaration) расходится по всем вызывающим. Любительская версия — один гигантский find-and-replace. Дисциплинированная версия, когда вызывающих много, — это миграционный вариант:
// 1. добавить новую форму рядом со старой, старая делегирует новой
function chargeOld(cents: number) { return charge({ cents }); }
function charge(opts: { cents: number }) { /* настоящее тело */ }
// 2. мигрировать вызывающих на `charge` по одному, тесты зелёные после каждого
// 3. удалить `chargeOld`, когда не осталось ни одного вызывающегоEncapsulate Variable делает то же с данными: оборачивает голое экспортируемое поле в getter/setter, чтобы будущий доступ шёл через одну точку, а затем мигрирует читателей.
// до: голый изменяемый экспорт — каждый читатель это вызывающий, которого ты не видишь
export let config = { region: "eu" };
// после: доступ направлен через функции, которые позже можно защитить или мемоизировать
let _config = { region: "eu" };
export const getConfig = () => _config;
export const setConfig = (c: Config) => { _config = c; };Каждый пронумерованный подшаг держит набор зелёным, так что ты можешь остановиться и отгрузить на любой строке.
Ритм — это цикл, а не спринт: крошечный шаг → прогон тестов → коммит → повтор. В этом вся дисциплина. Коммит — не бюрократия; это зуб храповика. После каждого зелёного коммита предыдущее хорошее состояние зафиксировано, поэтому цена любой ошибки ограничена одним шагом — git reset --hard или revert этого единственного коммита, и ты снова в заведомо рабочем состоянии. Пропусти коммиты — и один плохой шаг заражает час работы, который ты теперь не можешь чисто отделить.
loop:
сделай ОДИН приём из каталога # выделить, переименовать, встроить, переместить…
прогони набор тестов # должен быть зелёным (он был зелёным до)
зелёный? -> git commit # "refactor: extract subtotal()" ← храповик щёлкает
красный? -> откати ЭТОТ шаг # шаг не сохранил поведение; отмени, повтори мельчеДва режима отказа это убивает: big-bang-рефакторинг (часы правок, потом красный набор без понятия, какая правка виновата) и необратимый рефакторинг (ты изменил так много, что не можешь вернуться к рабочему коду). Маленькие обратимые шаги делают оба невозможными — набор может только щёлкать вперёд.
Перекрои функцию в три коммита, ни разу не покидая зелёного. Начни с запутанного калькулятора скидки:
function priceFor(order: Order): number {
let p = order.subtotal;
if (order.subtotal > 100 && order.coupon) p = p - p * 0.1; // опт + купон
return p + p * 0.2; // налог
}Шаг 1 — Extract Variable (коммит refactor: name the bulk-coupon condition):
function priceFor(order: Order): number {
let p = order.subtotal;
const qualifiesForDiscount = order.subtotal > 100 && order.coupon;
if (qualifiesForDiscount) p = p - p * 0.1;
return p + p * 0.2;
}Прогон тестов — зелёный. Коммит. Шаг 2 — Extract Function для скидки (коммит refactor: extract applyDiscount):
function priceFor(order: Order): number {
const discounted = applyDiscount(order);
return discounted + discounted * 0.2;
}
function applyDiscount(order: Order): number {
const qualifies = order.subtotal > 100 && order.coupon;
return qualifies ? order.subtotal * 0.9 : order.subtotal;
}Зелёный. Коммит. Шаг 3 — Extract Function для налога (коммит refactor: extract withTax):
function priceFor(order: Order): number {
return withTax(applyDiscount(order));
}
const withTax = (amount: number) => amount * 1.2;Зелёный. Коммит. Функция прошла путь от одного запутанного тела к трём именованным, тестируемым кускам. Важно: 20% налога и 10% скидки ни разу не изменились — поведение идентично на каждом шаге. Если бы по ходу продукт ещё сказал «налог теперь 21%», это отдельный коммит сверху, никогда не вшитый в Шаг 2 или 3 — потому что, если тест затем сломается, ты должен уметь сказать, виновато выделение или смена ставки.
▸Почему это работает
Почему настаивать на коммите между шагами, а не просто «прогнать тесты»? Потому что зелёный набор доказывает, что текущее состояние хорошее, но только коммит фиксирует это состояние дёшево. Без коммита «вернуться к последней рабочей версии» значит вручную отменять правки — ровно тот бисект, которого ты пытаешься избежать. С ним восстановление — один git revert <sha>. Каждый коммит — ещё и обозримый атом: ревьюер, читающий refactor: extract applyDiscount, по сообщению знает, что поведение не изменилось, и может пробежать глазами, тогда как диф на 400 строк под названием «cleanup + bugfix» вынуждает его перепроверять всё целиком. Маленькие коммиты делают и откат, и ревью дешёвыми; это окупает накладные расходы на печать.
▸Частая ошибка
Главный грех — смешанный коммит: рефакторинг и изменение поведения в одном коммите. Ты выделяешь функцию и чинишь off-by-one и переименовываешь поле — всё разом. Работает, ты пушишь. Через две недели появляется регрессия, и git bisect приземляется на этот коммит — теперь коммит содержит три логически независимых изменения, и bisect не может сказать тебе, какое из них виновник; ты снова читаешь весь диф руками. Гарантия сохранения поведения тоже пропала: ты больше не можешь сказать «этот коммит не мог вызвать баг поведения», потому что ещё как мог. Правило: коммит — это либо рефакторинг (меняется структура, поведение зафиксировано), либо изменение поведения (структура зафиксирована, меняется поведение) — никогда оба сразу. Когда ловишь себя на желании обоих, спрячь изменение поведения в stash, доделай рефакторинг под зелёным, закоммить, а потом сделай изменение поведения отдельным коммитом.
Выделяя функцию, ты замечаешь маленький баг в том же блоке и чинишь его в том же коммите. Тесты по-прежнему проходят. Почему senior-ревьюер прав, отклоняя это?
Рефакторинг по определению сохраняет поведение — измени наблюдаемое поведение, и это перестаёт быть рефакторингом. Ты не импровизируешь; ты применяешь именованные приёмы из маленького каталога: выделение/встраивание функции, выделение/встраивание переменной, переименование, перемещение, изменение объявления функции, инкапсуляция переменной — каждый обратим своим обратным приёмом. Дисциплина — это ритм: один крошечный приём, прогон набора, коммит, повтор — зелёный набор плюс коммит образуют храповик, который может щёлкать только вперёд, ограничивая цену любой ошибки одним шагом, который ты можешь откатить. Бояться надо не неверного приёма, а смешанного коммита — рефакторинга, сплавленного с изменением поведения, — который разом уничтожает git bisect и гарантию сохранения поведения. Держи рефакторинг и изменения поведения в раздельных коммитах, всегда, — и страховочная сетка действительно тебя поймает.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.