О чем пойдет речь
Статья представляет собой небольшой туториал для разработчиков на .NET и описывает способы, которыми можно упростить создание юнит-тестов для больших (и не очень) проектов с табличными данными или списками сложных объектов, нуждающихся в проверке.
Тут не будет крутых алгоритмов, машинного обучения, и искуственного интеллекта, а будет немного рутинная задача, которую мы будем шаг за шагом облегчать.
Опыт применения описываемых методов был наработан на реальном финтех-проекте и сэкономил много усилий на отладку и проверку алгоритмов, позволив создавать и проверять тестовые данные и операции над ними напрямую из данных бизнес-анализа.
Туториал состоит из трех частей:
Аппрувал-тесты. Что это такое и зачем они нужны.
Представление результатов тестирования в виде таблиц. Как можно упростить восприятие результатов аппрувал-теста.
Подготовка тестовых данных в табличной форме. Как подружить .NET и табличные файлы через F#.
К статье прилагается репозиторий с примером, и каждой части статьи соответствует своя ветка, в дальнейшем влитая в основную.
Предполагается, что читатель уже знаком с C#, каким-нибудь из юнит-тест фреймфорков (в коде туториала используется XUnit и Moq) и может написать какие-нибудь базовые ассерты.
Итак, начнем.
Часть 1. Аппрувал тесты.
Осознание того, что такое юнит-тесты и зачем они нужны, к начинающему программисту приходит обычно одновременно с пониманием принципов Dependency Injection, и пониманием того, что эти две вещи неразрывно связаны. Спустя некоторое время моки и ассерты становятся неотъемлемой частью разработки, и перед написанием логики и алгоритмов сначала пишется тест.
Однако, объем кода при использовании только ассертов растет, и приходится каждый раз писать новые наборы ассертов, которые для больших и разветвленных объектов будут сложны для понимания, либо свой фреймворк. Здесь на помощь могут прийти аппрувал тесты, которые представляют результат выполнения теста в текстовом виде для дальнейшей проверки автором теста. Результатами могут быть как сериализованные объекты, для которых ранее писались ассерты, так и любое пользовательское их представление.
Аппрувал тест после выполнения сгенерирует файл ".recieved", правильность которого оценит автор теста. При подтверждении создается файл ".approved", который при последующем выполнении теста будет сравниваться с заново сгенерированным файлом ".recieved". Если возникнут расхождения, считается, что тест не проходит, и фреймворк аппрувал тестов откроет файл в diff-инструменте.

Таким образом, преимуществами аппрувал тестов будет являться наглядность представления результата, быстрота проверки расхождений в результатах при невыполнении теста, а также сокращенное время на написание теста.
Недостатки же - это немного (но не критически) увеличенное время выполнения тестов по сравнению с обычными ассертами, необходимость каждый раз при изменении результата делать "аппрув" вручную и сложность представления начального результата заранее, до выполнения теста.
Часть 1. Пример.
Структура солюшена
Как пример, рассмотрим небольшой проект, точнее, только небольшой кусочек проекта с бизнес логикой. Основной задачей бизнес-логики будет начисление налога, по плоской или прогрессивной шкале в зависимости от дохода человека. Тут мы не претендуем на бухгалтерскую точность, основная наша цель - создать удобные юнит-тесты и посмотреть, каким образом можно сделать их более наглядными и удобными.

Проекты солюшена:
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):
TaxRateService - сервис, который предоставляет данные для начисления прогрессивного налога в виде коллекции TaxRate в зависимости от переданого типа налога (в нашем простейшем примере это Flat/Progressive. В реальном мире набор параметров может быть намного сложнее).
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 подойдет).

Навесим на класс атрибуты [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 (пока пустой), а сам тест - красный, при этом аппрувал тесты открывают окно дифф-инструмента для того, чтобы разработчик одобрил результат выполнения.

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

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

Теперь попробуем внести изменения в логику и запустить тесты еще раз:
new TaxRate { Id = 2, Rate = 0.00m, MinAmount = 0, MaxAmount = 1200 },

Тест ожидаемо падает, при этом мы видим, где произошли изменения, необходимо или исправить логику, чтобы проходил предыдущий вариант теста, или одобрить новый результат. После притяния изменений и еще одного прогона теста он снова становится зеленым.
Часть 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); }
Теперь, при его запуске и расхождениях в результатах программист увидит место ошибки в более наглядном формате:

Реальные данные, в отличие от тестового примера, могут быть намного более объемными, в этом случае отличаться будет только размер таблицы, а подход останется прежним.
Часть 3. Генерация массива тестовых данных из csv при помощи F#.
Иногда тесты требуют большого массива табличных данных, взятых из реальной базы, или поступивших с шага бизнес-анализа. Это могут быть списки объектов, событий, и числовых значений, финансовые данные или данные с датчиков.
Ничего нового в том, чтобы парсить csv и переводить данные из табличного формата в коллекцию объектов нет, но, как показала практика, в .NET проще всего это делать при помощи F# благодаря наличию CsvTypeProvider
Часть 3. Пример.
Допустим, мы хотим протестировать работу сервиса по начислению налога (метод GetEmployeeIncomeRecords) на массиве реальных данных, и у нас есть таблица с реальными значениями полученного дохода. Мы бы хотели, чтобы эти данные стали исходными данными для проверки сервиса.

Добавим к солюшену проект 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# для почти автоматической подготовки исходных тестовых данных из таблиц, то юнит-тест превращается в универсальный метод проверки сложной логики.
Спасибо за внимание!
