open atlas
↑ К треку
Паттерны и качество кода CP · 13 · 03

Legacy-код и правило бойскаута

Legacy — это код без тестов (Фезерс). Меняй его безопасно по алгоритму: найди шов, закрепи поведение характеризующими тестами, затем меняй и рефактори под этой сеткой. Прибирайся по-бойскаутски пошагово; сопротивляйся переписыванию и позолоте, раздувающим диф.

CP Senior ◷ 20 min
Уровень
ОсновыJuniorMiddleSenior

Тебе вручают OrderService на 600 строк, чтобы добавить туда одно поле. Тестов нет, зависимости создаются через new прямо в конструкторе, а ветка ценообразования лезет в статический Clock.now(). Каждый инстинкт кричит: «это мусор, переписать». Каждый senior, который отгрузил переписывание, знает, чем это кончается: через полгода у тебя две системы на сопровождении, новая не ловит граничные случаи, которые старая тихо обрабатывала, а бизнес перестал доверять твоим оценкам.

Другой путь невзрачен — и он работает. Тебе не нужно любить этот код; тебе нужно менять его безопасно. А для этого требуется одна вещь, которой у кода пока нет, — способ узнать, что ты ничего не сломал. Этот урок про дисциплину: как подвести сетку под непокрытый тестами код, а потом оставить участок чуть лучше, чем нашёл, не позволив «чуть лучше» сожрать тебе неделю.

Цель

После этого урока ты можешь применять рабочее определение legacy-кода от Фезерса (код без тестов) и объяснять, почему оно меняет твою стратегию; выполнять алгоритм изменения — найди точку изменения, найди шов, напиши характеризующие тесты, внеси изменение, рефактори под сеткой; применять правило бойскаута как оппортунистическую уборку, ограниченную радиусом поражения изменения, которое ты и так делаешь; и распознавать два провальных сценария, которые всё ломают, — обречённое большое переписывание и позолоту, которая раздувает диф и риск регрессии.

1

Legacy-код — это просто код без тестов, и это определение говорит, что делать. Рабочее определение Майкла Фезерса снимает с термина эмоции: legacy — это не «старый», не «чужой» и не «на языке, который мне не нравится». Это код, который ты не можешь менять с уверенностью, потому что ничто не подскажет, когда ты его сломал. Свежий код, написанный тобой сегодня утром без тестов, уже legacy по этой мерке.

Смысл определения — операционный. Если проблема в том, что «нет страховочной сетки», то решение не «будь осторожнее» и не «читай внимательнее» — это сначала подведи под него сетку. Всё остальное в этом уроке следует из этого. Ты не рефакторишь legacy-код, а потом его тестируешь; ты его тестируешь, и тогда можешь рефакторить.

2

Прежде чем тестировать, тебе нужен шов — место, где можно изменить поведение, не редактируя код в самой этой точке. Причина, по которой legacy-код тяжело тестировать, обычно в том, что он сам подключает своих коллабораторов: создаёт через new клиента БД, вызывает статический синглтон, читает Date.now() напрямую. Шов — это точка, где можно подставить фейк. Часто самый дешёвый шов — внедрение зависимости на границе, которую тебе и так нужно тронуть.

// Шва нет: зависимость вварена, поэтому ветку нельзя протестировать изолированно.
class OrderService {
  price(order: Order): number {
    const now = new Date();                 // приварено к системным часам
    const fx = new FxClient().rateFor(order.currency); // приварено к сети
    return applyRules(order, now, fx);
  }
}

// Шов, введённый параметризацией границы — поведение сегодня идентично.
class OrderService {
  constructor(private clock: () => Date, private fx: FxRates) {}
  price(order: Order): number {
    return applyRules(order, this.clock(), this.fx.rateFor(order.currency));
  }
}

Ты не изменил ни одного правила. Ты сделал код подставляемым, а это предусловие для закрепления его поведения.

3

Закрепи текущее поведение характеризующими тестами — не «правильное» поведение, а текущее. Характеризующий тест документирует, что код реально делает сегодня, баги включительно. Ты не утверждаешь, какой цена должна быть; ты запускаешь код, смотришь, что он возвращает, и замораживаешь это как ожидаемое значение. Задача теста — не корректность, а проволока-растяжка, которая срабатывает в момент, когда твоя правка меняет любой наблюдаемый вывод.

// Ты не решал, что 47.5 «правильно» — код это выдал. Ты это закрепил.
test("characterize: GBP order at 2021 rates", () => {
  const svc = new OrderService(() => new Date("2021-06-01"), fakeRates({ GBP: 1.18 }));
  expect(svc.price(gbpOrder)).toBe(47.5); // что бы он ни возвращал, зафиксировано
});

Если характеризующий тест вскрывает то, что выглядит багом, ты не чинишь его сейчас. Ты закрепляешь багованный вывод, отгружаешь своё настоящее изменение и чинишь баг отдельным, видимым изменением с собственным тестом. Смешивать «внести моё изменение» и «починить найденный баг» — вот как однострочная правка превращается в нечитаемый на ревью диф.

4

Теперь внеси изменение, а потом прибирайся по-бойскаутски только внутри радиуса поражения, который ты и так тронул. Когда сетка на месте, твоя настоящая правка безопасна: если ты сломаешь закреплённое поведение, тест станет красным. Правило бойскаута — всегда оставляй код чуть чище, чем нашёл — это инкрементальная стратегия, которая, повторённая на сотнях коммитов, стаскивает кодовую базу с дорогого конца кривой изменения без того, чтобы кто-то когда-либо назначал «спринт уборки».

Senior-уточнение — это слово оппортунистический. Ты улучшаешь код, который и так уже читаешь и уже покрываешь тестами, — переименуй вводящую в заблуждение переменную в редактируемой функции, вынеси продублированную ветку, которую ты только что был вынужден понять. Ты не открываешь соседние файлы, чтобы «прибрать заодно». Уборка должна ехать внутри существующего радиуса поражения изменения. В момент, когда она выходит за этот радиус, это новая работа, которой нужно своё обоснование, свои тесты и своё ревью, — а вкручивание её в этот диф лишь делает оба изменения труднее для ревью и рискованнее для отгрузки.

Разбор примера

Реальный тикет: «добавить лоялти-скидку в оформление заказа». Функция не покрыта тестами и лезет в часы и в FX-синглтон. Наивный ход — править её на месте и проверять глазами. Безопасный для legacy ход:

// ДО — без тестов, зависимости вварены. Трогать это — лотерея.
function checkoutTotal(cart: Cart): number {
  const now = new Date();
  const rate = FxSingleton.rateFor(cart.currency);
  let total = cart.lines.reduce((s, l) => s + l.qty * l.unitPrice, 0);
  if (now.getMonth() === 11) total *= 0.95; // декабрьская акция, магический литерал
  return total * rate;
}

Шаг 1 — шов: параметризуй две вваренные границы, чтобы их мог приводить в движение фейк. Шаг 2 — характеризовать: закрепи, что код делает сегодня, декабрьскую акцию включительно.

function checkoutTotal(cart: Cart, clock: () => Date, fx: FxRates): number {
  const now = clock();
  const rate = fx.rateFor(cart.currency);
  let total = cart.lines.reduce((s, l) => s + l.qty * l.unitPrice, 0);
  if (now.getMonth() === 11) total *= 0.95;
  return total * rate;
}

test("characterize: december promo applies", () => {
  expect(checkoutTotal(cart, () => new Date("2023-12-10"), fakeRates({ USD: 1 })))
    .toBe(/* что бы он сейчас ни вернул */ 95);
});

Шаг 3 — внеси настоящее изменение под сеткой (добавь лоялти-скидку). Шаг 4 — приберись по-бойскаутски внутри функции, которую ты уже открыл: голые 0.95 и === 11 — это магические числа, которые тебе пришлось расшифровать, чтобы внести изменение, поэтому их именование — внутри радиуса поражения:

const DECEMBER = 11, DECEMBER_PROMO = 0.95;
function checkoutTotal(cart: Cart, clock: () => Date, fx: FxRates, loyalty: LoyaltyTier): number {
  const total = subtotal(cart);
  const promo = clock().getMonth() === DECEMBER ? DECEMBER_PROMO : 1;
  return total * promo * loyaltyRate(loyalty) * fx.rateFor(cart.currency);
}

Чего ты не сделал: не погнался за FxSingleton в три других файла, где он используется, и не переписал Cart из-за того, что его форма тебя раздражает. Это реальные долги — но они вне радиуса этого тикета, поэтому получают собственный тикет. Диф, который видит ревьюер, — это «добавлена лоялти-скидка + названы две константы в тронутой функции», а не «переписано ценообразование».

Почему это работает

Почему инкрементальная уборка по-бойскаутски бьёт большое переписывание всякий раз, когда переписывание вообще рассматривается. Переписывание выбрасывает единственный актив, который есть у legacy-кода: годы накопленных граничных случаев, закодированных уродливыми ветками. Каждое странное if — обычно баг, на который кто-то наткнулся в продакшене. Переписывание стартует с чистой ментальной модели, которая опускает эти случаи, поэтому переоткрывает их по одному сбою за раз — а старую систему всё это время приходится сопровождать параллельно, потому что переключиться нельзя, пока новая не достигнет паритета, что всегда позже обещанного. Инкрементальный рефакторинг под тестами держит систему отгружаемой, а граничные случаи целыми на каждом шаге. Ты никогда не дальше, чем в одном зелёном прогоне тестов от деплоебельного состояния. Эта непрерывность, а не элегантность, — вот почему она побеждает.

Частая ошибка

Провальный сценарий правила бойскаута — позолота: «раз уж я здесь» становится лицензией переформатировать, переименовать и перепроектировать код, не связанный с твоим изменением. Диф раздувается с 8 строк до 800, ревьюер больше не может отделить твоё изменение поведения от косметической суеты, а твоё единственное настоящее изменение теперь погребено там, где может спрятаться регрессия. Хуже того, несвязанные правки не покрыты характеризующими тестами, которые ты написал для этого изменения, так что ты одновременно увеличил радиус поражения и понизил уверенность. Правило — «чуть чище», ограниченное областью, которую тебе и так пришлось тронуть и уже закрепить тестами. Если уборка манит тебя за пределы этого радиуса — заведи тикет, а не диф.

Проверь себя
Викторина

Ты добавляешь одно поле в непокрытую тестами функцию на 400 строк. По ходу чтения ты замечаешь три несвязанных код-смелла в соседних функциях и то, что выглядит латентным багом в редактируемой функции. Применяя алгоритм изменения и правило бойскаута, что ты делаешь?

Итог

Legacy-код — это код без тестов — отсутствие страховочной сетки, а не его возраст, — поэтому стратегия всегда сначала подведи под него сетку, а не «будь осторожнее». Алгоритм изменения: найди точку изменения, введи шов, чтобы зависимость стала подставляемой, напиши характеризующие тесты, закрепляющие текущее поведение (баги и всё прочее), внеси настоящее изменение под этой сеткой, затем рефактори теперь защищённый участок. Правило бойскаута — оставь его чуть чище — это инкрементальная дисциплина, которая держит кривую стоимости изменения плоской на сотнях коммитов и бьёт ловушку переписывания, которая выбрасывает с трудом добытые граничные случаи и навязывает параллельное сопровождение. Senior-дисциплина — оппортунистическая и ограниченная: улучшай только то, что внутри радиуса поражения, который ты и так тронул и уже покрыл тестами. Выйди за этот радиус — позолота соседнего кода, исправление найденных багов в том же дифе — и ты меняешь маленькое безопасное изменение на большое рискованное. Чуть чище, под тестами, внутри радиуса.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.