Автоматическое тестирование, включая модульное и интеграционное, хорошо документировано и поддерживается множеством библиотек и платформ. Однако с ростом сложности приложений и увеличением количества пользовательских сценариев возникают новые проблемы, требующие современных инструментов.
В этой статье мы рассмотрим 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 проектов. Его можно применять для формирования/обновления ожидаемых значений как при совместной работе над проектами, так и индивидуально в локальном окружении разработчика.
Какие инструменты вы используете для работы с ожидаемыми значениями? Делитесь опытом в комментариях!
Автор: VictoriaPuzhevich
