О чем пойдет речь
Статья представляет собой небольшой туториал для разработчиков на .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# для почти автоматической подготовки исходных тестовых данных из таблиц, то юнит-тест превращается в универсальный метод проверки сложной логики.
Спасибо за внимание!
BkmzSpb
Это все очень похоже на snapshot testing. Можно как-то поподробнее разъяснить разницу? Буквально вчера смотрел на snapshot testing в C# и нашел Verify, у вас есть опыт работы с ним?
Отдельный важный для меня вопрос -- интеграция тестирования с CI. Для корректной работы подобных тестов нужно за собой таскать артефакты (правильный вывод тестов), и это не всегда оптимальное решение. Есть ли какие-то стандартные решения у этой проблемы?
NeoNN Автор
Да, и правда, очень похоже по подходу, но опыта использования Verify нет, поэтому тут не подскажу. Артефакты же это просто текстовые файлы .approved, которые складываются в папку c исходным кодом и при запуске тестов в CI/CD пайплайне при билде QuietReporter сообщит, если тест будет падать. Если их складывать чуть в стороне (в примере это папка Results), то они не сильно будут мешаться.
zloddey
Когда-то я читал статью от авторов термина approval testing, и рассказывалось там, ЕМНИП, приблизительно следующее. Действительно, для данной техники есть несколько разных названий: snapshot testing, canonical testing (вроде бы) golden master, и наверняка что-то ещё. Но они выбрали именно approval testing, чтобы подчеркнуть ключевую особенность данных тестов: получившийся результат должен быть заапрувлен человеком, чтобы тест считался пройденным.
Если не считать этот момент принципиальным, то получается, что и разницы между подходами нет. Суть одна, названия лишь отличаются.