Легаси не лжёт

У легаси-кода нет документации. Если она и есть, ей три года. Тесты отсутствуют, а если есть, то сломаны и помечены skip. Комментарии противоречат коду. Автор уволился, а те, кто остался, оставили лишь напутствие: «тронешь — рванёт».

И тем не менее этот код прямо сейчас работает. Он обрабатывает платежи, принимает логины, оформляет заказы.

Документация лжёт. Комментарии лгут. Человеческая память лжёт ещё хуже. Не лжёт единственное — трафик, который реально течёт.

Так где же искать спецификацию? Не в вики. Не в Confluence. В nginx access log.

Курица и яйцо

Чтобы рефакторить легаси, нужна страховка. Когда вы что-то меняете, вы должны немедленно узнать, изменилось ли поведение. Эта страховка и есть тест.

Но в легаси тестов нет. Чтобы написать тест, нужно знать, что делает код. Чтобы знать, что делает код, нужно его прочитать. А прочитав, обнаруживаешь, что ни тестов, ни документации нет.

Что было раньше, курица или яйцо. Это классический тупик, который Майкл Фезерс назвал в Working Effectively with Legacy Code. В качестве ответа он предложил characterization test (характеризующий тест) — тест, который фиксирует не то, что код должен делать правильно, а то, что он делает сейчас. Правильно или нет — вопрос на потом. Сперва нужно зафиксировать текущее поведение, и только тогда можно к нему прикасаться.

Во времена Фезерса это писали руками. Вызываешь функцию, смотришь, что вышло, и записываешь полученное значение в expected как есть. Скучно, медленно — и поэтому никто не доводил до конца.

Но на уровне API эти «результаты вызовов функций» уже где-то накоплены. Каждый день, десятками тысяч. Внутри лог-файлов.

Лог за месяц и есть спецификация

Если собирать в течение месяца, можно захватить почти всё текущее поведение легаси-API.

nginx access log (1 месяц):
  endpoint · HTTP method · status code · timing
  частота вызовов → приоритет
  паттерны ошибок (401, 422, 500 …)

request/response body (захват через middleware или reverse proxy):
  пары нормальный запрос/ответ → поведение, которое должно проходить
  пары запрос/ответ с ошибкой → граничные случаи, которые нельзя ломать

Сложив эти две струи, получаем прямой перевод в Hurl-интеграционные тесты. Hurl — это формат, где HTTP-запрос и ожидаемый ответ записываются открытым текстом как есть. Одна пара трафика — «на этот запрос ушёл этот ответ» — это в точности один блок Hurl.

# POST /api/orders — частота вызовов #3, 12 тысяч в сутки
POST https://api.example.com/orders
Content-Type: application/json
{ "sku": "A-1024", "qty": 2 }

HTTP 201
[Asserts]
jsonpath "$.order_id" exists
jsonpath "$.status" == "pending"
jsonpath "$.total" == 49800

Этот тест не знает, «как должно работать API заказов». Он знает лишь, что «прямо сейчас оно работает вот так». И этого достаточно. В тот момент, когда рефакторинг изменит этот ответ, загорится красный.

Что выводится из логов автоматически:

  • какой endpoint реально используется → endpoint, к которому за месяц не обратились ни разу, — это мёртвый код. Кандидат на удаление перед рефакторингом.
  • паттерны нормальных ответов → базовые регрессионные тесты.
  • паттерны ошибок → настоящие граничные случаи, которые человек не способен вообразить. Это 422 и 500, порождённые реальными пользователями.
  • частота вызовов → приоритет тестов. Сперва фиксируем то, что идёт по 12 тысяч в сутки.

Последний пункт важен. Когда человек пишет тесты, он начинает с happy path, который помнит. У трафика нет такого предубеждения. Путь, который реально несёт нагрузку, и есть приоритет.

Двойная страховка

Этот подход применяется не в одиночку, а как один слой ратчет-конвейера, вытягивающего легаси в agent-operable.

nginx log (1 месяц) → автогенерация Hurl → фиксация текущего поведения легаси-API
        ↓
tsma → страховка на уровне функций (unit)
        ↓
filefunc → упорядочивание структуры кода (одна концепция — один файл)
        ↓
рефакторинг → Hurl проверяет сохранность поведения API (integration)

Суть в том, что страховка двухслойная.

  • tsma = страховка на уровне функций. Ловит изменения внутренней логики. Но даже при неизменной сигнатуре функции поведение всего endpoint может измениться.
  • Hurl from traffic = страховка на уровне API. Ловит сохранность контракта, видимого снаружи. Как бы вы ни перекраивали внутренности — лишь бы то, что входит и выходит снаружи, оставалось прежним, тест проходит.

Рефакторинг по определению — это «изменение внутренней структуры при сохранении внешнего поведения». Значит, определение того «внешнего поведения», что должно сохраниться, должно быть где-то зафиксировано. tsma держит внутреннюю границу, Hurl — внешнюю. Лишь когда оба слоя на месте, можно сказать агенту: «перекраивай как хочешь, а где сломалось — увидит машина».

Судья, которому нельзя польстить

Это в точности совпадает с сутью Symbolic Feedback Loop.

Если спросить агента «хорошо ли сделал рефакторинг?», он ответит «да, всё аккуратно прибрано». Дай ему мнение — он польстит. Но если прогнать Hurl, выйдет POST /orders → expected 201, got 500. Числа и статус-коды не льстят. Потому что у них нет эмоций.

Hurl-тест, извлечённый из трафика, — это спецификация, в которую не вмешалось человеческое суждение. Не «кто-то думает, что должно работать так», а «по наблюдениям, работало так». Это не утверждение, а измерение. И поэтому правильность рефакторинга может судить не человек, а машина. LLM — не судья, а исполнитель, а вердикт выносит детерминированный инструмент.

Предпосылка одна — хорошо записанный лог

Чтобы этот метод сработал, нужно ровно одно. Хорошо записанный лог за месяц.

Здесь «хорошо записанный» — это всё. Одного access log недостаточно. Он даёт endpoint, status code и timing, но не даёт главного, что нужно зафиксировать, — пары request body и response body. Зная только POST /orders → 201, нельзя воспроизвести «на этот вход ушёл этот выход». Чтобы зафиксировать функциональность, нужно держать в руках и то, что вошло, и то, что вышло.

Поэтому настоящий вопрос не «как написать тест», а «достаточно ли хорошо записан мой лог, чтобы стать спецификацией».

  • сохраняются ли request/response body или только status code.
  • сохраняются ли вместе и ответы с ошибками. Именно body у 422 и 500 — те граничные случаи, что человек не способен вообразить.
  • структурирован ли лог так, чтобы машина могла связать запрос и ответ в пару.

Если это есть, значит, вы уже месяц писали спецификацию. Отдельно писать тесты не нужно. Их за вас писал лог-конвейер. Если этого нет — достаточно прямо сейчас вставить один слой middleware и подержать его включённым месяц. Через месяц у вас в руках окажется всё текущее поведение легаси целиком.

Почему месяц, а не день. Дневной срез ловит только happy path. Месячный ловит длинный хвост системы — пакетную обработку в конце месяца, всплеск трафика перед расчётом, редко вызываемые админские endpoint, крон, что прокручивается раз в три часа ночи. Спецификация — это не среднее, а распределение.

Перевести лог в Hurl и зафиксировать функциональность

Когда лог готов, остальное механично. Скармливаете инструменту месячные пары request/response, и каждая пара переводится в блок Hurl. Сотни хлынувших таким образом Hurl-файлов и есть characterization-сьют — страховка, зафиксировавшая текущее поведение легаси целиком. Кода не прочитано ни строчки. Прочитан лишь утекший трафик.

Заранее обозначим место, где здесь обычно запинаются. «В логе есть персональные данные, платежи, токены — можно ли фиксировать это в тесте?»

Можно. Точнее, фиксировать это и не нужно. Этому методу значения изначально не нужны. characterization test фиксирует не значения, а поведение.

HTTP 201
[Asserts]
jsonpath "$.order_id" exists
jsonpath "$.total" == 49800

Здесь как спецификация важно не число 49800, а структура: «поле total существует как целое число и для данного входа вычисляется вот так». Замаскируйте значения или подмените их синтетическими данными — ценность спецификации почти не уменьшится. capture → маскирование → генерация Hurl: весь этот конвейер крутится внутри вашей инфраструктуры. Raw-логу некуда уходить. Остаётся лишь спецификация с закрытыми значениями, контракт, в котором сохранена только структура. То, что лог не нужно выпускать наружу, — это не уступка ради безопасности, а суть подхода: ведь изначально нужно зафиксировать только поведение.

Прогоните сгенерированный Hurl один раз на staging — и тут же разделится pass/fail. Если зажглись все зелёные, можно начинать рефакторинг. Скажите агенту крушить как угодно, а где сломалось — увидит Hurl.

Лестница, которую кладут без чтения кода

Так что настоящая ценность этого подхода не в том, что «быстро пишутся тесты». Настоящая ценность вот в чём.

  • старт без чтения кода — автор ушёл, документации нет, но одного лишь утекшего трафика хватает, чтобы подстелить страховку. Право прикоснуться к коду вы получаете прежде, чем поймёте его.
  • результат сразу верифицируем — прогоните сгенерированный Hurl на staging, и тут же выйдет pass/fail. Не «наверное сработает», а «прямо сейчас проходят 327 из 327».
  • данные не перелезают через забор — от capture до генерации Hurl всё заканчивается внутри вашей инфраструктуры. Чем сильнее зарегулирована отрасль, тем решительнее то, что можно стартовать, не выпустив наружу ничего.

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

Поток уже писал спецификацию

Мы силимся писать спецификацию отдельно. Руками верстаем OpenAPI, описываем поведение в вики, а когда они расходятся с кодом, называем это дрифтом и сокрушаемся.

Но живая система каждое мгновение сама писала свою спецификацию. Каждый раз, когда входит один запрос и выходит один ответ, это однострочное самоописание: «я — вот такая система». Лог-файл — это та автобиография, накопленная за месяц.

Мы просто её не читали.

У легаси не отсутствует документация. Документация лежит в access log, просто её форма неудобна для человеческого чтения. Переведите её в Hurl — и она станет запускаемой спецификацией, контрактом, который судит машина.

Документация лжёт. Трафик не лжёт.


Источники / основания

Ключевые концепции и инструменты

  • Michael Feathers. Working Effectively with Legacy Code. Prentice Hall, 2004. — источник концепции characterization test. «Фиксируется не то, что код должен делать правильно, а то, что он делает сейчас».
  • Проект Hurl (hurl.dev) — формат тестов HTTP-запрос/ответ открытым текстом. Интегрирован как один из 10 SSOT yongol.
  • Подтверждение на 527 функциях tsma — ратчет на уровне функций (tsma).

Извлечение тестов из трафика и исполнения (carving / record-replay)

  • Elbaum, Chin, Dwyer, Jorde (2009). “Carving and Replaying Differential Unit Test Cases from System Test Cases.” IEEE TSE 35(1). — академический фундамент differential unit test, который record-ит исполнение системы и replay-ит его на уровне юнитов.
  • Команда инженерии Meta (2024). “Observation-based Unit Test Generation at Meta.” FSE 2024, arXiv:2402.06111. — carving тестов из наблюдаемых значений исполнения приложения. 9,6 млн запусков в CI, обнаружено 5 702 дефекта. Промышленное подтверждение тезиса «наблюдение и есть тест».

Фиксация текущего поведения (snapshot / golden master)

  • Fujita, Kashiwa, Lin, Iida (2023). “An Empirical Study on the Use of Snapshot Testing.” ICSME 2023. — эмпирическое подтверждение применения snapshot (= golden master/characterization) тестов. «Фиксируется не правильность, а текущий вывод, чтобы засекать изменения».

Страховка под рефакторинг

  • Kim, Zimmermann, Nagappan (2014). “An Empirical Study of Refactoring Challenges and Benefits at Microsoft.” IEEE TSE 40(7). — подтверждение того, что без тестов, гарантирующих сохранение поведения, рефакторинг становится издержкой и риском.
  • Yoo, Harman (2012). “Regression Testing Minimization, Selection and Prioritization: A Survey.” STVR 22(2). — стандартное определение регрессионного теста = «уверенность в том, что изменение не повредило прежнее поведение».

Реальное распределение использования и есть приоритет

  • John D. Musa (1993). “Operational Profiles in Software-Reliability Engineering.” IEEE Software 10(2). — если распределять тесты по частоте использования, то даже при остановке из-за сроков сильнее всего проверена самая используемая функциональность. Классический фундамент тезиса «распределение трафика вместо смещения к happy path».

Почему судить должна машина (LLM — не судья, а исполнитель)

  • Huang, Chen, Mishra, et al. (2024). “Large Language Models Cannot Self-Correct Reasoning Yet.” ICLR 2024, arXiv:2310.01798. — без внешней обратной связи LLM не способна исправить собственные рассуждения. Почему нужен детерминированный внешний верификатор.

  • Sharma, Tong, et al. (2024). “Towards Understanding Sycophancy in Language Models.” ICLR 2024, arXiv:2310.13548. — RLHF обучает соглашательству, разрушая надёжность самосуждения LLM.

  • Обложка: сгенерировано ИИ (Google Gemini)

Что почитать заодно

  • Michael Feathers, “Characterization Testing” — текст автора термина. «В тот момент, когда софт попадает в продакшн, он сам становится своей спецификацией (it becomes its own specification)». Тезис, почти совпадающий с заголовком этой статьи.
  • Официальный туториал Hurl, “Your First Hurl File” — от GET / HTTP 200 до режима --test. Введение, где буквально берёшь в руки тот факт, что одна строка открытого текста и есть тест.
  • GitHub Engineering, “Scientist: Measure Twice, Cut Once” — библиотека, что одновременно запускает в продакшне легаси-код (control) и новый (candidate) и сравнивает результаты. «Только реальное поведение — настоящая спецификация».
  • Twitter Diffy (обзор на InfoQ) — прокси, что шлёт один и тот же запрос в новый и старый сервис и ловит как регрессию лишь разницу ответов. Классический прецедент тезиса «фиксировать поведение без написания тестов».
  • GoReplay — инструмент, что захватывает живой HTTP-трафик с сетевого интерфейса и реплеит его на staging. Образцовая реализация тезиса «продакшн-трафик как тестовый вход».
  • Nicolas Carlo, “Characterization vs Approval Tests” — разбор трёх по сути одинаковых терминов и акцент на роли «Printer», вычищающего из вывода чувствительные данные.
  • Pact — consumer-driven contract testing. Подход «явного контракта» в противовес фиксации трафика. Если смотреть на оба способа вместе, складывается баланс.

Связанные статьи