Легаси не лжёт
У легаси-кода нет документации. Если она и есть, ей три года. Тесты отсутствуют, а если есть, то сломаны и помечены 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. Подход «явного контракта» в противовес фиксации трафика. Если смотреть на оба способа вместе, складывается баланс.
Связанные статьи
- Hurl останавливает дрифт — как объявлять HTTP-контракт открытым текстом и запирать его в CI. Если эта статья — «трафик → Hurl», то та — «запирание дрифта через Hurl».
- tsma — рубеж регрессионной обороны легаси-кода — внутренняя граница двойной страховки (уровень функций). Если Hurl — внешняя граница, то tsma — внутренняя.
- Agent Operable Codebase — трёхступенчатый конвейер, вытягивающий легаси в код, с которым может работать агент.
- Почему кодинг-агенты работают и почему рушатся — структура Symbolic Feedback Loop.
- Ограничение — это контракт — тест как верифицируемый и принудительно исполнимый контракт.
- Как спасти провальный вайб-кодинг — практическая лекция: диагностика → запирание → починка → извлечение → перевод легаси через characterization testing.