О чем пойдет речь

Статья представляет собой небольшой туториал для разработчиков на .NET и описывает способы, которыми можно упростить создание юнит-тестов для больших (и не очень) проектов с табличными данными или списками сложных объектов, нуждающихся в проверке.

Тут не будет крутых алгоритмов, машинного обучения, и искуственного интеллекта, а будет немного рутинная задача, которую мы будем шаг за шагом облегчать.

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

Туториал состоит из трех частей:

  1. Аппрувал-тесты. Что это такое и зачем они нужны.

  2. Представление результатов тестирования в виде таблиц. Как можно упростить восприятие результатов аппрувал-теста.

  3. Подготовка тестовых данных в табличной форме. Как подружить .NET и табличные файлы через F#.

К статье прилагается репозиторий с примером, и каждой части статьи соответствует своя ветка, в дальнейшем влитая в основную.

Предполагается, что читатель уже знаком с C#, каким-нибудь из юнит-тест фреймфорков (в коде туториала используется XUnit и Moq) и может написать какие-нибудь базовые ассерты.

Итак, начнем.

Часть 1. Аппрувал тесты.

Осознание того, что такое юнит-тесты и зачем они нужны, к начинающему программисту приходит обычно одновременно с пониманием принципов Dependency Injection, и пониманием того, что эти две вещи неразрывно связаны. Спустя некоторое время моки и ассерты становятся неотъемлемой частью разработки, и перед написанием логики и алгоритмов сначала пишется тест.

Однако, объем кода при использовании только ассертов растет, и приходится каждый раз писать новые наборы ассертов, которые для больших и разветвленных объектов будут сложны для понимания, либо свой фреймворк. Здесь на помощь могут прийти аппрувал тесты, которые представляют результат выполнения теста в текстовом виде для дальнейшей проверки автором теста. Результатами могут быть как сериализованные объекты, для которых ранее писались ассерты, так и любое пользовательское их представление.

Аппрувал тест после выполнения сгенерирует файл ".recieved", правильность которого оценит автор теста. При подтверждении создается файл ".approved", который при последующем выполнении теста будет сравниваться с заново сгенерированным файлом ".recieved". Если возникнут расхождения, считается, что тест не проходит, и фреймворк аппрувал тестов откроет файл в diff-инструменте.

Рис.1. Пример выполнения аппрувал теста, открывающего diff-merge инструмент
Рис.1. Пример выполнения аппрувал теста, открывающего diff-merge инструмент

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

Недостатки же - это немного (но не критически) увеличенное время выполнения тестов по сравнению с обычными ассертами, необходимость каждый раз при изменении результата делать "аппрув" вручную и сложность представления начального результата заранее, до выполнения теста.

Часть 1. Пример.

Структура солюшена

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

Рис.2 Структура примера
Рис.2 Структура примера

Проекты солюшена:

  • Domain

    В предметной области все просто. Есть класс TaxRate, который содержит информацию о минимальном и максимальном доходе, на который необходимо делать начисление по процентной ставке Rate. Например, Tax Rate с MinAmount = 0, MaxAmount = 100 и Rate = 0.1 будет означать, что на доход с 0 до 100 долларов будет начислен налог по ставке 10%. А также есть класс IncomeRecord, который определяет запись полученного работником EmployeeId на дату Date дохода в размере Amount.

  • DAL

    В примере нет полноценного DataAccessLayer, но мы определим интерфейсы для репозиториев и UnitOfWork, чтобы замокать их в тесте сервиса.

  • BL

    В бизнес-логике, на которую мы будем писать тесты, два anemic сервиса (но аппрувал тесты подходят и для DDD):

    1. TaxRateService - сервис, который предоставляет данные для начисления прогрессивного налога в виде коллекции TaxRate в зависимости от переданого типа налога (в нашем простейшем примере это Flat/Progressive. В реальном мире набор параметров может быть намного сложнее).

    2. TaxCalculationService - основной сервис, который мы будем покрывать тестами, он будет подсчитывать годовой налог для списка сотрудников, и правильность подсчета должна соблюдаться. Основная логика - если доход попадает под повышенную ставку, то она берется только с уровня дохода, превышающего предыдущую ступень.

И, наконец, проект с юнит тестами.

Тестируются оба сервиса, с подготовкой данных для TaxCalculationService в классе TestEmployeeRecordsDataFactory. Удобная и быстрая подготовка данных для теста это отдельный момент и мы будем подробно рассматривать его в части 3.


Начинаем тестирование

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

В таком количестве ассертов очень легко заблудиться, поэтому посмотрим, каким образом можно упростить тест при помощи аппрувалов.

Подключим аппрувал тесты из nuget и перепишем наш тест с их использованием. А также убедимся, что в системе есть diff-merge инструмент, используемый по умолчанию (tortoise merge или kdiff подойдет).

[Fact]
public void ShouldProvideCorrectRates_ForProgressiveTaxType()
{
    var response = _service.GetTaxRates(ETaxType.Progressive).ToList();

    Assert.Equal(5, response.Count);

    Assert.Equal(0.00m, response[0].Rate);
    Assert.Equal(0,     response[0].MinAmount);
    Assert.Equal(1000,  response[0].MaxAmount);

    Assert.Equal(0.05m, response[1].Rate);
    Assert.Equal(1001,  response[1].MinAmount);
    Assert.Equal(5000,  response[1].MaxAmount);

    Assert.Equal(0.10m, response[2].Rate);
    Assert.Equal(5001,  response[2].MinAmount);
    Assert.Equal(10000, response[2].MaxAmount);

    Assert.Equal(0.20m,   response[3].Rate);
    Assert.Equal(10_001,  response[3].MinAmount);
    Assert.Equal(100_000, response[3].MaxAmount);

    Assert.Equal(0.35m,   response[4].Rate);
    Assert.Equal(100_001, response[4].MinAmount);
    Assert.Null(response[4].MaxAmount);
}

В таком количестве ассертов очень легко заблудиться, поэтому посмотрим, каким образом можно упростить тест при помощи аппрувалов.

Подключим аппрувал тесты из nuget и перепишем наш тест с их использованием. А также убедимся, что в системе есть diff-merge инструмент, используемый по умолчанию (tortoise merge или kdiff подойдет).

Рис. 3. Approval tests nuget package
Рис. 3. Approval tests nuget package

Навесим на класс атрибуты [UseReporter(typeof(DiffReporter))] и [UseApprovalSubdirectory("Results")], чтобы тесты корректно запускались и складывали результаты не в директорию с кодом, а в отдельную папку. А также можно указать, какой репортер будет использоваться в ходе запуска тестов в пайплайне (не в режиме отладки).

#if DEBUG
    // DIFF REPORTER is used to approve test results on a developer's machine
    [UseReporter(typeof(DiffReporter))]
#else
    // QUIET REPORTER is used when we run tests in CI/CD pipeline 
    [UseReporter(typeof(QuietReporter))]
#endif
    [UseApprovalSubdirectory("Results")]
    public class TaxRateServiceTest

Новый вариант будет выглядеть так:

[Fact]
public void ShouldProvideCorrectRates_ForFlatTaxType()
{
    var response = _service.GetTaxRates(ETaxType.Flat);
    var jsonResponse = JsonConvert.SerializeObject(response, Formatting.Indented);

    ApprovalTests.Approvals.Verify(jsonResponse);
}

Если запустить этот тест в первый раз, то мы увидим, что папка results содержит два файла - имятеста.received.txt и имятеста.approved.txt (пока пустой), а сам тест - красный, при этом аппрувал тесты открывают окно дифф-инструмента для того, чтобы разработчик одобрил результат выполнения.

Рис. 4. Первый запуск аппрувал-теста, красный тест
Рис. 4. Первый запуск аппрувал-теста, красный тест

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

Рис. 5. Разрешаем конфликт .recieved и .approved файлов
Рис. 5. Разрешаем конфликт .recieved и .approved файлов

Запускаем тесты еще один раз - и любуемся на их зеленый цвет. Логика TaxRate покрыта.

Рис. 6. Зеленые тесты после первого аппрува
Рис. 6. Зеленые тесты после первого аппрува

Теперь попробуем внести изменения в логику и запустить тесты еще раз:

new TaxRate { Id = 2, Rate = 0.00m, MinAmount = 0, MaxAmount = 1200 },

Рис. 7. Пример красного теста при изменении логики, которую покрывал тест
Рис. 7. Пример красного теста при изменении логики, которую покрывал тест

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


Часть 2. Представление результатов тестирования в виде таблиц.

Результаты выполнения аппрувал-теста чаще всего представляют в виде json. Это удобно для многих случаев - небольших объектов, больших древовидных структур, готовых ответов API, а также анонимных классов, которые содержат только необходимый для проверки результат.

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

В этом случае гораздо удобнее представлять данные в виде таблицы. Было бы здорово, если бы существовали diff-инструменты, которые могли бы представлять csv или excel данные в табличном виде с возможностью подсветки отличий того или иного поля. К сожалению, форматирование большинства diff-инструментов стандартное, текстовое, и для того, чтобы представить данные в удобном виде, требуется преобразовать их в таблицу с текстовым представлением ячеек (да-да, как в старинных текстовых таблицах времен нортон коммандера).

В таком виде данные становятся гораздо нагляднее и при изменении какого-либо из значений в ячейках таблицы diff-инструмент подсветит только это место.

Часть 2. Пример.

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

[Fact]
public void ShouldProvideCorrectRates_ForProgressiveTaxType_TableFormatting()
{
    var response = _service.GetTaxRates(ETaxType.Progressive);

    var tableFormattedResponse = response.ToStringTable(
        ("Id", r => r.Id),
        ("Min Amount", r => r.MinAmount.ToString(CultureInfo.InvariantCulture)),
        ("Max Amount", r => (r.MaxAmount == decimal.MaxValue 
                             ? "MAX" 
                             : r.MaxAmount.ToString(CultureInfo.InvariantCulture))),
        ("Rate", r => r.Rate.ToString(CultureInfo.InvariantCulture))
    );

    ApprovalTests.Approvals.Verify(tableFormattedResponse);
}

Теперь, при его запуске и расхождениях в результатах программист увидит место ошибки в более наглядном формате:

Рис. 8. Красный тест и табличное форматирование, видим расхождение в значениях
Рис. 8. Красный тест и табличное форматирование, видим расхождение в значениях

Реальные данные, в отличие от тестового примера, могут быть намного более объемными, в этом случае отличаться будет только размер таблицы, а подход останется прежним.


Часть 3. Генерация массива тестовых данных из csv при помощи F#.

Иногда тесты требуют большого массива табличных данных, взятых из реальной базы, или поступивших с шага бизнес-анализа. Это могут быть списки объектов, событий, и числовых значений, финансовые данные или данные с датчиков.

Ничего нового в том, чтобы парсить csv и переводить данные из табличного формата в коллекцию объектов нет, но, как показала практика, в .NET проще всего это делать при помощи F# благодаря наличию CsvTypeProvider

Часть 3. Пример.

Допустим, мы хотим протестировать работу сервиса по начислению налога (метод GetEmployeeIncomeRecords) на массиве реальных данных, и у нас есть таблица с реальными значениями полученного дохода. Мы бы хотели, чтобы эти данные стали исходными данными для проверки сервиса.

Рис. 9. Исходные табличные данные
Рис. 9. Исходные табличные данные

Добавим к солюшену проект F# class library, а к проекту - пакет FSharp.Data. Чтобы воспользоваться возможностью парсинга из csv, нужен следующий код (и да, это будет весь код для парсинга csv):

module CsvDataHelper =

  open System
  open FSharp.Data
  open AdvancedApprovalTests.Domain
  ...

  // EMPLOYEE INCOME RECORD DATA

  [<Literal>]
  let private employeePath = __SOURCE_DIRECTORY__ 
    + "\TestData\EmployeeIncomeRecordHeaders.csv"

  type EmployeeIncomeRecordData = CsvProvider<employeePath, 
    Schema = "int64, int64, date, decimal">
              //id   emp.   date  amount

  let private employeeIncomeRecords (data: EmployeeIncomeRecordData) = 
      data.Rows |> Seq.map(fun row -> 
          (
              IncomeRecord(
                  Id = row.Id,
                  EmployeeId = row.EmployeeId,
                  Date = row.Date,
                  Amount = row.Amount
              )
          )
      )

  let GetEmployeeIncomeRecords (path: string) = 
      let dataSet = EmployeeIncomeRecordData.Load(path)
      employeeIncomeRecords dataSet |> Seq.toArray 

Здесь мы референсим проект Domain, подключаем CsvTypeProvider, который смотрит на лежащий рядом csv файл с заголовками и парой значений, и пишем код метода, который переводит строчки файла в необходимые нам записи. Schema c типами необязательна, но лучше ее прописать для надежности. Далее, просто воспользуемся этим методом в тесте, скормив ему реальный файл с тестовыми данными.

    [Theory]
    [InlineData(
      "./SampleData/TaxRates1.csv", 
      "./SampleData/EmployeeIncomes1.csv")]
    public async Task ShouldCalculateProgressiveTaxCorrectly_DataHelper(
      string taxRatePath, 
      string employeePath)
    {
      var testRecords = CsvDataHelper.GetEmployeeIncomeRecords(employeePath);
      var taxRates = CsvDataHelper.GetTaxRateItems(taxRatePath);

      _incomeRepositoryMock
        .Setup(i => i.GetFiltered(2020))
        .ReturnsAsync(testRecords);

      _rateServiceMock
        .Setup(r => r.GetTaxRates(ETaxType.Progressive))
        .Returns(taxRates);

      var response = await _service.CalculateYearlyTaxAsync(
        new List<long>() { 1 }, 2020, ETaxType.Progressive);

      ApprovalTests.Approvals.Verify(response.ToStringTable());
    }

При запуске аппрувал-теста получим следующий результат:

Employee 1
Total tax 17860.30
Calculated tax:
 | Basis    | Tax      | 
 |---------------------| 
 | 1000.00  | 0.00     | 
 | 3999.00  | 399.90   | 
 | 14999.00 | 2999.80  | 
 | 48202.00 | 14460.60 | 

Ура! Логика начисления нужного нам кейса покрыта, мы можем быть уверены, что на реальных данных сервис поведет себя точно так же, и не бояться, что алгоритм сработает как-то не так.

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

Заключение

Таким образом, аппрувал тесты хоть и не является панацеей от всех недостатков ручного написания ассертов при юнит-тестировании, но помогают более наглядно визуализировать результаты выполнения тестов, особенно в случае табличного форматирования, а время на разработку и написание тестов в случае их использования сильно сокращается. Если же в дополнение к аппрувал-тестам использовать F# для почти автоматической подготовки исходных тестовых данных из таблиц, то юнит-тест превращается в универсальный метод проверки сложной логики.

Спасибо за внимание!

Комментарии (3)


  1. BkmzSpb
    15.02.2022 12:55
    +2

    Это все очень похоже на snapshot testing. Можно как-то поподробнее разъяснить разницу? Буквально вчера смотрел на snapshot testing в C# и нашел Verify, у вас есть опыт работы с ним?

    Отдельный важный для меня вопрос -- интеграция тестирования с CI. Для корректной работы подобных тестов нужно за собой таскать артефакты (правильный вывод тестов), и это не всегда оптимальное решение. Есть ли какие-то стандартные решения у этой проблемы?


    1. NeoNN Автор
      15.02.2022 13:25

      Да, и правда, очень похоже по подходу, но опыта использования Verify нет, поэтому тут не подскажу. Артефакты же это просто текстовые файлы .approved, которые складываются в папку c исходным кодом и при запуске тестов в CI/CD пайплайне при билде QuietReporter сообщит, если тест будет падать. Если их складывать чуть в стороне (в примере это папка Results), то они не сильно будут мешаться.


    1. zloddey
      15.02.2022 14:25

      Когда-то я читал статью от авторов термина approval testing, и рассказывалось там, ЕМНИП, приблизительно следующее. Действительно, для данной техники есть несколько разных названий: snapshot testing, canonical testing (вроде бы) golden master, и наверняка что-то ещё. Но они выбрали именно approval testing, чтобы подчеркнуть ключевую особенность данных тестов: получившийся результат должен быть заапрувлен человеком, чтобы тест считался пройденным.

      Если не считать этот момент принципиальным, то получается, что и разницы между подходами нет. Суть одна, названия лишь отличаются.