Сверхлегкая BDD: малая механизация автономных тестов

в 5:17, , рубрики: .net, bdd, C#, nunit, specflow, библиотеки, ооп, Программирование, Проектирование и рефакторинг, фреймворки

Тема автономного тестирования давняя, почтенная, разобранная до косточек. Кажется, что после отличной книги Роя Ошероува и сказать особо нечего. Но на мой взгляд есть некоторая несбалансированность доступных инструментов. С одной стороны монстры вроде SpecFlow, с огромным оверхедом ради возможности писать тесты-спецификации на квази-естественном языке, с другой — челябинская суровость фреймворков старой школы вроде NUnit. Чего не хватает? Инструмента для лаконичной, выразительной, легко читаемой записи тестов, по удобству и ортогональности аналогичного библиотекам для создания подделок, таких как FakeItEasy, или проверки утверждений вроде FluentAssertion.

В настоящий момент я пытаюсь создать такой инструмент.

BDD из топора

Вот так выглядит типичный тест с использованием моей микробиблиотеки:

[Test]
public void GivenSelfUsableWhenDisposeThenValueShouldBeDisposed()
{
    Given(A.Fake<IDisposable>().ToUsable()).
    When(_ => _.Dispose()).
    Then(_ => _.Value.ShouldBeDisposed());
}

Также задействованы библиотеки FakeItEasy и FluentAssertions, но не как зависимости, а каждая для решения своих задач (подделки и проверка утверждений).
Эквивалентный код в стиле старой школы:

[Test]
public void GivenSelfUsableWhenDisposeThenValueShouldBeDisposed()
{
    // Arrange
    var usable = A.Fake<IDisposable>().ToUsable();

    // Act
    usable.Dispose();

    // Assert
    usable.Value.ShouldBeDisposed();
}

Поддержка моков

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

Код в новом стиле:

[Test]
public void GivenNeutralUsableWhenDisposeThenValueShouldBeNotDisposed()
{
    Given(A.Fake<IDisposable>()).
        And(mock => mock.ToNeutralUsable()).
    When(_ => _.Dispose()).
    ThenMock(_ => _.ShouldBeNotDisposed());
}

С помощью метода And результат предыдущего Given фиксируется как мок, а тестовым объектом становится результат работы делегата. Это логично, так как мок используется в тестируемом объекте и его естественно создавать раньше.

Часто утверждения включают в себя и мок, и тестируемый объект. Такой вариант тоже поддерживается:

[Test]
public void GivenObjectWhenToUsableThenValueShouldBeSameAsObject()
{
    Given(A.Fake<object>()).
        And(mock => mock.ToUsable(A.Dummy<IDisposable>())).
    When(_ => _).
    Then((_, mock) => _.Value.Should().Be.SameAs(mock));
}

Поддержка исключений

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

[Test]
public void GivenDisposableWhenDisposeTwiceThenShouldBeDisposedTwice()
{
    Given(A.Fake<IDisposable>()).
        And(mock => A.Dummy<object>().ToUsable(mock)).
    When(_ => _.Dispose()).
        And(_ => _.Dispose()).
    ThenCatch(e => e.Should().Be.OfType<ObjectDisposedException>());
}

Кроме того, в этом коде видна...

Поддержка дополнительных действий и утверждений.

С помощью метода расширения And можно добавить дополнительные действия и проверки утверждений (выполняются в порядке записи вызовов метода). Это позволяет удобно структурировать код тестов.

Секретный ингредиент

Топором в микробиблиотеке работает вот такой класс:

public abstract class GivenWhenThenBase<T, TMock>
{
    internal GivenWhenThenBase(T result, TMock mock)
    {
        Result = result;
        Mock = mock;
    }

    internal T Result { get; set; }
    internal TMock Mock { get; }
}

Отдельным этапам тестирования соответствуют его наследники

public sealed class GivenResult<T, TMock> : GivenWhenThenBase<T, TMock>
{
    internal GivenResult(T result, TMock mock) : base(result, mock)  {}
}

public sealed class WhenResult<T, TMock> : GivenWhenThenBase<T, TMock>
{
    internal WhenResult(T result, TMock mock, Exception e = null) : base(result, mock)
    {
        Exception = e;
    }

    internal Exception Exception { get; set; }
}

public sealed class ThenResult<T, TMock> : GivenWhenThenBase<T, TMock>
{
    internal ThenResult(T result, TMock mock, Exception e = null) : base(result, mock)
    {
        Exception = e;
    }

    internal Exception Exception { get; set; }
}

Наследование реализации спроектировано в соответствии с рекомендациями из моей предыдущей статьи.

Приправы

Вся видимая магия реализована в LINQ-стиле с помощью обобщенных методов расширения.

  1. Создание теcтируемого объекта (и мока)

public static GivenResult<T, object> Given<T>(T result) => 
    new GivenResult<T, object>(result, null);

public static GivenResult<T, TMock> And<T, TMock>(this GivenResult<TMock, object> givenResult, Func<TMock, T> and) => 
    new GivenResult<T, TMock>(and(givenResult.Result), givenResult.Result);

  1. Прогон тестового сценария

public static WhenResult<TResult, TMock> When<T, TMock, TResult>(this GivenResult<T, TMock> givenResult, Func<T, TResult> when)
{
    try
    {
        return new WhenResult<TResult, TMock>(when(givenResult.Result), givenResult.Mock);
    }
    catch (Exception e)
    {
        return new WhenResult<TResult, TMock>(default(TResult), givenResult.Mock, e);
    }
}

public static WhenResult<TResult, TMock> And<T, TMock, TResult>(this WhenResult<T, TMock> whenResult, Func<T, TMock, TResult> and)
{
    if (whenResult.Exception != null)
        return new WhenResult<TResult, TMock>(default(TResult), whenResult.Mock, whenResult.Exception);
    try
    {
        return new WhenResult<TResult, TMock>(and(whenResult.Result, whenResult.Mock), whenResult.Mock);
    }
    catch (Exception e)
    {
        return new WhenResult<TResult, TMock>(default(TResult), whenResult.Mock, e);
    }
}

public static WhenResult<T, TMock> When<T, TMock>(this GivenResult<T, TMock> givenResult, Action<T> when)
{
    return givenResult.When(o =>
    {
        when(o);
        return o;
    });
}

public static WhenResult<T, TMock> And<T, TMock>(this WhenResult<T, TMock> whenResult, Action<T, TMock> and)
{
    return whenResult.And((o, m) =>
    {
        and(o, m);
        return o;
    });
}

  1. Проверка утверждений:

public static ThenResult<T, TMock> Then<T, TMock>(this WhenResult<T, TMock> whenResult, Action<T, TMock, Exception> then)
{
    then(whenResult.Result, whenResult.Mock, whenResult.Exception);
    return new ThenResult<T, TMock>(whenResult.Result, whenResult.Mock, whenResult.Exception);
}

public static ThenResult<T, TMock> Then<T, TMock>(this WhenResult<T, TMock> whenResult, Action<T, TMock> then)
{
    return whenResult.Then((r, m, e) =>
    {
        e.Should().Be.Null();
        then(r, m);
    });
}

public static ThenResult<T, TMock> ThenMock<T, TMock>(this WhenResult<T, TMock> whenResult, Action<TMock> then)
{
    return whenResult.Then((r, m, e) =>
    {
        e.Should().Be.Null();
        then(m);
    });
}

public static ThenResult<T, TMock> Then<T, TMock>(this WhenResult<T, TMock> whenResult, Action<T, TMock> then)
{
    return whenResult.Then((r, m, e) =>
    {
        e.Should().Be.Null();
        then(r, m);
    });
}

public static ThenResult<T, TMock> ThenCatch<T, TMock>(this WhenResult<T, TMock> whenResult, Action<Exception> then)
{
    return whenResult.Then((r, m, e) =>
    {
        e.Should().Not.Be.Null();
        then(e);
    });
}

Подопытный кролик

В примерах кода испытаниям на прочность подвергался класс из моей статьи Disposable без границ и несколько методов расширения. На данный момент класс переименован из Disposable в Usable во избежании коллизий имен с повсеместно используемым паттерном.

public sealed class Usable<T> : IDisposable
{
    internal Usable(T resource, IDisposable usageTime)
    {
        _usageTime = usageTime;
        Value = resource;
    }

    public void Dispose() => _usageTime.Dispose();

    public T Value { get; }

    private readonly IDisposable _usageTime;
}

public static class UsableExtensions
{
    public static Usable<T> ToUsable<T>(this T resource, IDisposable usageTime) => 
        new Usable<T>(resource, usageTime);

    public static Usable<T> ToUsable<T>(this T resource) where T : IDisposable =>
         resource.ToUsable(resource);

    public static Usable<T> ToNeutralUsable<T>(this T resource) => 
         resource.ToUsable(Disposable.Empty);
}

Итоги

Плюшки нового подхода по сравнению со старой школой:

  1. Код вместо комментариев
  2. То, что понимает и контролирует компилятор, приближено к тому что понимает и контролирует человек.
  3. Лучше и лаконичность, и выразительность, и читаемость.
  4. Повторяющиеся действия выделяются в отдельные методы легко и приятно.
  5. В едином стиле с обычными тестами поддержано использование моков и утверждений для выброшенных исключений

Плюшки по сравнению с высокоуровневыми BDD-фреймворками:

  1. В разы меньше церемоний и многословия.
  2. Ортогональность по отношению к другим библиотекам, облегчающим тестирование.
  3. Язык тестов — обычный C#, поддержанный всей мощью студии и армией разработчиков.

Дополнения и критика традиционно приветствуются.

Автор: Bonart

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js