Автоматизация тестов в .NET: мой опыт со Storm Petrel

от автора

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/918160/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *