Автоматическое тестирование, включая модульное и интеграционное, хорошо документировано и поддерживается множеством библиотек и платформ. Однако с ростом сложности приложений и увеличением количества пользовательских сценариев возникают новые проблемы, требующие современных инструментов.
В этой статье мы рассмотрим Scand Storm Petrel — инструмент для .NET-разработчиков, который автоматизирует однотипную работу по формированию и обновлению ожидаемых значений в тестах. Это особенно актуально при большом количестве тестовых сценариев или сложной структуре тестируемых объектов, что является неотъемлемой частью разработки современных приложений.
Основные проблемы тестирования в .NET
При создании новых юнит или интеграционных тестов разработчики обычно:
Выбирают тестовый фреймфорк (обычно xUnit, но можно и NUnit, MSTest или их менее известные альтернативы).
Подбирают библиотеки для мокирования (NSubsitute, Moq, FakeItEasy и т.д.).
Определяют архитектурные границы и типы тестов (Sociable/Solitary Unit Tests, Integration Tests и т.д.), следуя принципам TDD и шаблону Arrange-Act-Assert.
Ведут разработку приложения вместе с разработкой тестов. Актуальные значения сравнивают с ожидаемыми с помощью API, встроенного в тестовые фреймворки, специализированных библиотек типа FluentAssertions, Shouldly или их альтернатив.
Если первые три пункта относительно просты, то последний в контексте разработки тестов вызывает вопросы, особенно касающиеся:
Хранения ожидаемых значений. Здесь есть два основных варианта со своими достоинствами и недостатками:
а. В отдельных файлах (JSON, PDF, PNG, TXT и т.д.).
б. В C# коде: строковые или числовые переменные, константы в assert выражениях, параметры тестов, атрибутов; переменные или методы, в которых инициализированы ожидаемые объекты.
Формирования и обновления этих значений. Делать это вручную зачастую не является оптимальным способом: много кому знакомы ситуации, когда добавление нового поля в классе или изменение его поведения ведет к ручному исправлению несколько десятков мест в тестовых проектах. Также часто ожидаемый объект является довольно сложным для ручного формирования или перезаписи, поскольку в нем большое количество свойств и вложенных связей.
Поиска использования конкретных свойств объектов в тестах. Это часто нужно, чтобы понять сценарии его использования для помощи в поддержке проекта, оценки возможных рисков изменения поведения этого поля/свойства и т.д.
Почему я выбрал Storm Petrel
Мы проанализировали несколько вариантов с учетом поднятых вопросов. Ниже приведены ключевые преимущества и недостатки каждого из них, которые позволили нам прийти к выводу, что наилучшим решением для нашего проекта является Storm Petrel.
Вариант 1. Храним ожидаемые значения в отдельных файлах
В общем случае в этом варианте имеем слабую связанность (Loose Coupling) между кодом проекта и ожидаемыми значениями.
Достоинства:
Удобство просмотра ожидаемых файлов (JSON, PDF, PNG, TXT и т.д.) в сторонних специализированных программах.
Простота автоматического обновления, что экономит время разработки.
Недостатки:
Не подходит к большей части традиционных тестов.
Сложность поиска используемых свойства ожидаемых объектов в случае сериализации в JSON, XML или другие форматы.
Снижение производительности тестов из-за дополнительных операций с файлами или дополнительной сериализации.
Далее, в зависимости от реализации этого варианта, получаем свои особенности.
Вариант 1.1. Вставляем вызовы File.Read/Write в тело тестов
Достоинства:
Простота и гибкость реализации.
Недостатки:
Нужен дополнительный код для десериализации, в случае если мы храним ожидаемые объекты как сериализованные значения. Либо сравниваем сами сериализованные значения (обычно строки), что имеет свои очевидные недостатки.
Нужны дополнительные вызовы
File.Write
, используемые только для перезаписи файлов, но не в самой логике тестов. ЭтотFile.Write
приходится дублировать во всех тестах и держать закомментированным, а раскомментировать только когда разработчик решает перезаписать ожидаемые файлы.
Вариант 1.2. Считываем/записываем ожидаемые значения через инструменты снапшот тестирования
Т.е. используем Verify .NET или его альтернативы: Snapshooter, Meziantou.Framework.InlineSnapshotTesting, ApprovalTests.Net и т.д.
Достоинства:
Наличие интерактивных инструментов для сравнения и перезаписи снапшотов.
Встроенные возможности по сериализации объектов.
Недостатки:
Отсутствие поддержки традиционных тестов. Существующие тесты приходится переделывать под вызовы методов типа
Verify(…)
.Более узкие возможности вариативности. Обусловлено тем, что вызовы методов типа
Verify(…)
выполняют несколько действий, причем по собственным правилам: сериализуют/десериализуют объекты, сравнивают и перезаписывают их. А что будет если у нас специфическая сериализация или сравнение, которые не поддерживаютсяVerify
?Существенное время на изучение инструментов. По крайней мере в Verify .NET есть огромное количество NuGet пакетов, адаптеров утилит сравнения и т.д., которые пытаются покрыть все возможные случаи в тестах, что могут встретиться на практике.
Вариант 1.3. NuGet пакет Scand.StormPetrel.FileSnapshotInfrastructure
В этом варианте считываем ожидаемые значения с помощь АПИ этого пакета, а записываем ожидаемые значения через вызов автоматически сгенерированных тестов с суффиксом StormPetrel.
Достоинства:
Простота тестового кода. Для перезаписи ожидаемых файлов можно запускать автоматически сгенерированные StormPetrel тесты, а дополнительные вызовы
File.Write
просто не нужны в коде тестов.Минимальное влияние на другие аспекты процесса разработки. StormPetrel тесты можно отключить для процесса CI/CD, а включать только в окружении разработчика.
Унификация структуры ожидаемых файлов.
Недостатки:
Дополнительное время на изучение инструмента. Однако он состоит из всего лишь двух NuGet пакетов с документацией и примерами.
Вариант 2. Храним ожидаемые значения в C# коде тестового проекта
В отличие от варианта 1, в большинстве случаев здесь имеем сильную связанность (Tight Coupling) между кодом проекта и ожидаемыми значениями, хотя остается возможность организовать слабую связанность при необходимости.
Достоинства:
Поддержка большей части традиционных тестов.
Удобство поиска используемых свойств ожидаемых объектов средствами IDE.
Лучшая производительность тестов из-за отсутствия обращений к файловой системе и дополнительной сериализации.
Недостатки:
Неудобство использования сторонних программы для просмотра специальных видов ожидаемых значений (JSON, PDF, PNG, TXT и т.д.). Для таких тестов все же лучше использовать варианты для снапшот тестирования.
Далее разделяем вариант 2 на две части.
Вариант 2.1. Формируем или перезаписываем ожидаемые значения вручную
Достоинства:
Гибкость. Можем разработать тестовый метод в любой форме.
Недостатки:
Дополнительное время разработки для ручного формирования или перезаписи ожидаемых значений. Крайне актуально при большом количестве тестов, тестовых сценариев или сложной структуры ожидаемых объектов.
Вариант 2.2. Формируем или перезаписываем ожидаемые значения с помощью NuGet пакета Scand.StormPetrel.Generator
Достоинства:
Сокращение время разработки. Обновляем ожидаемые значения через вызов автоматически сгенерированных тестовых методов с суффиксом StormPetrel. Такие тесты можно запускать как индивидуально, так и массово. Пересматривать обновленные ожидаемые значения, чтобы убедиться в их правильности, никто не отменял.
Минимальное влияние на другие аспекты процесса разработки. StormPetrel тесты можно отключить для процесса CI/CD, а включать только в окружении разработчика.
Недостатки:
Неполная поддержка. Находятся примеры тестов, которые требуют либо изменения их кода для совместимости с поддерживаемыми сценариями Storm Petrel, либо внесения изменений в сам Storm Petrel.
Как я внедрил Storm Petrel в проект
В этом разделе опишем наш опыт использования Storm Petrel в одном из реальных проектов - ASP.NET Core сервисе для раскроя панелей. Здесь опустим функциональные и нефункциональные требования к проекту (нужно ли оптимизировать раскрой? Какие проблемы бизнеса мы решаем этим проектом в краткосрочной и долгосрочной перспективе? Требования к клиенту/серверу и т.д.), а будем фокусироваться на основных примерах разработанной функциональности и практике внедрения Storm Petrel.
Контекст: ASP.NET Core сервис для раскроя панелей
Пример: ASP.NET Core сервис, который рассчитывает раскрой настенных панелей. На вход ему приходит массив панелей с высотой и шириной, который вводит менеджер по продажам на специальной веб странице, а на выходе мы должны получить:
PDF файл с чертежом размещения панелей на производственной ленте размером 1220 x 50 000 мм с учетом технологического отступа в 2 мм.
Параметры раскроя (погонная длина и площадь занятой ленты, общий периметр и площадь панелей) с учетом технологического отступа и без.
Упрощенный код основного контроллера:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace LayoutApi.Controllers;
[Route("api/[controller]")]
[ApiController]
public class LayoutController : ControllerBase
{
[AllowAnonymous]
[HttpPost("calculate")]
public CalculateResult Calculate(Panel[] request)
{
//Реализация вычислений
}
[AllowAnonymous]
[HttpPost("pdf")]
public FileStreamResult GeneratePdf(Panel[] request)
{
//Реализация создания generatedPdfStream
return File(generatedPdfStream, "application/pdf", "generated_pdf.pdf");
}
}
Классы, используемые в этом контроллере:
public class Panel
{
public int Width { get; set; }
public int Height { get; set; }
}
public class CalculateResult
{
public Layout Layout { get; set; } = new Layout();
/// <summary>
/// Рассчет раскроя с учетом технологического отступа
/// </summary>
public Layout LayoutWithTechIndent { get; set; } = new Layout();
}
public class Layout
{
/// <summary>
/// Погонная длина, т.е. сколько миллиметров займет весь раскрой на производственной ленте
/// </summary>
public int LinearLength { get; set; } = 0;
public int LinearSquare { get; set; } = 0;
public int Perimeter { get; set; } = 0;
public int Square { get; set; } = 0;
}
Класс LayoutController
является стабильной границей тестирования, поэтому для него далее будем создавать юнит тесты. Тесты поместим в два отдельных xUnit проекта (традиционные и файл снапшот тесты). Можно их поместить в один проект, но тогда конфигурация Scand Storm Petrel станет более сложной.
Традиционные тесты для проверки параметров раскроя
В проекте традиционных тестов добавим Scand Storm Petrel с параметрами по умолчанию через Scand.StormPetrel.Extension расширение для Visual Studio. В проект тестов это добавит:
- NuGet пакеты ObjectDumper.NET и Scand.StormPetrel.Generator.
- Конфигурационный файл appsettings.StormPetrel.json
с необходимыми значениями.
Через расширение можем указать другие варианты конфигурации: пакет VarDump вместо ObjectDumper.NET или свою реализацию интерфейсов из Scand.StormPetrel.Generator.Abstraction. Storm Petrel также можно сконфигурировать вручную без использования расширения на основании документации.
Далее будем следовать основному сценарию Storm Petrel:
https://static.scand.com/com/uploads/primary-use-case.gif
Добавим новый класс с Fact
тестом и пустым ожидаемым значением new CalculateResult()
:
[Fact]
public void CalculateTest()
{
//Arrange
var inputPanels = new[]
{
new Panel
{
Height = 2500,
Width = 800,
}
};
//Act
var actual = new LayoutController().Calculate(inputPanels);
//Assert
actual.Should().BeEquivalentTo(
new CalculateResult());
}
Скомпилируем тестовый проект и запустим сгенерированный тест CalculateTestStormPetrel
, что заменит ожидаемое значение new CalculateResult()
на актуальное в коде исходного теста CalculateTest
:
new CalculateResult
{
Layout = new Layout
{
LinearLength = 2500,
LinearSquare = 3050000,
Perimeter = 6600,
Square = 2000000
},
LayoutWithTechIndent = new Layout
{
LinearLength = 2504,
LinearSquare = 3054880,
Perimeter = 6616,
Square = 2013216
}
}
Таким образом, ручная работа по формированию ожидаемого значения в тесте выполнена, а разработчику остается убедиться в том, что сформированное ожидаемое значение корректно. Если значение некорректно, то нужно будет исправить код метода LayoutController.Calculate
и повторить процесс перезаписи ожидаемого значения.
Далее очевидно, что у CalculateTest
могут изменяться входные значения и, соответственно, ожидаемые. Будем использовать атрибуты Theory
и MemberData
, а входные и ожидаемые значения передавать в качестве аргументов теста:
[Theory]
[MemberData(nameof(CalculateTheoryData))]
public void CalculateTheoryDataTest(CalculateTestCase testCase, CalculateResult expected)
{
//Arrange
//Act
var actual = new LayoutController().Calculate(testCase.InputPanels);
//Assert
actual.Should().BeEquivalentTo(expected);
}
где реализация CalculateTheoryData
содержит два сценария для примера:
public static TheoryData<CalculateTestCase, CalculateResult> CalculateTheoryData =>
new()
{
{
new CalculateTestCase(
"Одна типовая панель",
new[]
{
new Panel
{
Height = 2500,
Width = 800,
}
}),
new CalculateResult()
},
{
new CalculateTestCase(
"Одна минимальная панель",
new[]
{
new Panel
{
Height = 1,
Width = 1,
}
}),
new CalculateResult()
},
};
где, в свою очередь, дополнительный класс CalculateTestCase
является одним из вариантов решения того, что Storm Petrel требует реализации оператора равенства (==) для входных параметров теста. В нашем случае он имеет следующую реализацию:
public record CalculateTestCase(string Name, Panel[] InputPanels)
{
/// <summary>
/// Сравниваем только Name, игнорируем остальные свойства.
/// </summary>
/// <param name="other"></param>
/// <returns></returns>
public virtual bool Equals(CalculateTestCase? other) => other is not null && Name == other.Name;
public override int GetHashCode() => Name.GetHashCode();
}
Далее аналогично компилируем тестовый проект и запускаем автоматически сгенерированный тест CalculateTheoryDataTestStormPetrel
. Пустые ожидаемые значения new CalculateResult()
в методе CalculateTheoryData
перезапишутся на актуальные и будет достаточно убедиться в их корректности. Метод CalculateTheoryData
далее расширяем дополнительными необходимыми тестовыми сценариями.
Файл снапшот тесты для проверки PDF файлов с чертежом раскроя
Для проекта файл снапшот тестов выберем Scand.StormPetrel.FileSnapshotInfrastructure в поле Dumper Expression окна конфигурации Scand.StormPetrel.Extension расширение для Visual Studio. В проект тестов это добавит:
NuGet пакеты Scand.StormPetrel.FileSnapshotInfrastructure и Scand.StormPetrel.Generator.
Конфигурационный файл appsettings.StormPetrel.json с необходимыми значениями.
На основании документации Storm Petrel такую же конфигурацию можно создать вручную без использования расширения.
Далее будем следовать основному сценарию File Snapshot Infrastructure:
https://static.scand.com/com/uploads/primary-use-case-2.gif
и его варианту Default Configuration With Custom Options, поскольку нам известно фиксированное расширение pdf для снапшот файлов. Т.е. нам понадобится ModuleInitializer
:
using Scand.StormPetrel.FileSnapshotInfrastructure;
using System.Runtime.CompilerServices;
internal static class ModuleInitializer
{
[ModuleInitializer]
public static void Initialize()
{
SnapshotOptions.Current = new()
{
SnapshotInfoProvider = new SnapshotInfoProvider(fileExtension: "pdf"),
};
}
}
Приступим теперь к созданию Fact
теста:
using FluentAssertions;
using LayoutApi.Controllers;
using Scand.StormPetrel.FileSnapshotInfrastructure;
public class LayoutControllerTest
{
[Fact]
public void GeneratePdfTest()
{
//Arrange
var expectedPdfBytes = SnapshotProvider.ReadAllBytes();
var inputPanels = new[]
{
new Panel
{
Height = 2500,
Width = 800,
}
};
//Act
var actualPdfBytes = new LayoutController()
.GeneratePdf(inputPanels)
.FileStream
.ReadAllBytes();
//Assert
actualPdfBytes.Should().Equal(expectedPdfBytes);
}
}
где ReadAllBytes
можно реализовывать по-разному, в нашем случае это будет:
public static class Extensions
{
public static byte[] ReadAllBytes(this Stream stream)
{
using var ms = new MemoryStream();
stream.CopyTo(ms);
return ms.ToArray();
}
}
Скомпилируем тестовый проект и запустим автоматически сгенерированный тест GeneratePdfTestStormPetrel
, что в корне проекта создаст файл LayoutControllerTest.Expected/GeneratePdfTest.pdf
в соответствие с конфигурацией из ModuleInitializer
. Откроем этот pdf файл в браузере или другой программе для просмотра pdf и убедимся, что в файле находятся корректные данные. Если данные некорректны, то нужно будет исправить код метода LayoutController.GeneratePdf
и повторить процесс перезаписи ожидаемого pdf файла.
Далее очевидно, что у GeneratePdf
могут изменяться входные значения и, соответственно, ожидаемые pdf файлы. Будем использовать атрибуты Theory
и MemberData
, а входные значения передавать в качестве аргументов теста, причем один из аргументов обязан иметь название useCaseId
или должен быть помечен специальным атрибутом согласно документации:
[Theory]
[MemberData(nameof(GeneratePdfTheoryData))]
public void GeneratePdfTheoryDataTest(string useCaseId, Panel[] inputPanels)
{
//Arrange
var expectedPdfBytes = SnapshotProvider.ReadAllBytes(useCaseId);
//Act
var actualPdfBytes = new LayoutController()
.GeneratePdf(inputPanels)
.FileStream
.ReadAllBytes();
//Assert
actualPdfBytes.Should().Equal(expectedPdfBytes);
}
где реализация GeneratePdfTheoryData
содержит два сценария для примера:
public static TheoryData<string, Panel[]> GeneratePdfTheoryData =>
new()
{
{
"Одна-типовая-панель",
new[]
{
new Panel
{
Height = 2500,
Width = 800,
}
}
},
{
"two-panels",
new[]
{
new Panel
{
Height = 2500,
Width = 800,
},
new Panel
{
Height = 2500,
Width = 800,
}
}
},
};
Далее аналогично компилируем тестовый проект и запускаем автоматически сгенерированный тест GeneratePdfTheoryDataTestStormPetrel
. Появятся новые файлы (или перезапишутся при повторном запуске если их байты не будут совпадать с актуальными) LayoutControllerTest.Expected/GeneratePdfTheoryDataTest.Одна-типовая-панель.pdf
и LayoutControllerTest.Expected/GeneratePdfTheoryDataTest.two-panels.pdf
соответственно. Метод GeneratePdfTheoryData
далее расширяем дополнительными необходимыми тестовыми сценариями.
5. Результаты и выводы
Внедрение Storm Petrel значительно ускорило разработку юнит и интеграционных тестов через автоматизацию работы .NET разработчиков по формированию/обновлению ожидаемых значений. Следовательно, это ускорило разработку и самого разрабатываемого приложения. При этом:
Структура тестовых методов остается традиционной либо несущественно изменяется.
Остается гибким выбор библиотек для сравнения актуальных и ожидаемых значений, их сериализации/десериализации (при необходимости).
Используется инфраструктура тестов из .NET: для обновления ожидаемых значений можно запускать сгенерированные StormPetrel тесты как из IDE, так и командной строки
dotnet test ... --filter "FullyQualifiedName~StormPetrel"
.Используется инфраструктура .NET Incremental Generators: при необходимости код сгенерированных StormPetrel тестов можно посмотреть в IDE, отладить и понять корневую причину их неполадки.
Storm Petrel фактически не влияет на процесс CI. Его можно держать отключенным в конфигурационном файле в удаленном репозитории и включать только в окружении разработчика. В крайних случаях можно просто не добавлять Storm Petrel в удаленный репозиторий, а делать это только в локальном окружении.
В названии переменных для актуальных/ожидаемых значений вынуждены теперь использовать составляющие
actual
/expected
, что является некой стандартизацией тестов. Впрочем, эти составляющие можно конфигурировать для названий в своем тестовом проекта.Документация указывает на массу типовых примеров конфигураций и тестов, где Storm Petrel может переписывать ожидаемые значения.
Есть возможность игнорировать файлы с кодом в тестовых проектах, где логика Storm Petrel неприменима.
Scand Storm Petrel является проектом с открытым исходным кодом с вытекающими преимуществами.
Заключение
Scand Storm Petrel является эффективным инструментом, который облегчает и ускоряет разработку .NET проектов. Его можно применять для формирования/обновления ожидаемых значений как при совместной работе над проектами, так и индивидуально в локальном окружении разработчика.
Какие инструменты вы используете для работы с ожидаемыми значениями? Делитесь опытом в комментариях!
onyxmaster
Спасибо за альтернативу Verify.