Как стать автором
Обновить
3120.91
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

Искусство Unit-тестирования: сокращаем Arrange до нуля

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров3.1K


Unit-тесты очень важно и нужно писать, но вот незадача — на них никогда не хватает времени! Прочитав эту статью, вы узнаете абсолютно новый способ их написания, который сэкономит кучу времени и нервов.

Эта статья будет полезна .NET разработчикам, которые когда-либо сталкивались с написанием unit тестов, а также тим и техлидам в поиске оптимальной практики для своей команды.

Мотивация


Кто пишет юнит тесты на работе? Наверное, практически любой читатель этой статьи.
Кому не хватает времени на работе при написании юнит тестов? Наверное, также, практическим всем, кто их пишет.

Я полагаю, что ни у кого не вызывает сомнений необходимость писать юнит тесты.

  • Пилишь новую фичу? Проверь корректность реализации.
  • Выявили баг? Покройте тестом фикс.
  • Хочешь разобраться в старом коде? Дебажь через test runner.

Сколько ИИшек не придумают, а ситуация останется классической — в спринт напихали бизнес фич, а ваш менеджер выглядит вот так:


Это проблема отсутствия времени, но чтобы подступиться к решению, поговорим…

О написании юнит тестов


Библиотеки и инструменты используются разные, но из моей коммерческой практики — хорошо применять два подхода по систематизации и структурированию кода.

Конечно, речь идёт про AAA и RoyOsherove.

▍ На пальцах про AAA


Как вы могли понять это не о батарейках или играх, а про юнит-тестирование.
Согласитесь, в таком сложном мире, как программирование, сложно ориентироваться, когда всё лежит где попало, не на своих местах.

Гораздо проще, когда всё структурировано, лежит, так сказать, по полочкам.

Однажды умные разработчики подумали и поняли, что и тесты можно структурировать и разложить на конкретные и понятные этапы:

  • Arrange. В этой секции находится код, ответственный за настройку теста. Создание объектов, подготовка данных, настройка моков и так далее.
  • Act. Затем идёт действие. То есть, непосредственно вызов тестируемого функционала.
  • Assert. Финальный этап — проверка. Проверяется всё, что требуется проверить. Какие получились данные, состояние объектов, вызвалось ли то, что нужно, была ли ошибка. В общем, есть где развернуться.

Вот и получается, что паттерн Arrange-Act-Assert за счёт своей простоты и эффективности в отношении организации и написания тестов стал де-факто стандартом индустрии.

▍ На пальцах про Roy Osherove


Есть и другой де-факто стандарт индустрии по тестированию, действительно устоявшийся на 99,9%.

Это конвенция по именованию тестов Roy Osherove, которая выглядит следующим образом:

UnitOfWork_StateUnderTest_ExpectedBehavior

Людям, владеющим английским языком, в целом уже всё понятно.
То есть в имени теста указываем:

  • выполняемую единицу работы,
  • состояние, настраиваемое в тесте,
  • ожидаемое поведение.

Чувствуете, на что похоже? Если вы подумали про AAA, то суть точно уловили.

Таким образом, элементарное правило, очень лёгкое в реализации, кратно увеличивает структурированность проекта и лёгкость в навигации.

▍ Пример


public record Adder(int Value)
{
    public Adder Add(Adder that) =>
        new(Value + that.Value);
}

public class AdderTests
{
    [Fact]
    public void Add_AddingToZero_ResultNotAffectedByZero()
    {
        // arrange
        Adder ten = new(10);
        Adder zero = new(0);
    
        // act
        var sum = ten.Add(zero);
    
        // assert
        sum.Should().Be(ten);
    }
}

Проблематика


Почему об этих соглашениях было важно сказать? Дело в том, что подобный подход к структуризации позволяет систематизировать, и затем явно и очевидно увидеть узкие места.

По сути, при написании тестов узкое место одно — это Arrange этап, поскольку Act и Assert в среднем занимают 2 строчки. А вот Arrange самый интересный и объёмный. Подготовка объектов, коллекций, настройка интеграций, конфигураций — всё там.

А теперь представим, что мы выполняем типовую таску в кровавом ынтэрпрайзе.
Тогда мы напишем некий усреднённый типовой код и получим усреднённый…

▍ Типовой обработчик


public class SomeHandler(
    ISomeRepository repository,
    ISomeProcessingService service,
    ISomeSessionAccessor sessionAccessor,
    ISomeExternalDataProvider provider,
    IOptions<SomeOptions> options,
    ILogger<SomeHandler> logger)
{
    private readonly SomeOptions _options = options.Value;

    public async Task<SomeResponse> Handle(SomeRequest request, CancellationToken ct)
    {
        if (request.Field1 < 0)
            throw new SomeException();

        var entity = await repository.GetByField2(request.Field2, ct);
        if (request.Field3)
        {
            await service.ProcessAsync(entity, ct);
            logger.LogInformation("processed");
        }

        var session = sessionAccessor.Session;
        session.ChangeStatus();

        var externalData = await Task.WhenAll(
            _options.Sources.Select(
                source => provider.GetCollectionBySource(source, ct)));
        return new SomeResponse(
            entity,
            externalData.SelectMany(x => x).ToArray());
    }
}

Это такой средненький JSON перекладыватель, который что-то там сделал в сессии, что-то залоггировал и прочитал из конфигурации, а потом что-то положил в базу и что-то отдал наружу.

Бессмысленный и беспощадный набор символов, пускай семантически и синтаксически верный.

А теперь начнём писать юнит тест согласно практикам Лондонской школы и увидим, что он не влезает в один слайд PowerPoint презентации.

Для мокирования использовал библиотеку NSubstitute — www.nuget.org/packages/NSubstitute
Предпочитаю её за лаконичность и отсутствие SponsorLink инцидентов.

public class SomeHandlerTests
{
    [Fact]
    public async Task Handle_HappyPath_DoesNotThrow()
    {
        var repository = Substitute.For<ISomeRepository>();
        repository.GetByField2(default, default)
            .ReturnsForAnyArgs(new SomeEntity(
                Field2: nameof(SomeEntity.Field2),
                Data: nameof(SomeEntity.Data)));

        var service = Substitute.For<ISomeProcessingService>();

        var sessionAccessor = Substitute.For<ISomeSessionAccessor>();
        sessionAccessor.Session.Returns(Substitute.For<ISession>());

        var provider = Substitute.For<ISomeExternalDataProvider>();
        provider.GetCollectionBySource(default, default)
            .ReturnsForAnyArgs([
                new SomeExternalData(Guid.NewGuid(), Content: 1.ToString(), [1, 2, 3]),
                new SomeExternalData(Guid.NewGuid(), Content: 2.ToString(), [4, 5, 6]),
            ]);

        var handler = new SomeHandler(
            repository,
            service,
            sessionAccessor,
            provider,
            Options.Create(new SomeOptions { Sources = ["source1", "source2"] }),
            NullLogger<SomeHandler>.Instance);

        var response = await handler.Handle(new SomeRequest(
                Field1: 123,
                Field2: "123",
                Field3: true),
            ct: default);

        Assert.NotNull(response);
    }
}

И это всё для того, чтобы проверить базовый сценарий HappyPath.

А есть ещё ветвления, изменения поведения, ошибки интеграций и многое другое.
Казалось бы, можно вынести подготовку SUT (system under test) в отдельный метод и вызывать его в каждом тесте.

Но ведь в нашем приложении есть другие обработчики и сервисы, и тогда код продублируется, а бойлерплейт разрастётся не меньше, чем с линейной скоростью.

▍ Что делать?


По сути Arrange этап преследует простую цель подготовки теста.

Для её достижения шаблонный код готовит данные и поведение, чтобы создать нужный кейс для прогона.

Получается, ответ напрашивается сам собой:

  • Автоматизировать готовку данных. Убрать по максимуму все вызовы new() и [].
  • Автоматизировать готовку поведения. Убрать по максимуму все создания и настройки моков.

Решение


Прежде чем понять «как делать», познакомимся с инструментами, которые помогут нам решить проблему своей синергией. Это ряд NuGet пакетов, о которых вы могли или не могли ранее знать. И первый из них…

▍ AutoFixture


www.nuget.org/packages/AutoFixture

Этот NuGet пакет умеет создавать экземпляры объектов для типов данных, обладающих публичными конструкторами:

var fixture = new Fixture();
var bar = fixture.Create<Bar>();

record Foo(Guid Id);
record Bar(
    Foo Foo,
    string Name,
    bool IsBaz,
    int Number);

Создатель библиотеки — Mark Seemann, автор книги Dependency Injection in .NET.

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

Код выше создаст следующий объект:

{
  "Foo": {
    "Id": "537478f7-a6f0-4e85-8ca2-1d7e0da97e7e"
  },
  "Name": "Name0e4afc29-8ef7-4991-aaa1-2294a456cccc",
  "IsBaz": false,
  "Number": 58
}

▍ AutoData


www.nuget.org/packages/AutoFixture.Xunit2/5.0.0-preview0011

Но я — ленивый не только как программист, мне не хочется инстанциировать экземпляры Fixture и создавать все связанные объекты. Мне хотелось бы, чтобы всё было готово, когда уже запущен тест.

К счастью, существуют коннекторы AutoFixture к тестовым фреймворкам, например, xUnit.
Неформально я их называю AutoData по атрибуту, который организует нужный мне функционал.

Этим атрибутом мы размечаем тесты-теории, и неявно создаётся источник данных, который с помощью экземпляра Fixture создаёт объекты, запрошенные в параметрах теории.

public class TestClass
{
    [Theory, AutoData]
    public void TestMethod1(string foo)
    {
        foo.Should().NotBeEmpty();
    }
  
    [Theory, AutoData]
    public void TestMethod2(Foo foo)
    {
        foo.Bar.Should().NotBeEmpty();
    }
}

public class Foo
{
    public string Bar { get; set; }
}


На самом деле, это синтетическая теория, потому что кейс только один.
Но что делать при наличии источников данных?

Есть атрибуты InlineAutoData, MemberAutoData, ClassAutoData и так далее:

public class TestClass
{
    [Theory]
    [MemberAutoData(nameof(TestData))]
    public void TestMethod3(int a, int b, int c)
    {
        c.Should().BeGreaterThan(a + b);
    }

    public static IEnumerable<object[]> TestData =
    [
        [-1, -2],
        [-3, -4]
    ];
}


В данном примере источник данных предоставлял для кейса первые два параметра теста, а третий создавался силами AutoFixture. Огненная штука, обмазываемся Success'ом!

Итого, мы автоматизировали готовку данных и готовы двигаться дальше!

▍ AutoData + моки == AutoNSubstitute


Давайте, подумаем: что нам делать с моками?
По сути, при готовке моков мы создаём экземпляр мока, а затем сетапим какой-нибудь возврат либо объектом, либо другим моком.

Возникает вопрос: а что если попросить AutoFixture создать мок, в возвраты которого были бы записаны результаты вызовов Create?

Оказывается, так можно сделать с помощью кастомизации AutoNSubstitute.

Также существуют NuGet пакеты для Moq, RhinoMocks и FakeItEasy.

Чтобы создавать моки, нужно прокачать генератор данных с помощью метода Customize, пример теста будет выглядеть следующим образом:

public class TestClassClass
{
    [Fact]
    public async Task Test()
    {
        var fixture = new Fixture()
            .Customize(
                new AutoNSubstituteCustomization
                {
                    ConfigureMembers = true
                });
        var provider = fixture.Create<ISomeExternalDataProvider>();
        var collection = await provider.GetCollectionBySource(
            source: fixture.Create<string>(),
            ct: default);
        Assert.NotEmpty(collection);
    }
}


На скрине видно в отладке, что вернул экземпляр сервиса ISomeExternalDataProvider, созданный AutoFixture, при вызове метода, который был замокан автоматически.

▍ Собираем всё вместе


Теперь можно все эти три вещи собрать воедино через единственный атрибут.

В тестовый метод, размеченный таким атрибутом, будут приходить и объекты, и настроенные моки:

public class AutoNSubstituteDataAttribute() :
    AutoDataAttribute(
        () => new Fixture().Customize(
            new AutoNSubstituteCustomization
            {
                ConfigureMembers = true
            }));

Из коробки такого атрибута нет. Кстати, Марк Симанн в своём блоге предлагает создать его самостоятельно, используя модель расширения через наследование.

И теперь, вспомнив наш типовой обработчик, тот тест, который не помещался ни в какие экраны, можно переписать в 2 строчки и, абстрагировавшись от рутины, сосредоточиться на сценариях проверки логики:

public class SomeHandlerTests2
{
    [Theory, AutoNSubstituteData]
    public async Task Handle_HappyPath_DoesNotThrow(
        SomeRequest request,
        SomeHandler handler)
    {
        var response = await handler.Handle(request, ct: default);

        Assert.NotNull(response);
    }
    
    [Theory, AutoNSubstituteData]
    public async Task Handle_Field1LessThanZero_ThrowsSomeException(
        SomeRequest request,
        SomeHandler handler)
    {
        await Assert.ThrowsAsync<SomeException>(
            () => handler.Handle(request with { Field1 = -1 }, ct: default));
    }
    
    [Theory, AutoNSubstituteData]
    public async Task Handle_NoExternalData_ItIsEmptyInResponse(
        SomeRequest request,
        [Frozen] ISomeExternalDataProvider provider,
        SomeHandler handler)
    {
        provider.GetCollectionBySource(default, default)
            .ReturnsForAnyArgs([]);
        var response = await handler.Handle(request, ct: default);
        response.ExternalDataCollection.Should().BeEmpty();
    }
}

▍ Продвинутые сценарии


AutoFixture — без сомнения мощный генератор данных, но не всесильный. Он не умеет читать мысли, и создавать данные по желанию. Ему нужно сообщить, как правильно это сделать.

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

Можно придумать другой путь модификации поведения, и сейчас я покажу это на реальном примере из практики. Допустим, у нас есть некоторый Value Object, моделирующий дискретную оценку от 1 до 5:

public struct Rate
{
    public Rate(byte value)
    {
        if (value is >= 1 and <= 5)
            Value = value;
        else throw new ArgumentOutOfRangeException(
            nameof(value),
            value, 
            message: "rate can be from 1 to 5");
    }

    public byte Value { get; }
}

Тогда, если мы попробуем запросить в тесте объект оценки, то получим ошибку:

public class TestClassClass
{
    [Theory, AutoData]
    public void Test22(Rate rate)
    {
        rate.Value.Should().NotBe(0);
    }
}


Дело в том, что AutoFixture генерирует данные по-своему:


▍ Обобщённые атрибуты + static abstract


Проведём небольшой research. Если мы заглянем внутрь атрибута [AutoData], то увидим, что у него есть конструктор, который ожидает в качестве параметра делегат типа Func<Fixture>:

public AutoDataAttribute()
    : this(() => new Fixture())
{
}

Получается, можно воспользоваться этим и настраивать объект Fixture любым необходимым нам способом. Однако как получать различные сервисы и зависимости в статическом контексте конструктора? Так ещё и в параметры атрибута можно передавать только константы да типы…

В этом нам помогут нововведения C# 11: static abstract и обобщённые атрибуты, которые, как оказывается, хорошо вместе сочетаются.

public interface IFixtureCustomizer
{
    static abstract void Customize(IFixture fixture);
}

public class AutoDataAttribute<TFixtureCustomizer> : AutoDataAttribute
    where TFixtureCustomizer : IFixtureCustomizer
{
    public AutoDataAttribute() : base(
        fixtureFactory: () =>
        {
            var fixture = new Fixture();
            TFixtureCustomizer.Customize(fixture);
            return fixture;
        })
    {
    }
}

Теперь наше расширение генератора данных для поддержки оценок будет выглядеть следующим образом:

public class RateGenerator : IFixtureCustomizer
{
    public static void Customize(IFixture fixture) =>
        fixture.Register(() => new Rate((byte)Random.Shared.Next(1, 6)));
}

public class TestClassClass
{
    [Theory, AutoData<RateGenerator>]
    public void Test22(Rate rate)
    {
        rate.Value.Should().NotBe(0);
    }
}

Если на вашем проекте до сих пор .NET 6 и старше, то вы просто можете адаптировать это решение по следующему алгоритму:

  • Делаем интерфейс IFixtureCustomizer экземплярным.
  • В расширении атрибута ожидаем тип, который будет передаваться через typeof.
  • Внутри создаём экземпляр кастомизатора рефлексией через Activator.CreateInstance.

▍ Типы данных для тестов


Также можно описывать желаемое поведение и сценарий генерации тестовых данных через классы, объекты которых будут создаваться через AutoData в параметрах теории.

Например, AutoFixture использует библиотеку Fare для создания строк по заданному регулярному выражению.

Мне это пригодилось недавно в пет проекте — для теста мне нужно было генерировать случайные последовательности строк определённой лексики. Вооружившись документацией и смекалкой, я автоматизировал подготовку списка таких строк с помощью класса, передаваемого в параметр теста:

public record LexerInput([property:MinLength(10), MaxLength(25)] TokenInput[] TokenInputs) : IReadOnlyList<string>
{
    public IEnumerator<string> GetEnumerator() =>
        TokenInputs.Select(x => x.Value).GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public int Count => TokenInputs.Length;

    public string this[int index] => TokenInputs[index].Value;

    public override string ToString() =>
        TokenInputs.Aggregate(
            TokenInput.AdditiveIdentity,
            (x, y) => x + y).Value;
}

public record TokenInput([property: RegularExpression(TokenInput.Pattern)] string Value) :
    IAdditiveIdentity<TokenInput, TokenInput>,
    IAdditionOperators<TokenInput, TokenInput, TokenInput>
{
    [StringSyntax(StringSyntaxAttribute.Regex)]
    public const string Pattern = "[a-zA-Z]+|[0-9]+|[+]{2}";

    public static TokenInput operator +(TokenInput left, TokenInput right) =>
        new(left.Value + " " + right.Value);

    public static TokenInput AdditiveIdentity { get; } = new(string.Empty);
}

AutoFixture использует атрибуты пространства имён System.ComponentModel.DataAnnotations для чтения правил создания данных.

В данном случае из [RegularExpression] читается паттерн, по которому проинициализируется размеченное строковое свойство, а из [MinLength] и [MaxLength] читаются границы отрезка для выбора случайного размера массива.
В тесте это используется вот так:

public class RegexLexerTests(ITestOutputHelper output)
{
    [Theory, AutoHydraScriptData]
    public void GetTokens_MockedRegex_ValidOutput(
        LexerInput input,
        [Frozen] IStructure structure,
        RegexLexer lexer)
    {
        output.WriteLine(input.ToString());
        var patterns = TokenInput.Pattern.Split('|');

        structure.Regex.ReturnsForAnyArgs(
            new Regex(string.Join('|', patterns.Select((x, i) => $"(?<TYPE{i}>{x})"))));
        var tokenTypes = Enumerable.Range(0, patterns.Length)
            .Select(x => new TokenType($"TYPE{x}"))
            .ToList();

        // ReSharper disable once GenericEnumeratorNotDisposed
        structure.GetEnumerator()
            .ReturnsForAnyArgs(_ => tokenTypes.GetEnumerator());

        var tokens = lexer.GetTokens(input.ToString());
        for (var i = 0; i < input.Count; i++)
        {
            output.WriteLine(tokens[i].ToString());
            tokens[i].Value.Should().BeEquivalentTo(input[i]);
            tokens[i].Type.Should().BeOneOf(tokenTypes);
        }
    }
}

Итоги


Таким образом, сегодня вы получили пошаговую инструкцию по ракетному торпедированию скорости написания юнит тестов с помощью двухкомпонентной автоматизации Arrange этапа:

  • Готовка данных = AutoFixture + AutoData.
  • Готовка поведения = AutoFixture + AutoData + моки.

Благодаря нововведениям C# функционал библиотек можно расширять типобезопасно, элегантно и эффективно. Ну и, в конце концов, на джаве так не сделаешь!

Эту методику уже используют реальные C# разработчики в следующих компаниях: МТС, Chibbis и ПСБ. А мои подписчики из Mindbox начали процесс внедрения.


Ещё я веду Telegram-канал StepOne, куда выкладываю много интересного контента про коммерческую разработку на C#, даю карьерные советы, рассказываю истории из личного опыта и раскрываю все тайны IT-индустрии.

© 2025 ООО «МТ ФИНАНС»

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻
Теги:
Хабы:
+53
Комментарии8

Публикации

Информация

Сайт
ruvds.com
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
ruvds