Тестирование поведения приложений: эволюция подходов. От debugging к unit testing, TDD и BDD.

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

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

Debugging hell

Все из нас знают такой инструмент, как debugging. Сейчас очень редко приходится прибегать к нему, однако это не всегда было так. После написания программы, обычно довольно тривиальной, возникает желание убедиться в том, что то, что написано, это то, что мы хотели написать, а не то, что у нас получилось. Единственный инструмент, который имеется в наличии, или о которым мы знаем - это debugging. Ставим breakpoint в entry point программы, запускаем ёё, и начинаем отслеживать ход исполнения и верифицировать результаты.

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

Потратив массу времени на отладку и проверив все сценарии, к конце концов мы получаем заряд уверенности, что все ОК. Стоит отметить, что у другого человека, который не провел night full of debugging, такого заряда нет, и он вряд ли передастся ему от Вас. Что произойдет когда нужно будет внести изменения в поведение программы? Наша уверенность испарится немедленно. И здесь выбор невелик, либо снова засесть за debugging и пепроверить все сценарии, либо просто убедить себя, что все ОК. Если поджимают сроки, к примеру, сегодня защита курсового, то на первый вариант элементарно нет времени, даже если есть желание (хотя это вряд ли), и остается второй вариант. Что дальше? А дальше программа падает в самый не подходящий момент, например на презентации при защите перед комиссией.

Ручной запуск приложения в разных сценариях

Если приложение хоть немного сложнее, чем вывод на консоль "Hello %username%", то нам нужен код, который будет имитировать различные сценарии, запускать приложение, передавая ему подготовленные входные данные и зависимости. Обычно создается отдельное консольное приложение, либо меняется entry point самого приложения, чтобы работать как в production, так и в testing режиме.

public class Program
{
    public static void Main(string[] args)
    {
        var mode = (args != null && args.Length > 0 ? args[0] : "prod").ToUpper();
        if(mode == "PROD")
        {
            LaunchProgram();
        }
        else if(mode == "TEST")
        {
            TestProgramInScenarioA();
            //TestProgramInScenarioB();
            //TestProgramInScenarioC();
            //TestProgramInScenarioD();
            //TestProgramInScenarioE();
        }
        else
        {
            Console.WriteLine("Only prod and test modes are supported"); 
        }
    }
}

Запуск происходит в ручном режиме, происходит воссоздание отдельно взятого сценария, а дальше происходит погружение в "старый добрый" debugging. Этот этап мало чем отличается от предущего, за тем лишь отличием, что перед debugging добавлена некоторая подготовительная работа по имитации сценария.

Интеграционные тесты в ручном режиме

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

private void TestProgramInScenarioA()
{
    var sorter = new QuickSortAlgorithm();
    var sortedList = sorter.Sort(new[] {1, 2, 3, 4, 5, 6, 7}, true);

    VerifyEquality(sortedList, new[] {1, 2, 3, 4, 5, 6, 7});
}

private void TestProgramInScenarioB()
{
    var sorter = new QuickSortAlgorithm();
    var sortedList = sorter.Sort(new[] { 1, 2, 3, 4, 5, 6, 7 }, false);

    VerifyEquality(sortedList, new[] { 7, 6, 5, 4, 3, 2, 1});
}

private void TestProgramInScenarioC()
{
    var sorter = new QuickSortAlgorithm();
    var sortedList = sorter.Sort(new[] { 2, 5, 1, 4, 6, 3, 7 }, true);

    VerifyEquality(sortedList, new[] { 1, 2, 3, 4, 5, 6, 7 });
}

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

  1. Результаты не являются повторяемыми (repeatable).
    Для того, чтобы автоматическая проверка стала возможной, конечный результат должен обладать свойством повторяемости, то есть оставаться неизменным в зависимости от времени запуска. Так как приложение тестируется в целом, то есть тест интеграционный, то на конечный результат влияет множество факторов, как внутренних, так и внешних по отношению к приложению. Однако в отличии от внутренних факторов, которые можно контролировать, на внешние факторы повлиять зачастую не представляется возможным, что приводит к неповторяемым результатам.
  2. Приложение вообще может не иметь осязаемого результата работы, который можно проверить в детерминированной манере.
  3. Неудобство ручного запуска тестов.
    Приходиться заниматься вопросами ручного запуска, организации, поддержки набора тестов, и каждый раз создавать для этого доморощенную инфраструктуру.
  4. Debugging активность по прежнему доминирует в большом объеме.

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

Интеграционные тесты + unit testing framework

На этом этапе применяется один из unit-testing framework'ов, такой как NUnit, TestDriven.NET, MSTest, xUnit, ну и так далее.

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

На этом этапе может поменяться подход к именованию тестов. Тестов становится больше, и названия типа TestApplicationInScenario111 начинают вызывать отвращение.

[Test]
private void TestQuickSortInAscOrderOnAlreadyPresortedData()
{
    var sorter = new QuickSortAlgorithm();
    var sortedList = sorter.Sort(new[] {1, 2, 3, 4, 5, 6, 7}, true);

    VerifyEquality(sortedList, new[] {1, 2, 3, 4, 5, 6, 7});
}

[Test]
private void TestQuickSortInDescOrderOnReversedData()
{
    var sorter = new QuickSortAlgorithm();
    var sortedList = sorter.Sort(new[] { 1, 2, 3, 4, 5, 6, 7 }, false);

    VerifyEquality(sortedList, new[] { 7, 6, 5, 4, 3, 2, 1});
}

[Test]
private void TestQuickSortInDescOrderOnRandomInputData()
{
    var sorter = new QuickSortAlgorithm();
    var sortedList = sorter.Sort(new[] { 2, 5, 1, 4, 6, 3, 7 }, true);

    VerifyEquality(sortedList, new[] { 1, 2, 3, 4, 5, 6, 7 });
}

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

The name of this tests SUCKS BALLS and does not clearly convey the intent of the test.

Также можно интегрировать запуск тестов в continuous integration процесс, если конечно на данном этапе мы уже практикуем continuous integration. А если учесть что тесты интеграционные, и их результаты непредсказуемы, то применение этого подхода не несет никакой пользы.

Переход от интеграционных тестов к unit тестам

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

Свойства хорошего unit-test'а

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

  1. Repeatable

    Для того, чтобы автоматическое тестирование стало возможным, надо обеспечить повторяемость результатов тестов (repeatable test). В этом состоит главное отличие от интеграционных тестов, результат работы которых непредсказуем. Если мы написали модульный тест, который не обладает свойством повторяемости, то это просто маленький интеграционный тест.

    Вне зависимости от выбора уровня детализации модуля, если модуль в свою очередь зависит от других модулей (подсистем, систем), которые в данном случае играют роль внешних факторов, на которые мы повлиять не можем, но которые в то же время влияют на результат, то результат становится недетерминированным и неповторяемым. Следовательно, логика модуля должна тестироваться в изоляции от внешних зависимостей и факторов. Внешние зависимости заменяются искусственными сущностями, на которые можно повлиять. Приходим к идее использования fakes, в форме stubs and mocks.

  2. Independent

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

    Тест должен фокусироваться на одном аспекте работы модуля. Не нужно стараться в одном тесте покрыть множество различных аспектов и проверить сразу все. Иначе это будет шагом назад в сторону интеграционных тестов.

    Идея состоит в том, чтобы в случае упавшего теста, можно было уверенно указать на проблему, не прибегая к ручной отладке. В идеале для понимания проблемы должно быть достаточно взглянуть на название теста. Если не помогло, далее на код теста. Отсюда следует, что тест должен быть написан профессионально (professional), быть читабельным (readable), лаконичным, в написании теста должны применяться те же практики и принципы, что и при написании production кода. Если и названия кода теста для понимания проблемы недостаточно, то это весьма плохо, в этом случае приходится заглядывать в code under test, либо что еще хуже, прибегать к debugging.

  3. Thorough
  4. Code coverage аспектов работы модулей должно стремиться к 100%.

  5. Maintainable
  6. Так как уровень детализации тестов переместился на отдельные модули системы, то количество тестов значительно вырасло. А значит уровень maintainability становится жизненно важным фактором. В противном случае можно создать массу кода, покрыть ёё не меньшей массой тестов, радоваться 100% покрытию. А при добавлении новой функциональности либо изменении уже существующей, придется выкинуть написанные тесты и переписать заново, так как уровень их поддержки равен нулю. Нужно применять профессиональный подход в написании теста, обеспечивать readability, brevity, контролировать количество тестов, решать вопросы организации тестов, поддерживать их в актуальном состоянии. Тесты, которые не поддерживаются в актуальном состоянии, становятся балластом, а от балласта рано или поздно избавляются. В таком случае написание тестов - это пустая трата времени и сил.

  7. Fast

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

  8. Automatic

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

  9. Professional
  10. Readable

Benefits

Проанализируем выгоды полученные от применения модульного тестирования.

  1. Набор тестов позволяет с большей долей уверенности гарантировать корректное поведение приложения.
  2. Набор тестов позволяет доказать, что код работает.
  3. Наличие постоянной обратной связи от системы. Последствия внесения изменений становятся предсказуемыми.
  4. Написание тестов поощряет и принуждает к хорошему дизайну системы.
  5. Облегчение регресионного тестирования, уменьшение количества дефектов в production коде.
  6. Тесты служат в качестве up-to-date документации кода модулей.
  7. Обретение чувства спокойствия, уверенности.

При внесении изменений, сразу можно оценить последствия внесенных изменений, понять что и где сломалось и сломалось ли вообще. Не нужно для этого проводить многочасовой debugging session. Исчезает страх внесения изменения, страх сломать случайно что-то в другом конце системы. Появляется уверенность в корректной работе системы. Уменьшается количество дефектов в production коде. А если дефект все-таки обнаруживается, то нету чувства паники и беспокойства, взамен приходит чувство спокойствия и уверенности.

Для обеспечения независимости и повторяемости теста, нужно чтобы модуль был спроектирован таким образом, чтобы допускать тестирование его логики в изоляции от других компонент. Таким образом поощряется применение SOLID принципов, single responsibility principle, dependency isolation, dependency injection. В прошлой своей статье я описывал вопросы, посвященные управлению зависимостями между модулями системы.

Тесты, которые обладают описанными свойствами могут служить в качестве документации кода и поведения системы. Документация, внедренная в код модулей, зачастую устаревает, и при внесении изменений в модуль, документацию, например в виде комментариев, лень обновлять или нет времени. Так как тесты поддерживаются в актуальном состоянии, то такая документация никогда не устаревает и всегда находится up-to-date. В будущем, для того, чтобы понять что делает код, не нужно смотреть на код, достаточно посмотреть на тесты, а в идеале, лишь на названия тестов.

Вопросы, на которые нет ответов

При процессе применения unit-testing незамедлительно возникают такие вопросы.

  • Что тестировать?
  • Что не тестировать?
  • Каков должен быть уровень детализации тестов?
  • Как именовать тесты?
  • С чего начинать написание тестов?
  • С чего начинать написание кода модулей?

Модульное тестирование, будучи инструментом, а не методологией, не в состоянии дать ответы на эти вопросы. Инструмент не знает сам по себе, как правильно он должен применяться. Ответы на эти вопросы дают уже такие методологии, как test-driven development, behavior-driven development.

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

  • Задаются вопросом, как можно писать тесты до того, как написан код модуля (test-first), и пытаются это делать.
  • Количество тестов, уровень покрытия и уровень их детализации определяют интуитивно. Обычно это приводит к увеличению массы тестов и уменьшению их сопровождаемости.
  • Думают, что то, чем они сейчас занимаются - это и есть test-driven development. Нужно заметить, что на это убеждение большое влияние оказывает сбивающее с толку название этой методологии, а именно, наличие слова "test".

Test-first development

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

Основное отличие от unit-testing практики состоит в том, что вместо концентрации на тестировании функциональности модуля, разработчик концентрируется на дизайне модуля и осмыслении его поведения до реализации логики модуля. Применение test-first подхода заставляет сначала задуматься над требованиями, осмыслить их, понять, затем преобразовать требования в набор тестов, и только затем приступить к реализации логики модуля. Разработчик вынужден проводить глубокий анализ требований перед тем, как приступить к кодированию. Это уменьшает вероятность того, что модуль, будучи реализованным, потребует изменения из-за недопонимания требований.

После того как требования ясны и поняты, они описываются в виде тестов. Код тестов становится первым клиентом кода модуля и вносит ясность в каких сценариях используется модуль и описывает различные варианты использования модуля. Вследствие этого дизайн модуля может быть спроектирован на основании конкретных вариантов, сценариев использования, требований, а не на основании абстрактных предположений разработчика. Фокус смещается на проектирование дизайна модуля. Test-first подход позволяет создавать удобный для использования API модулей.

Test-first подход поощряет такой важный принцип, как simplicity. Нужно написать ровно столько кода модуля, чтобы выполнились все тесты, а так как тесты - это воплощение требований к модулю, то выполнение всех тестов - означает, что модуль удовлетворяет всем предъявленным к нему требованиям. Это позволяет не удариться в over-engineering и поощряет принципы KISS (keep it simple, stupid) и YAGNI (You ain't gonna need it), то есть делать только то, что действительно нужно в данный момент и делать это максимально просто.

Если подытожить, то test-first подход смещает фокус от верификации корректного функционирования кода в сторону осмысления требований и поведения, построения дизайна системы.

Test-driven development

TDD базируется на TFD практике. Нельзя сказать, что Вы используете TDD, если не практикуете TFD, то есть пишете тесте после того как модул и спроектированы и реализованы. В таком случае - это просто написание модульных тестов, ставящее перед собою целью тестирование функциональности.

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

Changes in development process

Основное отличие TDD от TFD состоит в том, что TDD привносит изменения в процесс разработки. Грубо говоря:

TDD = TFD + Changes in development process.

Хотя используя TFD в полном объеме, процесс разработки меняется так или иначе. Так что надо заметить, что разделение TDD и TFD здесь несколько искусственно.

Проанализируем этапы, приведенные на диаграмме:

  1. Собрать и проработать набор требований.
  2. Написать тест, отражающий одно из требований.
    Ни единой строчки кода реализации еще не написано. По мере написании тестов начинает вырисовываться API модуля. К концу написания тестов API модуля готов настолько, насколько нужно, чтобы код скомпилировался.
  3. Убедиться что тесты падают.
  4. Написать код модуля.
  5. Запустить тесты, убедиться что они выполняются, иначе вернуться к предыдущему пункту.
  6. Провести рефакторинг кода.

Место рефакторинга в TDD

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

Место рефакторинга в этой схеме не случайно. Что понять почему все именно так, нужно вспомнить что такое рефакторинг. Правильно, это такое изменение внутренней реализации, которое не меняет поведение. Главное здесь - это фраза "не меняет поведения". А что представляет из себя тест в TDD? Декларацию поведения. Из этого следует, что рефакторинг не должен потребовать изменение тестов. Розумеется, рефакторинг API модуля потребует внести соответствующие изменения в реализацию теста, но лишь в реализацию, декларация поведения останется неизменной. Основная функция тестов во время проведения рефакторинга - проверка того, что его проведение не изменило поведения.

TDD essential

Еще одна функция тестов в TDD - это мерило выполненной работы. Тесты, будучи декларацией поведения, представляют business value. Количество успешно выполняющихся тестов относительно общего их количества представляет процент выполненной работы. И когда Вам задают вопрос "Сколько работы сделано?", "Сколько еще осталось сделать?", "Когда будет закончена задача?", то можно больше не мычать, а достаточно взглянуть на test report, оценить количество выполняюших тестов, и дать конкретный ответ.

Очень важно понять, что изначальная функция тестирования, как верификации корректного функционирования модуля, отошла на второй план. Ее место заняла функция декларации требований и поведения, и верификация поведения. Нужен shift in mind, чтобы понять это, и что еще важнее, чтобы осознать и принять это, начать использовать и пожинать плоды.

Вопросы, на которые есть ответы

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

  1. Что тестировать?, Что не тестировать?

    Вспомнив, что цель теста - не тестирование, а декларация поведения, вопрос отпадает сам собой. TDD не занимается тестированием, значит вопросы "Что тестировать?" и "Что не тестировать?" просто неуместны. Если задать вопрос "Какое поведение описывать?", то ответ на этот вопрос понятен: "Поведение определяется требованиями и становится известно после анализа.". Behavior driven development уточняет ответ на этот вопрос еще больше.

  2. Как именовать тесты?

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

    [Test]
    public void When_data_ordered_in_asc_order_should_be_able_to_sort_in_desc_order()
    {
        var sorter = new QuickSortAlgorithm();
        var sortedList = sorter.Sort(new[] { 1, 2, 3, 4, 5, 6, 7 }, false);
    
        VerifyEquality(sortedList, new[] { 7, 6, 5, 4, 3, 2, 1 });
    }
    

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

  3. С чего начинать написание тестов?

    Выделить наиболее важное поведение и выразить его в виде теста. Двигаться далее в порядке важности.

  4. С чего начинать написание production кода?

    К концу написания набора тестов, API модуля готов. Далее берем тест, отражающий наиболее важное поведение, и пишем production code до тех пор, пока тест не начинает выполняться успешно. Далее, переходим к следующему требованию в порядке важности.

Why TEST-driven development?

"А почему TDD называется так как он называется? Что там делает слово test?". Если у Вас возник этот вопрос, значит Вы постигли TDD, и для вас TDD перестало быть еще одним buzzword в software development. Слово "test" в названии только сбивает с толку, и вносит неопределенность и путаницу. Слово "behavior" было бы более уместно.

Behavior-driven development

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

BDD масштабирует принципы TDD на фазы анализа требования, дизайна и реализации.

Что это значит? Попробую объяснить. Простите мой paint.

TDD предполагает, что разработчик занимается спецификацией поведения системы в виде набора тестов, что позволяет сформировать дизайн, после чего можно перейти к этапу реализации логики. Также предполагается, что требования уже известны на нужном уровне детализации (скажем отдельного класса). Однако эта информация не является первичной. Требования к системе уже описаны заказчиком до того, как разработчик приступил к кодированию.

В TDD единственная роль - это разработчик. ТDD сфокусирован на этапах дизайна системы и ее реализации. BDD помимо фазы дизайна и реализации, охватывает также фазу анализа требований и вопросы коммуникации, устанавливает связь между top-level требованиями уровня заказчика, и low-level набора поведений, которыми оперирует тестировщик либо разработчик. Другими словами, BDD расширяет методологию анализа требований и спецификации поведения на весь процесс и команду и унифицирует ёё.

И заказчик, и тестировщик, и разработчик занимаются описанием требований и поведения системы на разных уровнях ёё детализации. Другие участники процесса разработки эти требования потребляют. Чтобы добиться связи между требованиями и поведениями, сформированных разными участиками, на различном уровне, в различные моменты времени, и обеспечить коммуникацию участников процесса, применяется единый язык анализа и формализации требований и поведения. Единый язык поощряет взаимодействие членов команды в процессе сбора и понимания требований.

Унификация языка анализа требований

Требования заказчика выражаются в виде историй. BDD унифицирует механизм описания истории, определяя такие структурные элементы:

  1. Feature/Goal, то есть та функциональная возможность, которая по тем или иным причинам нужна заказчику.
  2. Role, субъект системы, которому необходима эта возможность, тот, кто будет использовать feature.
  3. Benefit/motivation. Выгода, полученная от использовании feature, а также мотивация заказчика, которая объясняет, почему возникла необходимость в feature. Мотивация фокусируются на первичной цели, обеспечивает более глубокое понимание потребностей заказчика в целом, и направляет анализ требований в нужное русло.

Использование этих элементов ничуть не ограничивает нас в описании требований, а лишь слегка их формализует, определяя что-то вроде довольно свободного и гибкого шаблона. Конкретный пример шаблон может быть таковым: "As a [role] I want [feature] so that [benefit]". Главное здесь конечно же не wording или то, как построено предложение.

Сформированная история сохраняется в backlog'e до тех пор, пока не настанет пора ее воплощения в жизнь. C наступлением этого момента проводится сбор и анализ требований, обсуждения как внутри команды, так и с заказчиком, оценивается объем работы. История и набор собраной информации в целом определяют поведение, которое формализуется в виде набора критериев приемки (acceptance criteria). Удовлетворение всех критериев приемки со стороны системы означает корректность поведения системы.

Acceptance criteria - это спецификация поведения. Язык его описания должен быть гибким и свободным, и не ограничиваться применением в одной или нескольких предметных областях. BDD предлагает использование следующих элементов:

  1. Given initial context, определяет исходный контекст, сценарий.
  2. When event occurs, определяет совершаемое действие.
  3. Then ensure outcomes, верифицирует последствия совершенного действия.

Making acceptance criteria executable

Имея в качестве исходной информации набор спецификаций поведения (acceptance criteria), разработчик может начать переносить их в код, то есть превращать их в форму выполняемых спецификаций (executable specification). Розумеется, это будут не юнит-тесты. Спецификации служат в качестве starting point для начала работы. По мере кодирования спецификаций формируется дизайн системы. Для модулей, появившихся в результате этого дизайна, определяются дочернии истории и спецификация поведения этих модулей, которые в свою очередь тоже переносятся в код. Процесс итеративно продолжается до тех пор, пока дальнейшее разбиение модулей станет нецелесообразным. В таком случае, спецификации нижнего уровня будут представлять собой юнит-тесты, а более высоких - интеграционные тесты, если проведение параллели с тестами здесь вообще уместна.

Если обобщить сказанное выше, то BDD подготавливает информацию, которая используется как исходная для применения TDD.

Вот что пишет по этому поводу Scott Bellware в своей статье.

Usually, the acceptance criteria are taken directly from the form written by the customer in the customer's comforable language, and translated directly into code by the developer who uses the customer's specified criteria and any notes that he's made in the conversation with the customer to get into any greater details.

You aren't trying to achieve seamless traceability of requirements with user stories and acceptance criteria. You're capturing just enough specification to get the work started.

Any new criteria that surface in the process of elaborating on a user story in code are captured directly in code, and can be communicated back to the customer later if necessary.

Describing acceptance criteria in natural language

Acceptance criteria пишутся людьми на простом английском языке. Будучи написанными, они переносятся в код при помощи unit testing либо specification framework. При переносе в код, язык описания спецификации не изменяется, используются те же языковые элементы такие, как given, when, then. Также применяется словарь предметной области, полученный в результате domain driven design.

К примеру, поведениt калькуляции расходов на доставку заказов, описанная при помощи MSpec.

[Subject(typeof(ShippingTablePostageCalculator))]
public class when_table_contains_record_with_matching_mail_class_and_distance 
    : given_shipping_table_postage_calculator
{
    static Money PostagePrice;

    Establish postage_calculator = () => WithShippingTable(
        new PostageRecord {MailClass = "FIRST_CLASS_MAIL", Distance = 1.00, Price = new Money(1.0m, "USD")}, 
        new PostageRecord {MailClass = "FIRST_CLASS_MAIL", Distance = 2.00, Price = new Money(2.0m, "USD")},
        new PostageRecord {MailClass = "PRIORITY_MAIL", Distance = 1.00, Price = new Money(5.0m, "USD")});

    Because of = () => { PostagePrice = Calculator.GetPostagePrice("FIRST_CLASS_MAIL", 1.00); };

    It should_get_postage_price_from_the_that_record = 
        () => PostagePrice.ShouldEqual(new Money(1.0m, "USD"));
}

Что за WTF!!??? Это что такое!!??? Если это было вашей первой мыслью при просмотре этого кодf, то могу сказать, что я подумал бы то же самое на Вашем месте. И не спрашивайте меня: "А что, для каждого теста нужно создавать новый класс?". Не будем сейчас углубляться в детали реализации конктреного framework. Это уже тема для отдельного поста.

С точки зрения написания теста (по уровню детализации в примере это таки юнит-тест) произошли следующие изменения по сравнению с TDD. Произошел переход от понятия теста к понятию спецификации, как элемента описания и верификации поведения. Поведение декларируется не только в имени теста, но и в его теле. Тело теста структурировано таким же образом, как и acceptance criteria, который оно представляет. Слово тест больше нигде не упоминается, ни в названии, ни в элементах framework, нету атрибутов Test, TestFixture, нету конструкций типа Assert.AreEqual(), на их место пришли поняти given/when/then(should).

Обощив, можно сказать, что BDD/specification framework не позволит писать тесты/спецификации и не быть ориентированным на поведение, она не просто поощряет, она принуждает отталкиваться от поведения. Это противоположно тому, как можно было писать тесты при помощи unit-testing framework не до, а после написания кода модулей, и говорить что вы занимаетесь test-driven development'ом. BDD буквально пропитывает понятием "поведения" все, начиная от анализа требований и заканчивая тестированием. Явная фокусировка на поведение - это то в чем TDD испытывает недостаток.

И напоследок...

Если BDD кажется Вам туманной материей, или Вас часто посещали вопросы типа "WTF?" при прочтении последней главы, то это нормально. Что касается меня, то хотя идея BDD мне и понятна, однако остается довольно много вопросов практического применения, на которые у меня пока нет ответов. В любом случае, думаю не стоит быть категоричным и отклонять BDD, только потому что класс с названием "when_table_contains_record_with_matching_mail_class_and_distance" выглядит ну по меньшей мере странно.

Также, стоит задать себе вопрос, на самом ли деле мы практикуем test-driven development, применяя практику юнит тестирование? Или мы занимаемся лишь покрытием кода модульными тестами, используя при этом unit testing и mocking frameworks? Иногда я ловлю себя на этом.

Полезные ссылки

Книги по unit-testing

Test driven development

Behavior driven development

1 comments:

cerber said...

С первого взгляда это действительно кажется диким. Я не представляю себе систему, архитектуру которой диктовали "тесты". ИMXO BDD должен хорошо показать себя при внедрении новой "фичи" в уже готовую систему. Причем требования должен собрать исполнитель. И не стоит гнаться за покрытием тестами в 100%. Нужно помнить о правиле 80/20. Как правило, 80% достаточно покрывают все основные моменты. Остальные 20% - это эффорт.

Post a Comment