Как покрыть тестами legacy VB.NET: характеризационные тесты, швы и обёртки

РубрикаПрактикаУровеньСредний Чтение9 минОпубликовано ТегиVB.NETМодульные тестыxUnitРефакторинг

В старом VB.NET-проекте тестов обычно нет, а логика размазана по формам, модулям и Shared-методам. Переписать всё ради тестируемости нельзя, но начать покрывать код тестами можно уже на этой неделе. Показываем порядок действий и минимальные приёмы.

Главное. Начинайте с характеризационных тестов на чистые вычисления, вводите швы через необязательные параметры и интерфейсы, отделяйте загрузку данных от обработки и привязывайте каждый исправленный баг к тесту.

Цель: зафиксировать поведение, а не доказать правильность

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

Такой подход снимает главный страх. Вы не решаете, баг ли округление в третьем знаке или так задумано. Вы записываете факт и получаете сигнал, если рефакторинг его изменит. Обсуждать, правильно ли поведение, можно потом, отдельной задачей и с заказчиком.

Тестовый проект рядом с основным

Добавьте в решение отдельный проект тестов на VB.NET. Подойдут xUnit, NUnit или MSTest: все три работают с Visual Basic и запускаются из Test Explorer в Visual Studio и командой dotnet test. Если основной проект на .NET Framework 4.x, тестовый проект может быть в новом SDK-формате с целевой платформой net48, это упрощает подключение пакетов.

Писать тесты на VB.NET не обязательно, C#-проект тоже может ссылаться на VB-сборку. Но если команда сопровождения пишет на VB, держите тесты на том же языке, иначе их перестанут читать.

Если нужные методы объявлены как Friend, не делайте их Public ради тестов. Добавьте в основной проект атрибут InternalsVisibleTo с именем тестовой сборки, и тесты увидят внутренние члены.

AssemblyInfo.vbVB.NET
' В основном проекте
<Assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Billing.Tests")>

Где искать первые точки входа

Не начинайте с самого запутанного места. Найдите код, у которого есть понятный вход и выход и нет обращений к базе, файлам и интерфейсу: расчёт скидки, разбор строки из файла обмена, формирование номера документа, конвертация единиц. Такие методы часто живут в модулях (Module) и Shared-классах, и это удача: их можно вызывать из теста напрямую.

Если логика сидит в обработчике кнопки на форме, сделайте одну механическую правку: перенесите тело обработчика в отдельный метод класса без изменения кода, а в обработчике оставьте вызов. Это безопасный рефакторинг, который Visual Studio выполняет командой Extract Method. После него появляется что тестировать.

  • методы с ветвлениями по статусам и типам документов;
  • всё, что считает деньги, налоги и даты;
  • парсеры входящих файлов и форматтеры исходящих;
  • код, в котором недавно были инциденты.

Шов для времени, базы и конфигурации

Главное препятствие для тестов это скрытые зависимости: DateTime.Now, прямые запросы к SqlConnection, чтение ConfigurationManager.AppSettings, обращение к файловой системе. Термин «шов» (seam) из книги Майкла Физерса «Эффективная работа с унаследованным кодом» обозначает место, где поведение можно подменить, не меняя сам код вокруг.

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

InvoiceRules.vbVB.NET
Public Interface IClock
    ReadOnly Property Today As Date
End Interface

Public Class SystemClock
    Implements IClock
    Public ReadOnly Property Today As Date Implements IClock.Today
        Get
            Return Date.Today
        End Get
    End Property
End Class

Public Class InvoiceRules
    Private ReadOnly _clock As IClock

    Public Sub New(Optional clock As IClock = Nothing)
        _clock = If(clock, New SystemClock())
    End Sub

    Public Function IsOverdue(dueDate As Date) As Boolean
        ' было: Return dueDate < Date.Today
        Return dueDate < _clock.Today
    End Function
End Class

Первый характеризационный тест

Тест пишется в три шага. Вызовите метод с реальными входными данными, лучше взятыми из продакшен-логов или выгрузок. Поставьте в проверку заведомо неверное ожидание, запустите тест и посмотрите, что метод вернул на самом деле. Перенесите фактическое значение в проверку. Звучит как жульничество, но именно так и фиксируется поведение.

Для методов с большим выходом, например формирования XML для обмена или текста печатной формы, удобнее снапшот-тесты. Библиотека Verify сохраняет результат в файл рядом с тестом при первом запуске и сравнивает с ним при следующих. Изменение поведения превращается в понятный дифф, который можно принять или отклонить.

InvoiceRulesTests.vbVB.NET
Imports Xunit

Public Class InvoiceRulesTests
    Private Class FixedClock
        Implements IClock
        Public Property Value As Date
        Public ReadOnly Property Today As Date Implements IClock.Today
            Get
                Return Value
            End Get
        End Property
    End Class

    <Theory>
    <InlineData("2026-09-01", False)>
    <InlineData("2026-08-31", True)>
    Public Sub IsOverdue_FixesCurrentBehavior(due As String, expected As Boolean)
        Dim clock As New FixedClock With {.Value = #2026-09-01#}
        Dim rules As New InvoiceRules(clock)

        Assert.Equal(expected, rules.IsOverdue(Date.Parse(due)))
    End Sub
End Class

Что делать с базой данных

В legacy-коде запросы обычно вперемешку с логикой. Не пытайтесь сразу внедрить репозитории и ORM. Сначала разделите метод на две части: загрузка данных в DataTable или список объектов и обработка уже загруженного. Вторая часть тестируется как обычная функция, а DataTable в тесте собирается в несколько строк кода.

Для слоя доступа к данным пишите отдельные интеграционные тесты против настоящей СУБД: локального экземпляра SQL Server Express или контейнера. Их запускают реже, но они ловят то, что не поймает ни один мок, например изменение типа колонки или неявное преобразование дат.

Не подменяйте базу самописными моками на каждый запрос. Тесты, которые повторяют SQL-логику внутри мока, ничего не проверяют и ломаются при любой правке запроса.

Как встроить тесты в работу команды

Договоритесь о простом правиле: любой исправленный баг сопровождается тестом, который падал до исправления. За полгода такой практики покрываются ровно те места, где система действительно ломается. Добавьте запуск dotnet test или vstest.console в сборку на CI, чтобы красный тест останавливал выпуск версии.

Не гонитесь за процентом покрытия. Для legacy-проекта 20 процентов, сосредоточенные в расчётах и обменах, ценнее 70 процентов, набранных тестами на свойства и конструкторы.

Как это делают сегодня

Тесты, написанные сейчас, становятся страховкой при миграции: тот же набор можно запускать против кода после переноса на .NET 8 или .NET 10. Для новых модулей используйте внедрение зависимостей через Microsoft.Extensions.DependencyInjection и TimeProvider из .NET 8 вместо самописного IClock.

Вопросы и ответы

Нужна ли Visual Studio Enterprise для тестов legacy-кода?

Нет. Microsoft Fakes, который умеет подменять Shared-методы вроде DateTime.Now, есть только в Enterprise, но швы через интерфейсы и параметры работают в любой редакции и с бесплатными xUnit или NUnit.

Можно ли тестировать код внутри форм WinForms?

Напрямую неудобно. Перенесите логику из обработчиков в отдельные классы и тестируйте их. Сами формы проверяют вручную или UI-автотестами, но это отдельная и дорогая история.

Что делать, если тест фиксирует явный баг?

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

Читайте также