Behavior Driven Development Explained with MSpec

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

Речь пойдет о behavior driven development, идеях, подходах и используемых инструментах. Слово BDD в последнее время стало своего рода buzzword'ом. Разработчиков можно в целом разделить на тех, кому нравится BDD стиль, и те, кто, относится к нему негативно либо крайне негативно. В статье постараюсь пройти по узкой дорожке и не впасть ни в одну из крайностей. Статья ориентирована на разработчиков, которые интересуются BDD подходом, либо практикуют, либо ищут более оптимальные решения и инструменты.

Рассмотрим следующие вопросы:

  1. From unit testing to behavior specifications
  2. Использование MSpec - BDD framework in context-specification style
  3. Conclusion

From unit tests to behavior specifications.

Goal of unit testing and drawback analysis

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

Модульные тесты обычно являются API-ориентированными, то есть фокусируясь на проверке правильности работы модуля, они описаны в терминах этого модуля, его API. API модуля в процессе разработки изменяется довольно часто, например, как результат рефакторинга либо перераспределения обязанностей. В итоге, API-ориентированность тестов увеличивает частоту их изменения при внесении изменений в код.

Юнит тест представляет собой смесь состоящую из бизнес задач и механизмов тестирования этих задач. К бизнес задачам можно отнести совершение тех или иных действия в заданном контексте, проверка их результата (это то, ЧТО мы тестируем). Механизмы тестирования - это mocking frameworks, assertion libraries, все то, что позволяет осуществлять верификацию результата (это то, КАК мы тестируем). Эта смесь усложняет чтение тестов и будущую их поддержку, так как нужно постоянно отделять бизнес специфику от деталей механизмов тестирования. Это приводит к тому, что тесты не сопровождаются должным образом, становясь балластом, начиная приносить больше вреда чем пользы.

В погоне за упрощением юнит теста, разработчики структуриют тело теста, например, используя Four Phase Test Approach, либо Arrance-Act-Assert. Идея состоит в логическом разделении тела метода на части. Разбиение происходит в терминах механизма тестирования, а не в терминах бизнес специфики: инициализация теста, подготовка тестируемого объекта, совершение действий над объектом, верификация результата. Структурирование набора тестов происходит обычно по принципу Test Case Per Class, либо же Test Case Per Feature.

Отбросив технические сложности, такие как плохая читабельность, сложность сопровождения, то основным концептуальным недостатком, независимо от применения test-last либо test-first development'а, является то, что юнит тест позволяет проверить то, что созданное ПО работает корректно, но отнюдь не то, что ПО является корректным.

Executable specifications

Размышления над вопросом "Что значит сделать корректное ПО?" приводят к понятию поведения, спецификации требований. Корректное ПО - такое ПО, поведение которого соответсвует предъявленным ему требованиям. Идея BDD подхода в описании требований (спецификаций), которые могут быть выполнены - executable specifications. Вместе с тем, executable specification наследуют все свойства юнит тестов: repeatable, independent, thorough, maintainable, fast, automatic, professional, readable. Основное отличие от юнит тестов в том, что юнит тест верифицирует правильность работы модуля, а executable specification - описывает желаемое поведение модуля. Описывая поведение модуля, спецификация привязана к API модуля в меньшей степени. Так как поведение меняется значительно реже чем API модуля и сам модуль, то и частота обновления спецификаций ниже, чем частота обновления юнит тестов.

Given-when-then

Использование конструкций given-when-then является методикой структурирования тела спецификаций. Однако, если подходы Four Phase Test Approach, либо Arrance-Act-Assert фокусировались на разбиении теста в терминах механизмов тестирования, то части спецификации given/when/then выражены в терминах бизнеса. Вместо инициализации теста и подготовке объекта мы думаем о контексте, в рамках которого происходит операция (given), вместо вызова методов думаем о сути выполняемой операции (when), вместо тестирования результатов - думаем о поведении (then). Помимо концептуального различия, такой given-when-then подход служит для унификации языка описания спецификаций.

Scenarios and context/specification style

Применение BDD подхода в самой малой его толике - это именование юнит тестов таким образом, чтобы оно описывало поведение тестируемого аспекта. Для названия обычно тоже применяется тот же шаблон given-when-then, либо его разновидности.

Однако BDD - это нечто большее. BDD предполагает выделение поведения как отдельной концепции. Поведения в свою очередь проявляются при каких-то условиях. Набор таких условий определяет сценарий. Сontext/Specification стиль предлагает рассматривать поведения не как плоский список, а фокусироваться на различных сценариях, в рамках которых проявляются те или иные поведения. Следствием такого подхода является то, что одно и тоже поведение может проявлятся в разных сценариях. BDD framework'и в отличие от unit testing framework делают явный акцент на поведениях, сценариях, а не на механике тестирования.

Flavors of BDD approach

BDD подход можно разделить на два вида: xSpec и xBehave.

xSpec - это применение BDD на уровне модулей (unit level), то есть описание спецификаций по отношению к модулям. Обычно используются те же технические инструменты, что и для написания юнит тестов (mocking frameworks, assertion libraries, unit test frameworks). Некоторые BDD framework'и предлагают описывать спецификации, на основании которых потом генерируется код тестов, то есть разделять описание спецификации и реализацию. Думаю, что такая практика неприменима для unit-level спецификаций. Так как единственной ролью, которая работает со спецификациями, путем их описания и реализации, является разработчик, то разделять описание и реализацию спецификации в два разных места не стоит. Более привлекательным выглядит подход, когда описание может быть сгенерировано отдельно на основании кода спецификации, например в виде отчета. Unit-level спецификации применимы на этапе дизайна и реализации ПО, например, как замена стандартных юнит тестов.
Примеры фреймворков: MSpec, NSpec.

xBehave - это применение BDD для описания высокоуровневых пользовательских историй (user stories) в виде приемочных критериев (acceptance criteria) при помощи given-when-then синтаксиса. Охватывает этап сбора и анализа требований, а также этап дизайна. Такой подход пропагандирует описание спецификаций одной ролью, например аналитиком либо тестировщиком, а реализацию уже разработчиком. Тут уже становится оправданным разделить описание и реализацию спецификаций, например в виде текстового файла с историями и автоматической кодогенераций тестов. В таком случае сложно обеспечивать синхронизацию текста историй и сгенерированного кода тестов. Также возникает сложность в реализации тестов. А именно, как реализовать проверку спецификации таким образом, чтобы не потерять свойств юнит тестов (repeatable, independent, thorough, maintainable, fast, automatic, professional, readable). Если же уровень спецификации - пользовательская история, то проверка может вовлечь в себя взаимодействие систем или подсистем. А значит, в лучшем случае - это будет интреграционный тест. И возможно ли вообще в этом случае сделать автоматический тест? Также предполагается переход от story-level спецификаций к unit-level спецификациям. Насколько этот переход будет бесшовным, и будет ли в нем польза - тоже остается для меня пока вопросом.
Примеры фреймворков: NBehave, SpecFlow.

Использование MSpec

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

Пример спецификации в стиле context/specification.

Спецификация состоит из установления контекста (given), в MSpec для этого применяется делегат Establish, выполнения бизнес операции (when), при помощи делегата Because, и наконец верификации поведения (then), при помощи делегатов It. Given и when (Establish, Because) компоненты формируют сценарий, в котором наблюдается то или иное поведение (It).

[Subject(typeof(Shipment))]
public class When_applying_postage_info_from_external_source
{
    Establish _ = () => shipment= InitializePackaging(
        ShippingInfo.Create(PostalService.UA, true, Shape.POSTCARD, new 20.44m.Dollars());
    Because of =()=> pack.ApplyExternalPostage();

    It should_override_shipping_cost =
        () => shipment.PostageCost.ShouldEqual(new PriceInfo(20.44m, Currency.USD));
    It should_not_override_postal_service =
        () => shipment.PostalService.ShouldEqual(PostalService.UA);

    static Shipment shipment;
}
[Subject(typeof(Shipment))]
public class When_applying_postage_info_for_album_which_is_shipped_via_usps_pm
{
    Establish _ =()=> shipment = InitializePackaging("USPS-PM", 20.44m.Dollars()));
    Because of = () => shipment.ApplyExternalPostage();

    It should_override_shipping_cost =
        () => shipment.InternalCost.ShouldEqual(new PriceInfo(20.44m, Currency.USD));

    static Shipment shipment;
}

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

Тех, кто привык писать юнит тесты смущает, что на каждый тест приходится писать новый класс. Однако если взглянуть на то, что это не тест, а законченный сценарий (контекст + наблюдаемые поведения), то можно оправдать overhead создания нового класса в целях моделирования отдельного сценария. На самом деле, ничего криминального в подходе class-per-specification на мой взгляд нету. Это не production code, наличие большого количества классов не увеличивает сложность приложения, так как каждый из таких классов независим друг от друга, эти классы не объединяются в модули с внутренними зависимостями между собой. Для понимания набора спецификаций не нужно анализировать граф зависимостей, спецификации автономны, и представляют скорее плоский список. Также, как показывает практика, при переходе от тестов к спецификациям, их количество уменьшается, засчет того, что количество возможных сценариев в большинстве случаев будет меньше количества сценариев умноженных на уникальное количество поведений (юнит тесты).

Также слышна критика в адрес MSpec касательно применения делегатов и конструкций "=()=>". Тем не менее, функциональный подход позволяет сделать спецификации self-descriptive, давая внятные имена делегатам (It should_override_shipping_cost). Хотя в NSpec например применяется несколько другой подход. Больше смущает то, что классы и его члены смоделированы как static.

Failure verification

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

[Subject(typeof(Shipment))]
public class when_verifying_delivery_for_shipment_without_external_postage_info_specified
{
    Establish _ = () => packaging = InitializePackaging(PostalService.USPS, true, null);
    Because of = () => failure = Catch.Exception(() => shipment.VerifyDelivery());

    It should_fail = () =>
    {
       failure.ShouldBeOfType(typeof(DeliveryVerificationFailed));
       failure.Message.Should.Be("Delivery verification failed due to non existent postage info/");
    }

    static Shipment shipment;
    static Exception failure;
}

Для проверки того, что операция не может быть выполнена, приходится совершать много телодвижений. Во-первых, нужно, определить статическое поле типа Exception, которое будет хранить информацию об ошибке. В делегате Because нужно применить кунг-фу конструкцию Catch.Exception, также которая подразумевает использование делегата. В итоге получается конструкция из двух вложенных делегатов, которая отнюдь не поражает нас читабельностью. В конце концов, в делегате It нужно проверить что это именно та ошибка, которая подразумевается сценарием. Для сравнения, конструкция проверки ошибочной ситуации в классическом юнит тесте (используется FluentAssertions).

[Then]
public void test_for_operation_failure()
{
    analyzer.Invoking(a => a.IsFirstBillingRun(new DateTime(2011, 6, 1)))
        .ShouldThrow<ArgumentException>()
        .WithMessage("Client was not billed on", ComparisonMode.Substring);
}

Simple context

Иногда сценарий слишком прост, контекст которого подразумевается по умолчанию, либо слишком незначителен чтобы выделять его в отдельный шаг. В таком случае спецификацию можно упростить, объеденив given и when шаги. Например.

[Subject(typeof(PostalService))]
public class when_postal_service_is_parsed_from_string_with_postal_service_and_mail_class_separated_by_hyphen
{
    static PostalServiceservice;
    Because of = () => service = PostalService.Parse("USPS-FCM");

    It should_use_first_part_of_string_before_hyphen_as_postal_service =
        () => service.PostalService.ShouldEqual("USPS");
    It should_use_second_part_of_string_after_hyphen_as_mail_class =
        () => service.MailClass.ShouldEqual("FCM");
}

Action-common and context-common scenarios

Обычно несколько спецификаций можно сгруппировать по общим признакам.

  1. Выполняется одна и та же операция, но меняется контекст.
  2. Контекст один и тот же, но меняется операция.

Эту особенность можно использовать и вынести общую часть за скобки, тем самым разгрузив спецификацию от повторяющихся деталей, которые подразумеваются по умолчанию. Например, как в данном context-common scenario.

 
public class with_weight_calculator
{
    Establish _ = WeightCalculator;

    private static void WeightCalculator()
    {
        deviceWeightProvider = MockRepository.GenerateStub<DeviceWeightProvider>();
        boxSelector = MockRepository.GenerateStub<BoxSelector>();
        postageCalculator = new WeightCalculator(
            deviceWeightProvider ,
            boxSelector)
    }

    static WeightCalculator weightCalculator;
    static BoxSelector boxSelector;
    static DeviceWeightProvider deviceWeightProvider;
}

[Subject(typeof(PostageCalculator), "external shipping cost calculation")]
public class when_calculating_weight_for_medium_device_packed_into_safebox : with_weight_calculator
{
    Establish context = () =>
    {
        device = Device.New(new Client(1, "1"), "Notebook")
            .WithBoxingInfo("Safe") 
            .Build();
    };
    Because of = () => weight= weightCalculator.CalculateWeight(order);

    It should_be_greather_than_1kg =()=> weight.Should().BeGreather(1.Kilograms());

    static Device device;
    static Weight weight;
}

MSpec позволяет выделять общие части сценария поднимая их в базовый класс. Когда несколько сценариев разделяют общий контекст, я предпочитаю базовый класс называть по шаблону "with_{COMMON_CONTEXT}". Если разделяется общая операция - то по шаблону "when_{COMMON_ACTION}".

Можно вынести как Establish, так и Because, а также общие члены, которые используются в сценарии. Однако не все то, что делать можно, делать стоит. Введение иерархии наследования сильно связывает классы, и на это должна быть весомая причина. К примеру есть общий контекст, повторяющийся от спецификации к спецификации, который подразумевается по умолчанию, и описательная сила спецификации не уменьшится, если разнести создание контекста по разным местам. Важно помнить, что спецификация должна быть читабельной, и коммуницировать разработчику поведение. Если для того чтобы понять, что имелось ввиду, приходиться ходить по 3-уровневой иерархии наследования, то это не true. Я бы не выходил за рамки 2-уровневой иерархии для структурирования спецификаций. Двух уровней должно быть достаточно.

Behavior specification suite

Есть случаи, в которых применение context\specification style представляется излишней сложностью. Например, нужно проверить простое поведение, сценарий в предельно просто и неразрывно связан с поведением, например вызов метода и проверка его результата. В таком случае не хочется создавать много классов с двумя строчками кода в каждом. Например.

[Subject(typeof(Weight))]
public class When_converting_weight
{
    It should_convert_ounces_to_grams_according_to_coefficient =
        () => new Weight(1, Weight.Ounce).ConvertToGramms()
            .ShouldEqual(new Weight(1 / gram_to_ounce_coefficient, Weight.Gram));
    It should_convert_grams_to_ounces_according_to_coefficient =
        () => new Weight(1, Weight.Gram).ConvertToOunces()
            .ShouldEqual(new Weight(1 * gram_to_ounce_coefficient, Weight.Ounce));
    It should_do_nothing_when_converting_grams_to_grams =
        () => new Weight(1, Weight.Gram).ConvertToGramms().ShouldEqual(new Weight(1, Weight.Gram));
    It should_do_nothing_when_converting_ounces_to_ounces =
        () => new Weight(1, Weight.Ounce).ConvertToOunces().ShouldEqual(new Weight(1, Weight.Ounce));

    const decimal GRAM_TO_OUNCE_KOEFFICIENT = 0.03527m;
}

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

Behavior/scenario specification style

Иногда нужно проверить, что отдельно взятое поведение, проявляется в разных сценариях. Если сравнивать с context/specification style, где мы движемся от сценария к поведениям, которые в нем наблюдаются, то в данном случае, мы движемся в обратном направлении - от поведения к сценариям, в которых оно проявляется либо не проявляется. Например.

[Subject(typeof(PostalService))]
public class postal_service_is_considered_to_be_more_expensive
{
    It is_usps_priority_mail = () => ShippingInfo.Builder
        .New(PostalService.USPS, MailClass.PRIORITY_MAIL).Build()
        .IsPremium.ShouldBeTrue();
    It is_russin_post_fast = () => ShippingInfo.Builder
        .New(PostalService.RUS, MailClass.FAST).Build()
        .IsPremium.ShouldBeTrue();
    It is_ukraine_post = () => ShippingInfo.Builder
        .New(PostalService.UAPOST).Build()
        .IsPremium.ShouldBeTrue();
}

Происходит проверка одного и того же поведения (shipping service should be premium) в разных сценариях (when usps priority mail, when fedex, etc.). Если бы мы применили context/specification style, то получили бы четыре сценария. MSpec, будучи context/specification фреймворком, не поддерживает behavior/scenario стиль. Пример приведенный выше - это лишь попытка использовать существующие средства для описания описания спецификации в behavior/scenario стиле.

Pros and cons of MSpec

Подведем итог, сформулируем плюсы и минусы использования MSpec в behavior-driven-development'е.

Pros:

  1. MSpec - фреймворк который был специально построен для поддержки BDD стиля, а значит впитал в себя его идеи. В противоположность unit-testing фреймворкам, где разработчик волен сам выбирать стиль написания юнит тестов, использование MSpec принуждает разработчика именно к behavioral стилю. Это может быть как плохо так и хорошо, в зависимости от того, насколько жесткие правила и ограничения фреймворк накладывает на разработчика. Явно позиционируется фокус на спецификацию поведения, а не тестирования функциональности.
  2. Инструментальная поддержка. Интеграция с ReSharper.
  3. Описание спецификации и ее реализация не разделены (разные файлы), как это обычно делается в xBehave BDD framework'ах.
  4. Поддержка вынесения общего контекста или общей операции "за скобки" путем поднятия делегатов Establish, Because в базовый класс.

Cons:

  1. Использование static классов.
  2. Синтаксический шум. Чрезмерное использование делегатов приводит к появлению громоздких синтаксических конструкций, в которых теряется бизнес специфика.
  3. Низкий уровень readability для средних и больших спецификаций. Небольшие спецификации компактны и читабельны, но с увеличением размера спецификации читабельность резко ухудшается.
  4. Смесь бизнес и технических деталей в теле спецификации. По сравнению с юнит тестами, бизнес специфика явно утверджается при помощи имен классов и делегатов, но по-прежнему перемешана с механизмами тестирования (использование mocking framework'ов, assertions libraries, создание/подготовка объекта).
  5. Отсуствие поддержки behavior-scenario стиля описания спецификаций.

Conclusion

BDD - идея и реализация

Что касается перехода от unit-testing к behavior-driven development, стоит различать две вещи: идея, концепция и ее реализация. BDD как идея и концепция мне нравится, это сдвиг мировоззрения по сравнению с применением стандартного подхода юнит-тестирования. Использование же существующего фреймворка, такого как MSpec, показывает в свою очередь и определенные минусы. Однако это не повод отбрасывать идею вовсе. Когда я показываю спецификации, написанные на MSpec, другим разработчикам, то сразу возникают вопросы: "А что: каждый тест - это класс?", "А почему классы статические?", "Что за =()=>?". Не увидев за этими деталями реализации саму идею и принципы BDD, разработчик отворачивается от этого подхода и вовсе, приводя в качестве аргументов "против" неудавшиеся детали реализации.

Benefits

Моя личная практика использования BDD-подхода, в context/specification style, привела следующим наблюдениям:

  • Уменьшение количества сценариев по отношению к количеству юнит-тестов.
  • Поведения группируются относительно сценариев в которых они проявляются. Легче стало проверять одно и тоже поведение, которое проявляется в разных сценариях.
  • Легче стало применять spec(test)-first подход. Описания спецификаций меньше привязаны API модуля, чем юнит тесты.
  • Спецификации легче в сопровождении. Возвращаясь через некоторое время к спецификации, бывает достаточно прочитать ее описание для понимания поведения, и реже для этого приходиться лезть в код модуля.

SimpleSpec - simple unit-level BDD framework for .net

Те, кто принял идею BDD, и кого не устраивают существующие реализации, могут создать свой фреймворк на основании используемой ими unit-testing library. Слово фреймворк может в этом случае звучать пафосно, так как он может состоять всего из одного класса.

В своей практике я тоже пришел к идее создания велосипеда собственной простой версии BDD framework'а, который бы учитывал наблюдения, которые я сделал при работе с MSpec. В следующей статье из цикла "BDD Clarified" (work is in progress) детали и использование этого фреймворка будут рассмотрены подробнее. А пока лишь реклама.

[Scenario]
public class when_calculating_user_attendance_variation : with_user_attendance_statistics_analyzer
{
    double revenueVariation;

    public when_calculating_user_attendance_variation()
    {
        IsA<ScenarioSpecification>();
        Given(user_attendance_statistics_analyzer);
        Given(() => user_sent_visits(
            new UserVisit(client, 1, new DateTime(2011, 7, 1), 10, 1, new Money(3.0m, Currency.USD)),
            new UserVisit(client, 1, new DateTime(2011, 7, 1), 10, 1, new Money(4.0m, Currency.USD)),
            new UserVisit(client, 2, new DateTime(2011, 8, 1), 10, 1, new Money(10.0m, Currency.USD)),
            new UserVisit(client, 3, new DateTime(2011, 9, 1), 10, 2, new Money(4.0m, Currency.USD))));
        Given(() => analyzer.LoadUserStatistics(client));
        When(() => userCountVariation = analyzer.CalculateUserCountVariation(new DateTime(2011, 8, 1)));
    }

    [Then]
    public void variation_should_be_calculated_upon_total_count_of_all_users_of_subsequent_statistics_snapshots()
    {
        // (10USD - 7 USD)/7USD
        userCountVariation .Should().BeApproximately(0.4285, 0.0001);
    }
}

Resources

Behavior driven development:

BDD frameworks:

  • MSpec - Machine.Specifications is a Context/Specification framework geared towards removing language noise and simplifying tests. How to MSpec?
  • SpecFlow - binding business requirements to .Net code. Getting started.
  • NBehave is a framework for defining and executing application requirement goals.
  • NSpec is a BDD framework for .NET of the xSpec (context/specification) flavor. NSpec is intended to be used to drive development through specifying behavior at the unit level. NSpec is heavily inspired by RSpec and built upon the NUnit assertion library.

5 comments:

Мурадов Мурад said...

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

cerber said...

Как говорил товарищ Эрик Эванс: "Надо говорить языком предметной области". Отличная статья.

Anonymous said...

Согласен с упоминанием Эрика Эванса предыдущим комментатором. Имхо, без хорошего внедрения DDD в проекте либо не будет четко сформулированных бизнес-спецификаций, т.к. нет единой доменной модели понятной и тем, кто формулирует бизнес-требования, либо в коде проекта не будет четкой объектной модели, соответствующей предметной области, тогда код тестов будет чрезвычайно громоздким и нечитаемым.

Mamacita Lucy said...

Я не мог поверить, что существует настоящий онлайн-кредитор, который может быть таким добрым и честным, как Бенджамин Ли, который предоставил мне ссуду в 2 миллиона евро для выполнения моего проекта, который так долго ждал своего исполнения, но с С помощью офицера Бенджамина все было легко для меня. Я скажу вам связаться с кредитным офицером Бенджамином Ли по адресу 247officedept@gmail.com

achmedfadeley said...

Spin Palace Casino review & bonus offers | Bonuses for 2021
Spin Palace Casino offers a range of casino games to players from live games and table 먹튀 검증 먹튀 랭크 games w88 login to live dealers. Play slots 해외야구 for real money, and 사이트 추천 spin 강원 랜드 여자 앵벌이

Post a Comment