Как безопасно менять унаследованное бизнес-приложение без тестов — характеризационные тесты и рефакторинг на практике

· Обновлено: · · Унаследованные технологии, Использование существующих активов, Рефакторинг, Проектирование тестов, Характеризационные тесты, C#, .NET, Сопровождение, Таблица решений, Техническая консультация

История изменений (2 обновлений, последнее 31 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Текст статьи исправлен, чтобы соответствовать японскому оригиналу.
Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21620044)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Как безопасно менять унаследованное бизнес-приложение без тестов — характеризационные тесты и рефакторинг на практике. KomuraSoft LLC. https://comcomponent.com/ru/blog/characterization-test-legacy-refactoring/

DOI (зарегистрированный архив)
10.5281/zenodo.21620044
DOI (последняя зарегистрированная версия)
10.5281/zenodo.21620045

«Я знаю, что нужно исправить. Но стоит представить, что из‑за этого сломается что‑то ещё, — и трогать уже страшно». Так часто говорят те, кому досталось бизнес‑приложение без тестов.

У многих приложений на VB6, .NET Framework или Access автотестов нет вообще. Спецификацию давно перестали обновлять, и фактически «код — единственная спецификация». Бизнес при этом не стоит: смена ставки налога на потребление, правка макета печатной формы, новый контрагент — такие запросы не ждут.

В этом блоге в статье «До каких пор будут работать приложения VB6 — поддержка рантайма и реалистичный путь миграции на .NET» мы разбирали подход, при котором старую систему считают «работающей спецификацией» и двигают миграцию, сверяя с ней вывод. Здесь та же идея, но в другой ситуации: не миграция, а правки прямо в коде, который сейчас в эксплуатации. Главный инструмент — характеризационный тест (characterization test). Даже если тестов нет, заранее зафиксируйте тестом поведение, которое вы можете сломать, — и рефакторинг, и добавление функций становятся заметно безопаснее.

Кому адресована статья и что предполагается. Опыт с автотестами не нужен: можно начинать с нуля, не написав ни одного теста. Нужны только два условия: (1) целевое приложение собирается у вас локально (есть исходники и рабочая среда сборки); (2) у вас есть право менять исходный код. Примеры на C# (запись подходит и для .NET Framework, и для .NET), но сам подход от языка не зависит. Если исходников нет или проект не собирается, приёмы из статьи напрямую не применить — сначала нужна подготовка на предыдущем шаге.

1. Сначала выводы

  • Не бросайтесь сразу чинить. Сначала зафиксируйте текущее поведение тестом. Даже без спецификации вывод работающего сейчас кода и есть спецификация.
  • Инструмент для этого — характеризационный тест. Он записывает не «правильное поведение», а «текущее поведение»: печатные формы, CSV, результаты расчёта и другой вывод сохраняют как эталон и сравнивают diff до и после изменения (метод golden master).
  • Если тест некуда вставить (логика прямо в обработчике UI‑события, прямые обращения к DateTime.Now или к пути файла), минимальными правками — извлечением метода и подменой зависимости через интерфейс — сделайте «шов» (seam). Крупная переделка не нужна.
  • Не смешивайте рефакторинг и добавление функций в одном коммите. Для рефакторинга критерий успеха — «нулевой diff», для новой функции — «только запланированный diff». Смешаете — и уже не поймёте, что означает diff.
  • Насколько глубоко строить тестовую инфраструктуру, решают три фактора: масштаб доработки × оставшийся срок жизни системы × последствия сбоя. Покрыть всё модульными тестами — не всегда верный ответ; иногда правильнее «только характеризационные тесты» или «не трогать».
  • Начать можно и без CI. Один тестовый проект и папка с эталонными файлами уже сильно повышают безопасность, даже если тесты запускают только вручную.

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 19, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Почему унаследованный код «ломается от прикосновения»

Дорабатывать унаследованный код страшно не потому, что он старый, а потому что нет способа проверить, верен ли результат изменения.

Майкл Физерс в книге «Working Effectively with Legacy Code» определил унаследованный код не как «просто старый код», а как «код без тестов».1 Без тестов нет быстрого способа на каждом изменении понять, код стал лучше или хуже. По этому определению даже вчерашний код — унаследованный, если тестов нет.

В коде без тестов раскручивается такой цикл:

  1. Тестов нет, область влияния изменения не видна — страшно
  2. Страшно — существующую структуру не трогают и обходятся минимальным копированием и ещё одним условным ветвлением
  3. Такие заплатки копятся, код становится ещё менее читаемым и ещё более хрупким
  4. Из‑за хрупкости страшнее ещё сильнее (возврат к пункту 1)

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

Чтобы разрешить это противоречие, доработку унаследованного кода ведут в таком порядке:1

  1. Только вокруг изменяемого места зафиксировать текущее поведение снаружи (характеризационный тест)
  2. Внутри этой сетки сделать минимальное изменение с очень низким риском что‑то сломать (например, извлечение метода) и получить точку, куда можно вставить тест
  3. Когда структура позволит писать точечные тесты, приступить к задуманному изменению (рефакторинг или добавление функций)

В следующих разделах разберём пункты 1 и 2.

3. Характеризационный тест — запись «текущего поведения»

3.1 Чем это отличается от обычного теста

Обычный тест проверяет правильное поведение: «по спецификации должно быть так». Характеризационный тест устроен иначе. Он записывает, как код на самом деле ведёт себя сейчас, и сознательно откладывает вопрос, правильно это или нет.

Допустим, в спецификации не сказано, как обрабатывают дробную часть суммы — округлением или усечением. Если действующий код усекает, и бизнес так работает уже десять лет, то «усечение» — как минимум фактическая спецификация. Характеризационный тест фиксирует это как есть: «текущий вывод такой‑то». Даже если позже окажется, что это дефект, сначала его фиксируют. Менять поведение (исправлять дефект) нужно отдельно, как намеренное изменение, уже после того как сетка натянута.

3.2 Метод golden master по шагам

Для унаследованного кода, у которого единица вывода достаточно крупная, метод golden master — самый выгодный по трудозатратам вид характеризационного теста. Процедура простая:

  1. Определить, какой вывод даёт изменяемая функция (текст печатной формы, CSV, список результатов расчёта и т. п.)
  2. Подготовить типичные входные данные, выполнить действующий код и получить вывод
  3. Сохранить этот вывод как есть в эталонный файл (golden master) и закоммитить его в репозиторий
  4. Дальше при каждом изменении кода запускать тест и проверять, что diff между выводом и эталонным файлом равен нулю

Реализация на C# не требует отдельной библиотеки — достаточно такой простой записи:

[Fact]
public void 月次請求一覧_ルデンマスタ()
{
    // 1. Читаем типичные входные данные (например, маскированные данные из продакшена)
    var input = File.ReadAllLines(TestDataPath("billing-input-202606.csv"));

    // 2. Вызываем существующую логику как есть и получаем строку вывода
    string actual = BillingReport.Generate(input);

    // 3. Отсутствующий эталонный файл — это либо «сломано тестовое окружение»,
    //    либо «первый запуск». В любом случае молча пропускать нельзя:
    //    записываем результат и безусловно проваливаем тест
    string expectedPath = TestDataPath("billing-expected-202606.txt");
    if (!File.Exists(expectedPath))
    {
        File.WriteAllText(expectedPath + ".candidate", actual);
        Assert.Fail("Эталонного файла нет. Просмотрите содержимое файла " +
                    ".candidate и, если оно верно, закоммитьте его как эталон.");
    }

    // 4. Проверяем полное совпадение с сохранённым поведением
    string expected = File.ReadAllText(expectedPath);
    Assert.Equal(expected, actual);
}

Не делайте так, чтобы при отсутствии эталона текущий вывод сразу сохранялся как эталон, а тест при этом проходил. Если эталон забыли закоммитить или положили не туда, CI пройдёт зелёным и регрессию не заметит. Первую запись стоит делать как выше: вывести файл‑кандидат (.candidate) и явно провалить тест, чтобы поток был односторонним — сначала человек смотрит результат, и только потом он попадает в репозиторий как эталон.

Сообщения Assert.Equal при появлении diff для разбора мало. На практике при провале дополнительно пишите фактический вывод в отдельный файл вроде billing-actual-202606.txt, чтобы сравнить его с эталоном через WinMerge или другой инструмент diff.

Этот пример предполагает, что существующую логику BillingReport.Generate можно вызвать из тестового проекта как есть. На унаследованных системах обычно сначала спотыкаются именно здесь; как это связать, разобрано в разделе 3.4.

В литературе этот приём называют по‑разному: кроме golden master testing встречаются approval testing и snapshot testing. Ту же идею упаковали в библиотеки; для .NET типичные варианты — ApprovalTests.Net и Verify. Они берут на себя то, что в коде выше сделано вручную: соглашение об именах эталонных файлов, автозапуск инструмента diff, подтверждение эталона. Имеет смысл начать с простой реализации, как выше, и подключать библиотеку, когда эталонных файлов станет много и ими неудобно управлять. Искать материалы лучше по запросам «approval testing» и «snapshot testing», а не «golden master» — так находится больше.

3.3 Как выбирать входные данные и нормализовать вывод

Входные данные берут по принципу «типичное плюс граница». Один‑два обычных случая, плюс входы, которые проходят ветвления, найденные при чтении кода: закрытие периода в конце месяца, ноль записей, отрицательные значения, особая обработка для конкретного контрагента. Если можно взять маскированные данные из продакшена, они лучше всего покрывают реальные ветвления.

Недетерминированные значения в выводе перед сравнением нормализуют. Дата и время печати, длительность обработки, GUID, автонумерация и подобное меняются на каждом запуске, поэтому в исходном виде каждый раз дают diff. После генерации вывода сделайте предобработку — например, регулярным выражением замените 印刷日時: 2026/07/17 16:00 на 印刷日時: <DATE> — и только затем сравнивайте.

Ориентир, какой вывод хорошо подходит для golden master:

Тип вывода Пригодность Комментарий
CSV, файлы фиксированной длины Сохраняются и сравниваются как есть. Первая цель, за которую стоит браться
Печатные формы (текст или исходные данные предпросмотра печати) Берите строку до превращения в PDF; сравнение двоичных PDF лучше не делать
Список результатов расчёта (суммы, остатки на складе и т. п.) Можно добавить тестовый метод, который выгружает результаты в CSV
То, что пишется в БД После записи сделайте SELECT по таблице, выгрузите в CSV и сравните
Сам вид экрана Подойдёт, если удаётся свести к строке. Автоматизация действий на экране — другой инструментарий, он разобран в статье «Автоматизированное UI‑тестирование desktop‑приложений Windows»
Отправка во внешнюю систему Нужен шов (следующий раздел), чтобы перехватить данные непосредственно перед отправкой

3.4 Как вызвать существующее приложение из тестового проекта

Существующее WinForms / WPF‑приложение — это EXE‑проект. Типичный первый барьер унаследованной доработки: «тестовый проект добавили, а классы основного приложения из него не видны». Ниже — как это связать.

1. Добавьте один тестовый проект. В существующее решение добавляют новый тестовый проект. Даже оставаясь на .NET Framework, можно взять MSTest, NUnit или xUnit. Целевой фреймворк тестового проекта должен совпадать с основным (если основное приложение на .NET Framework 4.8, тестовый проект тоже 4.8). Если они расходятся, уже при добавлении ссылки появятся предупреждения или ошибки загрузки.

2. Добавьте ссылку на основной проект. Способа два; по умолчанию берите первый.

Как связать Когда применять Как сделать
Ссылка на проект (предпочтительно) Есть исходники основного приложения, оно собирается в том же решении ПКМ по тестовому проекту → Добавить ссылку → Проект → выбрать EXE‑проект основного приложения. EXE‑проект тоже сборка, на него можно ссылаться (мнение «на EXE ссылаться нельзя» — заблуждение)
Ссылка на файл DLL / EXE Основное приложение нельзя включить в решение, на руках только собранные двоичные файлы Добавить ссылку → Обзор → указать EXE / DLL из bin основного приложения. Следите, чтобы после каждой пересборки основного приложения ссылка не указывала на устаревший файл

Направление ссылки только одно: тест → основное приложение. Если основное приложение ссылается на тесты, получится циклическая ссылка.

3. Чтобы тестировать internal как есть, используйте InternalsVisibleTo. Как будет видно в главе 4, при извлечении метода логику часто оставляют internal (тестировать можно, не расширяя открытый API). Тогда в сборку основного приложения добавляют одну строку атрибута.2

// В AssemblyInfo.cs основного приложения или в начале любого исходного файла
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("MyApp.Tests")]

Два ограничения.2

  • Состояние подписи основного приложения и тестов должно совпадать. Либо обе сборки без подписи, либо обе со строгим именем. Если у основного приложения строгое имя, пишите InternalsVisibleTo("MyApp.Tests, PublicKey=0024...") — нужен полный открытый ключ, а не токен открытого ключа. Ключ извлекают командами sn -p и sn -tp.
  • private по‑прежнему не виден. InternalsVisibleTo открывает только internal / protected internal / private protected. Если захотелось тестировать private‑метод, это обычно знак, что класс слишком большой: проще вынести логику извлечением метода из главы 4.

4. Проверьте, куда кладутся файлы данных. Текущий каталог при запуске тестов — выходная папка тестов (bin\Debug\...). Эталонные файлы и входные CSV кладите в папку TestData и в свойствах файла поставьте «Копировать в выходной каталог» = «Копировать, если новее» — тогда TestDataPath из раздела 3.2 пишется прямолинейно. Если основное приложение читает app.config или другой файл настроек, тестовому проекту часто нужен такой же набор настроек.

Когда это сделано, golden master из раздела 3.2 можно писать как есть.

4. Как сделать «шов» (seam), чтобы вставить тест

Стоит начать писать golden master — и во многих унаследованных приложениях упираешься в стену: логика сидит прямо в обработчике UI‑события, без открытия экрана её не запустить. Нужно место, где тестовый код может подставить своё поведение и наблюдать результат, — то, что Физерс называет швом (seam).1

4.1 Отделяем логику от UI извлечением метода

Типичное «до»: расчёт, доступ к БД, зависимость от времени и обновление экрана живут в одном обработчике события.

// До: всё написано прямо в обработчике события
private void btnCalc_Click(object sender, EventArgs e)
{
    var rows = LoadRowsFromDb();                          // прямой доступ к БД
    var now = DateTime.Now;                               // зависимость от текущего времени
    decimal total = 0;
    foreach (var row in rows)
    {
        if (row.SalesDate.Year == now.Year &&
            row.SalesDate.Month == now.Month)              // агрегируем только текущий месяц
        {
            total += Math.Floor(row.Amount * 1.1m);        // бизнес-правило: обработка дробной части
        }
    }
    lblTotal.Text = total.ToString("N0");                  // сразу обновляем экран
}

В таком виде для теста агрегации за текущий месяц нужны экран, БД и «сегодняшняя дата». Стандартный ход с минимальными правками — извлечь только расчёт в отдельный метод и превратить внешние зависимости (строки из БД и текущее время) в параметры. Рефакторинг Visual Studio «Извлечь метод» (Ctrl+R, M) снижает риск ошибок при ручном переписывании.3

// После: извлечён только расчёт; «строки из БД» и «текущее время»
// принимаются как параметры
internal static decimal CalcMonthlyTotal(IEnumerable<SalesRow> rows, DateTime now)
{
    decimal total = 0;
    foreach (var row in rows)
    {
        if (row.SalesDate.Year == now.Year &&
            row.SalesDate.Month == now.Month)
        {
            total += Math.Floor(row.Amount * 1.1m);
        }
    }
    return total;
}

private void btnCalc_Click(object sender, EventArgs e)
{
    var rows = LoadRowsFromDb();
    lblTotal.Text = CalcMonthlyTotal(rows, DateTime.Now).ToString("N0");
}

Обработчик события сжимается до трёх строк — загрузка, расчёт, отображение, — а извлечённый метод можно тестировать с любыми строками и любой датой. Граничные случаи, связанные со временем (конец месяца, начало месяца, високосный год), воспроизводятся простой передачей даты вроде new DateTime(2028, 2, 29).

4.2 Делаем зависимости подменяемыми через интерфейс

Если одной параметризации мало (DateTime.Now вызывают повсюду, путь к файлу зашит в код и т. п.), зависимость оборачивают в интерфейс и подставляют реализацию. В рекомендациях Microsoft Learn по модульному тестированию для .NET прямая зависимость от DateTime.Now тоже приводится как классический пример того, что из теста не контролировать, и в качестве решения описывается обёртка в интерфейс, чтобы ввести шов.4

public interface IClock
{
    DateTime Now { get; }
}

public sealed class SystemClock : IClock
{
    public DateTime Now => DateTime.Now;
}

// В тесте подставляем реализацию, которая возвращает фиксированное время
public sealed class FixedClock : IClock
{
    private readonly DateTime _fixed;
    public FixedClock(DateTime value) => _fixed = value;
    public DateTime Now => _fixed;
}

Если добавить IClock в конструктор существующего класса, придётся править все места вызова. На переходный период реалистично рядом оставить конструктор без параметров, который по умолчанию берёт SystemClock, и постепенно поправлять вызывающий код. Зашитые пути к файлам или строки подключения к БД оборачивают так же — небольшим интерфейсом только с операциями «прочитать / записать».

В Visual Studio есть встроенный рефакторинг извлечения интерфейса из существующего класса (Extract Interface); такие правки можно делать механически.3

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

5. Таблица решений: насколько глубоко заходить

И характеризационные тесты, и создание швов стоят трудозатрат. Довести весь унаследованный код до одного уровня покрытия в небольшой или средней команде нереалистично, да и не нужно. Решение принимают по трём осям:

  • Масштаб доработки: исправление дефекта в несколько строк, добавление функции или изменение, которое затрагивает структуру
  • Оставшийся срок жизни системы: миграция или вывод из эксплуатации через год‑два либо эксплуатация ещё 5 лет и дольше
  • Последствия сбоя: косметическая порча вида печатной формы или неверная сумма счёта / остаток на складе
Масштаб доработки Оставшийся срок жизни Последствия сбоя Рекомендуемый уровень
Небольшой (несколько строк, смена настроечного значения) Короткий (до ~2 лет) Малые (косметические искажения отображения) Только характеризационные тесты. Зафиксировать соответствующий вывод, внести изменение, проверить нулевой diff — и закончить
От небольшого до среднего Короткий Большие (деньги или склад) Только характеризационные тесты, но плотнее. Расширить набор входных данных, включая граничные случаи
Средний (добавление функции, изменение логики) Долгий (от 5 лет) От малых до средних Характеризационные тесты плюс модульные тесты только вокруг изменяемого места (сделать шов)
От среднего до крупного Долгий Большие Характеризационные тесты плюс тестовая инфраструктура плюс дробление релиза на более мелкие единицы
Крупный (нужна структурная переделка) Короткий Не трогать. Не дорабатывать, обходиться эксплуатационными мерами, трудозатраты направить на миграцию / замену
— (запроса на доработку нет) Не трогать. Не делать профилактический рефакторинг кода, который и так работает

Две нижние строки «не трогать» — не пассивный выбор по умолчанию, а активное решение. Вложения во внутреннее качество системы с коротким оставшимся сроком жизни не окупаются. Эти трудозатраты лучше направить на решение о миграции из статьи «Продление жизни и миграция бизнес‑приложений VB6 / Access — таблица решений: оставить, обернуть или заменить» и на проектирование целевого решения.

Если дело доходит до модульных тестов, нужна граница: что писать как модульный тест, а что оставить интеграционному (с реальной БД или реальными файлами). Эта граница разобрана как таблица решений в статье «Как провести границу между модульными и интеграционными тестами» — имеет смысл читать её вместе с этой. Модульные тесты по природе должны быть быстрыми, изолированными и повторяемыми,4 поэтому характеризационные тесты, которые ходят в БД или файловую систему, разумнее выносить в отдельный проект или отдельную единицу запуска.

6. Правила эксплуатации — как не сломать страховочную сетку

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

6.1 Не смешивайте рефакторинг и добавление функций в одном коммите

Рефакторинг — это изменение, которое делает код понятнее и удобнее в сопровождении, не меняя поведения.5 Значит, критерий успеха — нулевой diff с golden master. У добавления функций и исправления дефекта критерий успеха наоборот: появился только запланированный diff. Смешаете оба вида изменений в одном коммите — и при появлении diff уже не отличите «запланированное изменение» от «поломки».

Тип изменения Что делать с golden master Критерий успеха
Рефакторинг (изменение структуры) Не обновлять Нулевой diff
Исправление дефекта / добавление функции (изменение поведения) Обновить после ревью diff Только запланированный diff
Создание шва (извлечение метода, подмена через интерфейс) Не обновлять Нулевой diff
Смена правила нормализации эталона Перегенерировать Причину изменения указать в сообщении коммита

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

6.2 Эталон обновляют в порядке «сначала ревью diff, потом перезапись»

Когда поведение изменили намеренно, обновите и golden master. Зафиксируйте порядок:

  1. Сгенерировать вывод после изменения и глазами просмотреть diff с текущим эталоном
  2. Убедиться, что diff состоит только из запланированного изменения (если сдвинулась хотя бы одна незапланированная строка — разбирать)
  3. Перезаписать эталонный файл новым выводом и положить его в тот же коммит, что и код, чтобы это осталось в истории вместе

Опасный сценарий: «тест стал красным, перезапишем эталон, чтобы стал зелёным». Так вы фиксируете регрессию как «правильное поведение», и сетка перестаёт быть сеткой.

Быстрее всего это видно на реальном diff. Допустим, доработка — «к товарам со ставкой налога на потребление 10% добавить льготную ставку 8%», и diff с эталоном выглядит так:

  2026/06/30,A-Торг,Канцтовары,       10000,   1000,  11000
- 2026/06/30,A-Торг,Напитки(льгота),      5000,    500,   5500
+ 2026/06/30,A-Торг,Напитки(льгота),      5000,    400,   5400
  2026/06/30,A-Торг,Подытог,           15000,   1500,  16500
- 2026/06/30,B-Пром,Детали машин,      200000,  20000, 220000
+ 2026/06/30,B-Пром,Детали машин,      200000,  20001, 220001

Верхние две строки (налог в строке со льготной ставкой изменился с 500 на 400) — запланированное изменение. Это и есть цель доработки, эталон можно обновить. Нижние две строки, где на 1 иену съехал налог у B-Пром, к льготной ставке отношения не имеющий, — это регрессия. Скорее всего, задели общую функцию обработки дробной части. Если здесь сказать «ну это же одна иена» и перезаписать эталон, дальше это смещение на 1 иену зафиксируется как «правильное поведение».

Правило умещается в одно предложение. Не обновляйте эталон, если по каждой строке diff не можете объяснить, почему она изменилась. Есть хотя бы одна необъяснимая строка — разбирайте причину. В примере выше можно ещё заметить, что строка Подытог не обновилась (если изменилась строка со льготной ставкой, должен измениться и промежуточный итог). Строки, которые должны были измениться, но не изменились, тоже предмет ревью diff.

6.3 Даже без CI соберите минимальную конфигурацию, которая крутится локально

Даже если сервера CI нет, с такой минимальной конфигурацией можно начать сегодня:

  • Добавить в решение один тестовый проект (на .NET Framework тоже работают MSTest, NUnit и xUnit; как связать — раздел 3.4)
  • Эталонные файлы и входные данные хранить в папке TestData и версионировать вместе с кодом
  • Сделать командным правилом вручную запускать тесты перед каждым коммитом
  • В инструкцию по релизу добавить одну строку — «запустить тесты и убедиться в нулевом diff», — чтобы проверку не забывали

Запускать можно не только через dotnet test. В решении со старыми (не SDK‑style) csproj dotnet test иногда ведёт себя не так, как ожидаете. Тогда есть два варианта.

  • Обозреватель тестов Visual Studio. После сборки тесты подхватываются сами, запускать можно из GUI. Для тех, кто тесты ещё не писал, так обычно проще начать.
  • vstest.console.exe. Средство командной строки, которое запускает уже собранную тестовую DLL по прямому пути; вызывается из Developer Command Prompt.6
vstest.console.exe MyApp.Tests\bin\Debug\MyApp.Tests.dll /logger:trx

Что выбрать — зависит от среды. Важно другое: правило «перед коммитом прогнать хотя бы один раз».

Когда сверяют результаты тестов с реальным выводом приложения, расследование идёт тем быстрее, чем лучше журналирование. Что стоит писать в лог, разобрано в статье «Минимальные требования к своему логгеру и чек‑лист интеграционных тестов».

7. Итог

  • Унаследованный код — это «код без тестов»,1 и настоящая причина, по которой он ломается от прикосновения, в том, что нет способа проверить результат изменения. Прежде чем править, зафиксируйте текущее поведение тестом.
  • Характеризационный тест записывает не «правильное поведение», а «текущее поведение». Метод golden master (approval testing / snapshot testing) — сохранить печатные формы, CSV или результаты расчёта как есть в эталонные файлы и сравнивать diff — можно начать с обычного кода на C#, без специальных библиотек.
  • Первый барьер — вызвать код существующего EXE из тестового проекта. На EXE‑проект тоже можно дать ссылку проекта, а чтобы тестировать internal как есть, в основное приложение добавляют одну строку InternalsVisibleTo (раздел 3.4).2
  • Если тест некуда вставить, сделайте шов (seam) через извлечение метода и подмену зависимости через интерфейс. Обёртка такой зависимости, как DateTime.Now, — стандартный приём, он же описан в рекомендациях Microsoft по модульному тестированию.43
  • Насколько глубоко строить инфраструктуру, решают масштаб изменения × оставшийся срок жизни × последствия сбоя. «Только характеризационные тесты» или «не трогать» — полноценные решения.
  • В эксплуатации не смешивайте рефакторинг (критерий успеха — нулевой diff) и добавление функций (критерий успеха — только запланированный diff),5 и обновление эталона всегда пропускайте через ревью diff. Даже без CI одно командное правило гонять тесты локально сильно меняет уровень безопасности.

Похожие статьи

Смежные области консультаций

В Komura Software LLC мы занимаемся внедрением характеризационных тестов в существующие бизнес‑приложения без тестов, поэтапным рефакторингом к тестируемой структуре и тем, куда вкладываться — в доработку или в миграцию.

Справочные материалы

  1. Michael C. Feathers, “Working Effectively with Legacy Code” (Prentice Hall, 2004). Русский перевод: «Эффективная работа с унаследованным кодом». Об определении унаследованного кода как «кода без тестов», о том, чтобы сначала зафиксировать текущее поведение характеризационным тестом (characterization test) и только потом менять код, и о понятии шва (seam), через который в код вводят тест.  2 3 4

  2. Microsoft Learn, InternalsVisibleToAttribute Class. Атрибут делает типы и члены, которые обычно видны только внутри той же сборки, видимыми указанной дружественной сборке. Действует на internal / protected internal / private protected, но не на private. Текущая сборка и дружественная должны быть либо обе без подписи, либо обе со строгим именем; при строгом имени указывают полный открытый ключ, а не токен открытого ключа — его получают через sn -p и sn -tp 2 3

  3. Microsoft Learn, Extract and inline refactorings (Visual Studio). О порядке действий для рефакторингов Visual Studio «Извлечь метод» (Ctrl+R, M) и «Извлечь интерфейс» (Extract Interface) для C# / Visual Basic.  2 3

  4. Microsoft Learn, Unit testing best practices for .NET. О свойствах хорошего модульного теста (fast / isolated / repeatable / self-checking / timely), о приёме обернуть неконтролируемую зависимость вроде DateTime.Now в интерфейс, чтобы ввести шов (seam), и о том, что зависимости от инфраструктуры не стоит тащить в модульные тесты, а нужно выносить в интеграционные.  2 3

  5. Microsoft Learn, Refactor code (Visual Studio). Рефакторинг определяется как процесс изменения кода, чтобы его было проще сопровождать, понимать и расширять, без изменения поведения.  2

  6. Microsoft Learn, VSTest.Console.exe command-line options. VSTest.Console.exe — средство командной строки для запуска тестов: можно указать тестовый файл (DLL) напрямую и вызывать его из Developer Command Prompt. 

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

Как создать и эксплуатировать службу Windows — от выбора между Планировщиком заданий и службой до превращения BackgroundService в службу

Стоит ли держать постоянно работающую обработку как службу Windows или хватит Планировщика заданий. Практический разбор: таблица выбора, ...

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Что такое характеризационный тест (characterization test)?
Это тест, который записывает не «правильное поведение», а «текущее поведение» как есть. В унаследованном коде без актуальной спецификации часто просто нечем проверить, что считать правильным. Поэтому сначала сохраняют вывод работающего сейчас кода (печатную форму, CSV, результат расчёта и т. п.) как эталон и затем механически проверяют, что до и после изменения вывод совпадает. Базовый приём: сначала натянуть эту страховочную сетку из зафиксированного поведения и только потом переходить к рефакторингу или добавлению функций.
С чего начать, если в унаследованном коде вообще нет тестов?
Реалистичный путь — писать характеризационные тесты только вокруг того места, которое собираетесь менять. Покрыть тестами всю систему почти никогда не окупается по трудозатратам, да и не нужно. Сначала определите, какой вывод даёт изменяемая функция (печатная форма, CSV, то, что пишется в БД, и т. п.), сохраните в файл вывод на типичных входных данных и зафиксируйте его. Внутри этой сетки сделайте небольшой рефакторинг с сохранением поведения — например, извлечение метода, — вынесите логику в тестируемый вид и только после этого беритесь за нужное изменение.
Почему нельзя смешивать рефакторинг и добавление функций в одном коммите?
Потому что при появлении diff в выводе вы уже не сможете локализовать причину. Рефакторинг проверяют условием «поведение не изменилось», добавление функций — условием «изменилось только в задуманном месте»: критерии успеха противоположны. Смешаете их — и не отличите, запланированное это отклонение от golden master или поломка. Безопаснее разделять: в коммите с рефакторингом — нулевой diff, в коммите с новой функцией — только запланированный diff.
Когда обновлять golden master (эталонный файл)?
Только когда поведение меняют намеренно — то есть в коммите с новой функцией или исправлением дефекта. Перед заменой эталона глазами просмотрите diff старого и нового вывода и убедитесь, что в нём только запланированное изменение. Если механически перезаписать эталон только потому, что тест стал красным, регрессия (незапланированное изменение поведения) молча зафиксируется как «правильное», и страховочная сетка перестанет работать.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог