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

    Тема автономного тестирования давняя, почтенная, разобранная до косточек. Кажется, что после отличной книги Роя Ошероува и сказать особо нечего. Но на мой взгляд есть некоторая несбалансированность доступных инструментов. С одной стороны монстры вроде 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 GivenUsableWhenDisposeTwiceThenShouldBeException()
    {
        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);
    2. Прогон тестового сценария
      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;
      });
      }
    3. Проверка утверждений:
      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#, поддержанный всей мощью студии и армией разработчиков.

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

    Поделиться публикацией

    Комментарии 12

      +1
      Пользуюсь TestStack.BDDfy и не понял, в чем преимущество вашей библиотеки или недостаток\перегруженность TestStack.BDDfy?
        0
        в чем преимущество вашей библиотеки или недостаток\перегруженность TestStack.BDDfy

        C Reflection API, я думаю, все понятно.
        С Fluent API — BDDFy в качестве контекста передает тестовый объект целиком, что заставляет использовать поля вместо локальных (для каждого действия!) переменных, волшебный вызов BDDFy() в конце (мелочь, но надо же к чему-то еще придраться).
        Спасибо за наводку — до вас я эту библиотеку просмотрел бегло, сейчас прочитал документацию целиком. Остался один небольшой нюанс — как написать сценарий для варианта с ожидаемым исключением?

          +1
          Исключение — как угодно.

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

          Если что — ответ авторский:
          https://github.com/TestStack/TestStack.BDDfy/issues/14

          BDDFy в качестве контекста передает тестовый объект целиком, что заставляет использовать поля вместо локальных (для каждого действия!) переменных

          Тут дело вкуса, имхо, потому что в вашем случае при 3-5 переменных читать код становится нереально.
            0
            Исключение — как угодно.
            BDD — поведенческий подход, а потому в общем случае — произошло исключение, но реально то обычно проверяем тип\текст\содержимое исключения, так что проще не заделывать под исключения отдельный синтаксис.
            Если что — ответ авторский:

            Авторский ответ как раз показывает, что случай с ожидаемым исключением обрабатывается совсем не как угодно, а через свирепый ad-hoc:


            public void WhenICallAMethodWithExpectedException()
            {
                _action = () => MethodUnderTestWhichThrowsException();
            }
            
            [Test]
            public void ThenMyTestShouldBeASuccess()
            {
                Assert.Throws<InvalidOperationException>(() => _action());
            }

            Это сильно искажает исходный смысл теста (по сравнению с аналогичным "гладким"), так как When перестает что-то делать, а реальное тестовое действие исполняется вручную лишь при вызове Then.
            У меня никакого переноса действий нет, а случай ожидаемого исключения обрабатывается единообразно со всеми остальными.


            Тут дело вкуса, имхо, потому что в вашем случае при 3-5 переменных читать код становится нереально.

            Для юнит-тестов переменных, сквозных для всего теста, нужно от одной до трех: тестируемый объект, мок, ожидаемое исключение. Все эти переменные у меня поддерживаются из коробки.
            Переменные, локальные для конкретного этапа, будут локальными и в соответствующих лямбдах.
            На случаи, когда требуется больше одного тестового объекта на сценарий, я (пока) не замахивался.

              +1
              Да, исключения не совсем удобно обрабатываются. Только я в тестах и кейсов таких особо не встречаю, обычно таки на пользователя вываливается текст читабельный в диалоге, а не ошибка.

              ПС: и переменных прилично в приложениях, как минимум пользователь, сущность с которой он работает и пара служебных. Так что тест — окружение пользователя, вполне работающий подход.
                0
                Только я в тестах и кейсов таких особо не встречаю, обычно таки на пользователя вываливается текст читабельный в диалоге, а не ошибка.

                Это значит, что до уровня юнит-тестов вы обычно, скорее всего, не опускаетесь: там пользователь не человек и исключение — полноценный результат.


                Так что тест — окружение пользователя, вполне работающий подход.

                Это если по классу на тест, что на мой взгляд тяжеловато.

                  +1
                  Хм, а вы используете BDD на низкоуровневых юнит-тестах? До меня сейчас это дошло.

                  Что там от «поведения», я чуток не понимаю?
                    0

                    Да то же самое что и у вас, только уровнем ниже. Просто пользователь не человек, а такая же машина.
                    В BDD мне понравился сам подход, но отпугнула некоторая тяжеловесность его реализации. Авторы BDDFy, судя по всему, пришли к схожим выводам.

        +1
        Мне нравиться минималистский, флюидный подход — код теста компактен, не «размазан» по методам, легко охватить взглядом. Идея лежит вроде бы на поверхности, но найти похожую библиотеку мне не удалось.
          0

          Именно это и дало мне стимул к велосипедостроению

            +1
            Ну, тогда осталось выложить на GitHub и в Nuget. И самое сложное — придумать название.
          +1
          Хотелось бы попробовать)

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое