Характеризующие тесты
Перед рефакторингом легаси, которое ты не понимаешь до конца, характеризующие тесты фиксируют его текущее поведение — даже ошибочные части — так что рефакторинг не сможет тихо изменить поведение. Это сеть, делающая рефакторинг безопасным, и запись того, что код реально делает.
Тебе достаётся функция на 300 строк, которая считает стоимость доставки. Ни тестов, ни документации, автор уволился два года назад, а за ней — оплаченные счета: она несущая. Тебе нужно отрефакторить её, чтобы добавить нового перевозчика, но ты не до конца понимаешь, что она делает. Учебник говорит: «рефакторь под тестами». Тестов нет. Чтобы написать тесты, нужно знать правильный ответ. Правильного ответа ты не знаешь; ты знаешь только текущий ответ.
Именно этот разрыв — вся проблема легаси-кода, и из него есть точный выход. Ты тестируешь не то, что код должен делать, — ты пока не можешь. Ты тестируешь то, что он делает сейчас, фиксируешь это и только потом начинаешь менять структуру. Это и есть характеризующие тесты, и именно они — страховочная сеть, которая делает рефакторинг непонятного-тебе кода действительно безопасным.
После этого урока ты можешь зафиксировать поведение нетестированной легаси-функции характеризующими тестами (золотой эталон) — захватывая наблюдаемый вывод, а не предполагаемый; объяснить, почему это позволяет рефакторить, не меняя поведение тихо; и держать две раздельные задачи врозь — «зафиксировать, чтобы рефакторить безопасно» против «позже, осознанно изменить поведение собственным тестом», — так чтобы зафиксированный баг никогда не запер тебя в обязанности сохранять его навсегда.
Рефакторинг по определению — изменение, сохраняющее поведение, поэтому нужен способ обнаружить, что поведение сдвинулось. Смысл рефакторинга — улучшить структуру, не меняя того, что код делает. Единственный способ дать эту гарантию, а не надеяться на неё, — иметь нечто, что падает в тот момент, когда поведение сдвигается. На коде с настоящим набором тестов это нечто — сам набор. На нетестированном легаси у тебя нет ничего — поэтому любой «рефакторинг» на самом деле непроверенное переписывание, и баги, которые ты вносишь, всплывают в проде неделями позже, далеко от правки.
Сеть должна существовать до изменения. Её нельзя добавить после того, как ты уже сдвинул поведение, потому что тогда ты впечёшь дрейф в эталон.
Характеризующий тест утверждает фактический вывод кода, а не правильный. Это тот ход, что разрывает курицу-и-яйцо. Чтобы написать тест, не нужно знать правильный ответ; нужен текущий ответ, и код сам тебе его сообщит. Приём Физерса: вызови функцию, посмотри, что она возвращает, и впиши ровно это значение в утверждение — даже если оно выглядит неверным.
// Ты подозреваешь, что правильный ответ ~7.50, но ты не утверждаешь этого.
// Ты запускаешь код и утверждаешь то, что он реально выдал.
test('characterizes shippingCost for a 2kg domestic parcel', () => {
expect(shippingCost({ weightKg: 2, zone: 'domestic' })).toBe(6.99);
});Откуда взялось 6.99? Ты написал .toBe(0), запустил, сообщение об ошибке сказало Expected 0, Received 6.99, и ты вставил 6.99 обратно. Тест теперь проходит и фиксирует эту пару вход-выход. Ты не утверждаешь, что 6.99 верно. Ты утверждаешь «вот что система делает сегодня», и теперь любой рефакторинг, который это изменит, упадёт громко.
Фиксируй поведение, которое реально прогоняет код, включая уродливые ветки — покрытие путей важнее покрытия «счастливых» случаев. Сеть с дырами даёт поведению проскользнуть. Ветки, которые скорее всего сломаются при рефакторинге, — это странные: нулевой вес, фолбэк на неизвестную зону, причуда округления, ранний return, о котором никто не помнит. Характеризуй их осознанно. Быстрый способ их найти — скормить разнообразные входы и снять снапшот выводов массово — подход «золотого эталона» — так ты фиксируешь широкую полосу поведения дёшево, а не пишешь по одному утверждению вручную.
// Золотой эталон: фиксируем много пар вход/выход разом.
const cases = [
{ weightKg: 0, zone: 'domestic' },
{ weightKg: 2, zone: 'domestic' },
{ weightKg: 2, zone: 'intl' },
{ weightKg: 99, zone: 'unknown' }, // путь фолбэка
];
test('golden master: shippingCost across known paths', () => {
const observed = cases.map((c) => ({ ...c, cost: shippingCost(c) }));
expect(observed).toMatchSnapshot(); // первый прогон записывает, последующие сравнивают
});Первый прогон записывает снапшот — эта запись и есть характеризация. С этого момента набор кричит, если вывод любого пути сдвинется. Нацель сеть туда, где рефакторинг скорее всего соскользнёт, а не туда, где код очевидно прост.
Фиксация текущего поведения фиксирует и баги — и это нормально, пока ты держишь «зафиксировать» и «исправить» двумя раздельными, раздельно тестируемыми решениями. Это senior-режим отказа. Ты характеризуешь функцию, снапшот включает строку, говорящую, что международная доставка для 0кг возвращает 4.20, когда очевидно должно быть 0, и теперь твой зелёный набор закрепляет этот баг. Если ты относишься к характеризующему тесту как к спецификации, ты будешь бояться когда-либо его исправить, потому что исправление делает набор красным, а «тесты же проходили».
Дисциплина: характеризующий тест — это временное описание реальности, а не требование. Рефакторь под ним — зелёный значит «я сохранил поведение, включая ошибочные части, улучшая структуру». Затем, как отдельный коммит с собственным намерением, измени ошибочное поведение: напиши новый тест, утверждающий правильный 0, посмотри, как старая характеризующая строка падает, обнови её намеренно и отметь в коммите, что это осознанное изменение поведения, а не регрессия. Рефакторинг держит сеть зелёной; исправление делает её красной нарочно. Смешение этих двух — то, как команды каменеют от страха перед собственным легаси.
Оберни нетестированную функцию, затем рефактори под сетью. Вот легаси-функция — запутанная, но в проде:
// legacy: никто не уверен, что делает каждая ветка
function shippingCost(o: { weightKg: number; zone: string }): number {
let c = 0;
if (o.zone === 'domestic') c = 4.99 + o.weightKg * 1.0;
else if (o.zone === 'intl') c = 4.2 + o.weightKg * 3.5; // 4.2 базы даже при 0кг?
else c = 9.99; // неизвестные зоны
return Math.round(c * 100) / 100;
}Шаг первый — зафиксируй. Не рассуждай о корректности; запусти и вставь:
test('characterization: shippingCost (current behavior)', () => {
expect(shippingCost({ weightKg: 2, zone: 'domestic' })).toBe(6.99);
expect(shippingCost({ weightKg: 2, zone: 'intl' })).toBe(11.2);
expect(shippingCost({ weightKg: 0, zone: 'intl' })).toBe(4.2); // выглядит неверно, всё равно фиксируем
expect(shippingCost({ weightKg: 5, zone: 'mars' })).toBe(9.99); // фолбэк
});Шаг второй — рефактори структуру с зелёной сетью. Вынеси правила по зонам в таблицу; поведение не должно сдвинуться:
const RULES: Record<string, { base: number; perKg: number }> = {
domestic: { base: 4.99, perKg: 1.0 },
intl: { base: 4.2, perKg: 3.5 },
};
function shippingCost(o: { weightKg: number; zone: string }): number {
const r = RULES[o.zone];
const c = r ? r.base + o.weightKg * r.perKg : 9.99;
return Math.round(c * 100) / 100;
}Четыре характеризующих утверждения по-прежнему проходят — включая строку 4.2-при-0кг. Зелёный значит, что реструктуризация сохранила поведение, баг включён. Добавить нового перевозчика «mars» теперь — правка в одну строку. Вопрос с 4.2-при-0кг реален, но это отдельный тикет: новый тест, утверждающий 0, старая характеризующая строка обновлена намеренно, и коммит, который говорит «fix: intl base no longer charged at 0kg» — а не тихо подсунутый в рефакторинг, где ревьюер никогда не отличил бы исправление от регрессии.
▸Почему это работает
Зачем утверждать поведение, которое ты считаешь неверным, вместо того чтобы просто исправить его заодно? Потому что у рефакторинга и изменения поведения разные профили риска, и им нужны разные доказательства. Если ты исправляешь баг и реструктурируешь в один заход без сети, у тебя нет способа доказать, что реструктуризация не внесла второе, непреднамеренное изменение, прячущееся за тем, что ты намеревался сделать. Фиксация текущего поведения сначала изолирует переменную: под зелёной характеризующей сетью любое падение — это то, что сдвинул ты, так что корректность рефакторинга проверяема. Баг не забывается — он получает тикет. Смешение этих двух — то, как PR «уборки» тихо отгружает три никем не отревьюенных изменения поведения.
▸Частая ошибка
Ловушка — относиться к характеризующему снапшоту как к спецификации навсегда. Полгода спустя кто-то пытается исправить очевидный баг 4.2-при-0кг, золотой эталон краснеет, и рефлекс: «я сломал тесты, откатываю». Теперь баг защищён тестами — худший исход, потому что сеть, созданная разрешать изменение, теперь его запрещает. Защитись от этого маркировкой: называй их характеризующими тестами, оставь комментарий, что утверждаемое значение наблюдаемо, а не одобрено, и относись к красному характеризующему тесту во время осознанного исправления как к ожидаемому — ты обновляешь его намеренно. Тест, существующий чтобы описывать реальность, никогда нельзя путать с тестом, существующим чтобы защищать требование.
Ты пишешь характеризующий тест на легаси-код, и он утверждает, что международная доставка при 0кг возвращает 4.2, в чём ты довольно уверен — это баг. Ты прогоняешь структурный рефакторинг, и набор остаётся зелёным. Что значит здесь зелёный и что делать с 4.2?
Рефакторинг должен сохранять поведение, поэтому ему нужна сеть, падающая в тот миг, когда поведение сдвигается, — а на нетестированном легаси этой сети ещё нет. Характеризующие тесты создают её, утверждая фактический вывод кода, а не правильный: запусти функцию, вставь то, что она выдала, в утверждение, зафиксируй. Покрой уродливые ветки и используй снапшот золотого эталона, чтобы зафиксировать много путей дёшево. Сеть затем делает рефакторинг проверяемым — зелёный значит «поведение сохранено, включая баги». Эта последняя оговорка и есть режим отказа: характеризующий тест описывает реальность, он не требование, так что держи «зафиксировать, чтобы рефакторить» строго отдельно от позднего, осознанно тестируемого «изменить это поведение нарочно». Сделав это правильно, ты наконец сможешь реструктурировать код, который не понимаешь до конца, не меняя того, что он делает, тихо — и не начиная бояться когда-либо его исправить.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.