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

В этой статье мы рассмотрим 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 проектов. Его можно применять для формирования/обновления ожидаемых значений как при совместной работе над проектами, так и индивидуально в локальном окружении разработчика.

Какие инструменты вы используете для работы с ожидаемыми значениями? Делитесь опытом в комментариях!

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


  1. onyxmaster
    13.06.2025 13:19

    Спасибо за альтернативу Verify.