Продолжается подписка на наши издания! Вы не забыли подписаться?

В поисках качества кода: Знакомство с Behavior Driven Development (BDD)

Автор: Эндрю Гловер
Stelligent Incorporated
Опубликовано: 24.04.2009

Разработка, основанная на тестировании (test-driven development) – это отличная практическая идея, но некоторые разработчики не могут преодолеть концептуальную пропасть, с которой у них ассоциируется слово тестирование. В этой статье Эндрю Гловер продемонстрирует более естественный способ интеграции основных принципов TDD в практическое программирование. Познакомьтесь с behavior-driven development (разработка, основанная на функционировании) с помощью инфраструктуры JBehave и узнайте, что произойдет, если фокусироваться на том, как программа работает, а не на том, что она производит в конечном итоге.

Тестирование, выполняемое разработчиком – это очевидно полезная вещь. Тестирование на ранней стадии, например, во время написания кода – еще лучше, особенно когда оно приводит к повышению качества кода. Напишите тесты заранее – и вы имеете шанс выиграть «голубую ленту» победителя регаты. Дополнительные возможности для проверки функционирования кода и его предварительной отладки, без всякого сомнения, повышают скорость разработки.

Но даже зная все это, мы еще очень далеки от того времени, когда написание тестов до написания кода станет общим стандартом. Точно так же как TDD стало следующим этапом эволюции развития экстремального программирования (eXP) и выдвинуло на первый план инфраструктуры для unit-тестирования, следующий скачок эволюции будет сделан с того уровня, где находится TDD. В этом месяце я предлагаю сделать подобный скачок в эволюции от TDD к его интуитивному родственнику: behavior-driven development (BDD) – разработке, основанной на функциональности (поведении).

Разработка, основанная на функциональности

Хотя подход с предварительным тестированием работает у многих, он подходит не для всех. На каждого разработчика приложений, с успехом применяющего TDD, найдется несколько разработчиков, активно отрицающих этот подход. Несмотря на многочисленность инфраструктур тестирования, таких как TestNG, Selenium и FEST, все равно находится много причин не выполнять тестирование кода.

Две обычные причины отказа от TDD – «у нас недостаточно времени для тестирования» и «код слишком сложный и трудно проверяемый». Другой преградой для программирования с предварительным написанием тестов является сама концепция «тест пишется до кода». Большинство рассматривает тестирование как осязаемое действие, скорее конкретное, нежели абстрактное. Опыт подсказывает, что невозможно проверить то, что еще не существует. Для некоторых разработчиков, остающихся в рамках этой концепции, идея предварительного тестирования - просто оксюморон.

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

На самом деле большинство из нас уже и так думает подобным образом. Смотрите:

Фрэнк: Что такое стек?

Линда: Это структура данных, хранящая объекты в порядке «первым вошел, последним вышел» или «последним вошел, первым вышел». Обычно у этой структуры есть API с такими методами, как push() и pop(). Иногда присутствует метод peek().

Фрэнк: Что делает метод push()?

Линда: Метод push() принимает входной объект, например, foo и помещает его во внутренний контейнер, например, массив. Метод push() обычно ничего не возвращает.

Фрэнк: Что будет, если передать методу push() два объекта, например, сначала foo, а потом bar?

Линда: Второй объект bar должен оказаться наверху концептуального стека, содержащего по крайней мере два объекта, так что при вызове метода pop() объект bar должен быть извлечен первым, до первого объекта foo. Если метод pop() вызвать еще раз, должен быть возвращен объект foo и стек должен стать пустым (предполагается, что в нем ничего не было до того, как мы добавили эти два объекта).

Фрэнк: Так метод pop() удаляет самый последний элемент, добавленный в стек?

Линда: Да, метод pop() должен удалить верхний элемент, при этом предполагается, что в стеке есть элементы, чтобы их удалять. Метод peek() работает точно также, но при этом объект не удаляется. Метод peek() должен оставить верхний элемент в стеке.

Фрэнк: Что будет, если вызвать метод pop(), когда в стек еще ничего не было добавлено?

Линда: Метод pop() должен выдать исключение, показывающее, что в стек еще ничего не добавлялось.

Фрэнк: Что будет, если выполнить команду push() null?

Линда: Стек должен выдать исключение, так как null не является допустимым значением для метода push().

Можно ли выделить что-нибудь особенное в этом диалоге, кроме того, что Фрэнк не силен в структурах данных? Нигде не использовалось слово «тестирование». Однако слово «должен» проскальзывало регулярно и звучало довольно естественно.

Действовать естественно

Какие инфраструктуры можно использовать?

Аннотации позволяют реализовать BDD в JUnit и TestNG. Но, на мой взгляд, гораздо интереснее использовать специальные BDD-инфраструктуры, такие как JBehave. Эта инфраструктура предоставляет возможности для определения классов описания поведения, таких как expectation framework (инфраструктура для описания ожидаемого поведения), которая способствует более грамотному стилю программирования.

В подходе BDD нет ничего нового или революционного. Это просто эволюционное ответвление подхода TDD, где слово «тест» заменено словом «должен». Если отложить в сторону слова, то многие найдут понятие «должен» более естественным для процесса разработки, чем понятие «тест». Мышление в терминах функциональности (того, что код должен делать), приводит к подходу, когда сначала пишутся классы для проверки спецификации, которые, в свою очередь, могут оказаться очень эффективным инструментом реализации.

Используя разговор между Фрэнком и Линдой в качестве основы, давайте посмотрим, как подход BDD поддерживает разработку в том стиле, который намеревались популяризовать с помощью TDD.

JBehave

JBehave – это BDD-инфраструктура для платформы Java, основанная на принципах xUnit. Как естественно предположить, в JBehave делается упор на слово должен, а не на тест. Как и в случае JUnit, классы JBehave можно запускать в вашей стандартной среде разработки или на предпочитаемой вами платформе для сборки проекта, такой как Ant.

JBehave позволяет создавать классы для проверки функциональности, почти так же как и в JUnit; однако в случае с JBehave нет необходимости производить наследование от какого-либо базового класса, и все методы для проверки должны начинаться с should, а не с test, как показано в листинге 1.

Листинг 1. Простой класс для проверки функциональности стека

public class StackBehavior 
{
  public void shouldThrowExceptionUponNullPush() throws Exception{}
  public void shouldThrowExceptionUponPopWithoutPush() throws Exception{}
  public void shouldPopPushedValue() throws Exception{}
  public void shouldPopSecondPushedValueFirst() throws Exception{}
  public void shouldLeaveValueOnStackAfterPeek() throws Exception{}
}

Все методы, определенные в листинге 1, начинаются с should, и их названия – это вполне читабельные и понятные предложения. Получившийся класс StackBehavior описывает многие функциональные возможности стека из разговора между Фрэнком и Линдой.

Например, Линда упомянула, что стек должен выдавать исключение, если пользователь попробует поместить в него null. Посмотрите на первый метод класса StackBehavior: он так и называется shouldThrowExceptionUponNullPush(). Другие методы называются по этому же шаблону. Такая описательная манера наименования методов, естественно, не являющаяся привилегией JBehave или BDD, позволяет определить неудачно работающую функциональность так, чтобы ее было легко прочесть и понять, как будет показано позже.

Возвращаясь к методу shouldThrowExceptionUponNullPush(), как можно проверить эту функциональность? Кажется разумным сначала добавить метод push() в класс Stack, что довольно просто.

Листинг 2. Упрощенное определение стека для обеспечения проверки требуемой функциональности

public class Stack<E>
{
  public void push(E value) { }
}

Можно заметить, что здесь приведено минимальное количество кода для стека, позволяющее начать проверку требуемой функциональности. Как упоминала Линда, эта функциональность крайне проста: если кто-то вызовет метод push() со значением null, стек должен выдать исключение. Как я реализовал это поведение, можно увидеть в листинге 3.

Листинг 3. Стек должен выдать исключение, если в него добавляется null

public void shouldThrowExceptionUponNullPush() throws Exception
{
  final Stack<String> stStack = new Stack<String>();

  Ensure.throwsException(RuntimeException.class, new Block()
  {
    public void run() throws Exception 
    {
      stStack.push(null);
    }
  });
}

Большие надежды и переопределения(Great expectations and overrides)

В листинге 3 есть несколько уникальных элементов JBehave, которые необходимо пояснить. Сначала мы создаем экземпляр класса Stack, ограниченный типом String с помощью Java 5 generics. Затем с помощью expectation framework JBehave естественным способом моделируется желаемое поведение. Класс Ensure аналогичен классу Assert в JUnit или TestNG; однако в нем добавлен набор методов, которые помогают сделать API более читаемым (часто это называется грамотным программированием). В листинге 3 я определяю, что вызов метода push() с параметром null должен выдать исключение RuntimeException.

В JBehave также введен тип Block, который реализуется за счет переопределения метода run() методом, содержащим поведение, которое нужно проверить. Внутри JBehave проверяет, что желаемое исключение не было выдано, а, следовательно, и не обработано, и генерирует отчет о неудачном запуске. (Java не поддерживает замыканий, которые были бы очень удобны в данном случае, и тип Block эмулирует их – прим.ред.) <...>

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

Листинг 4. Желаемое поведение не подтвердилось

1) StackBehavior should throw exception upon null push:
VerificationException: Expected: 
object not null
but got: 
null:

Предложение «StackBehavior should throw exception upon null push» в листинге 4 соответствует названию метода для проверки поведения - shouldThrowExceptionUponNullPush() и названию класса. Естественно, JBehave отчитывается, что при запуске данного кода для проверки функциональности ничего не произошло. Потому следующим шагом я сделаю так, чтобы эта функциональность срабатывала - для этого добавим проверку на null в листинге 5.

Листинг 5. Добавление нужной функциональности в класс Stack

public void push(E value) 
{
  if (value == null)
    throw new RuntimeException("Can't push null");
}

При повторном запуске все сработает нормально, как показано в листинге 6.

Листинг 6. Успешное выполнение

Time: 0.021s

Total: 1. Success!

Поведение управляет разработкой

Не правда ли, результат работы в листинге 6 похож на результат работы JUnit? Вероятно, это не случайно. Как упоминалось, JBehave построен на основе парадигмы xUnit и даже поддерживает фикстуры с помощью методов setUp() и tearDown(). Поскольку я, возможно, буду использовать экземпляр класса Stack в своем классе для проверки поведения, я мог бы поместить эту логику в фикстуру, как показано в листинге 7. Отметим, что JBehave следует тем же правилам при работе с фикстурами, что и JUnit, запуская методы setUp() и tearDown() для каждого метода проверки функциональности.

Листинг 7. Фикстуры в JBehave

public class StackBehavior 
{
  private Stack<String> stStack;
  
  public void setUp() 
  {
    this.stStack = new Stack<String>();
  }
  //...
}

Перейдем к следующему методу для проверки поведения shouldThrowExceptionUponPopWithoutPush(). Он проверяет функциональность, аналогичную той, что проверял метод shouldThrowExceptionUponNullPush() из листинга 3. Как видно из листинга 8, в этом нет ничего сложного, не так ли?

Листинг 8. Проверка функциональности pop

public void shouldThrowExceptionUponPopWithoutPush() throws Exception
{

  Ensure.throwsException(RuntimeException.class, new Block() 
  {
    public void run() throws Exception 
    {
      stStack.pop();
    }
  });
}

Как можно догадаться, листинг 8 на данный момент не может быть скомпилирован, так как метод pop() еще не написан. Однако, прежде чем начать писать метод pop(), необходимо рассмотреть несколько моментов.

Реализация функциональности

Технически я мог бы сейчас реализовать метод pop() так, чтобы он просто выдавал исключение всегда, вне зависимости от порядка вызовов. Но для дальнейшей реализации требуемой функциональности более эффективен подход, соответствующий желаемой спецификации. В этом случае для того, чтобы метод pop() выдавал исключение, если до этого не был вызван метод push() (или, логически, если стек пуст), необходимо реализовать его состояние. Как упоминала раньше Линда, у стека есть «внутренний контейнер», который физически содержит элементы стека. Поэтому можно создать в классе Stack объект ArrayList для хранения значений, переданных через метод push(), как показано в листинге 9.

Листинг 9. Стеку необходим какой-нибудь внутренний способ хранения объектов

public class Stack<E> 
{
  private ArrayList<E> list; 

  public Stack() 
  {
    this.list = new ArrayList<E>();
  }
 //...
}

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

Листинг 10. Реализовать метод pop() стало проще

public E pop() 
{
  if(this.list.size() > 0)
    return null;
  else
    throw new RuntimeException("nothing to pop");
}

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

Следующий метод shouldPopPushedValue() для проверки функциональности также определяется достаточно просто. Необходимо просто вызвать метод push() с параметром («test») и проверить, что при вызове pop() возвращается это же значение.

Листинг 11. Если значение удалось добавить, то его должно быть можно и извлечь

public void shouldPopPushedValue() throws Exception
{
  stStack.push("test");
  Ensure.that(stStack.pop(), m.is("test"));
}

Поиск совпадений

В листинге 11 проверяется, что метод pop() возвращает значение «test». При использовании класса Ensure из JBehave часто оказывается, что необходим более функциональный способ для описания ожидаемого поведения. Для решения этой проблемы JBehave предлагает класс Matcher. В данном случае мы воспользуемся классом UsingMatchers (в листинге 11 ссылка на экземпляр этого класса находится в переменной m), чтобы можно было использовать такие методы, как is(), and(), or() и другие для создания более понятного стиля определения ожидаемого поведения.

Переменная m в листинге 11 – это статическое поле класса StackBehavior. как показано в листинге 12.

Листинг 12. Использование класса UsingMatchers в классе для проверки функциональности

private static final UsingMatchers m = new UsingMatchers(){};

Класс UsingMatchers

Можно заметить, что код в листинге 12 не очень элегантен. Переменная m в листинге 11 отрицательно влияет на читаемость кода («ensure that pop's value m (что за m?) is test»). Можно избежать использования типа UsingMatchers, расширив специальный базовый класс UsingMiniMock, предоставляемый JBehave. В этом случае последняя строка в листинге 11 будет выглядеть более читабельно: Ensure.that(stStack.pop(), is(«test»)).

Теперь, когда в листинге 11 прописан новый метод для проверки поведения, можно запустить его, но при этом происходит ошибка, как показано в листинге 13.

Листинг 13. Новый метод проверки поведения не работает

Failures: 1.

1) StackBehavior should pop pushed value:
java.lang.RuntimeException: nothing to pop

В чем же проблема? Дело в том, что метод push() реализован не до конца. Если помните, в листинге 5 я ограничился минимальной реализацией, чтобы запустить код для проверки функциональности. Теперь пришло время закончить работу и действительно добавлять принимаемые значения во внутренний контейнер (если эти значения не null). Реализация этого метода приведена в листинге 14.

Листинг 14. Реализация метода push

public void push(E value)
{
  if(value == null)
    throw new RuntimeException("Can't push null");
  else
    this.list.add(value);
}

Однако если запустить код для проверки этого поведения, все равно происходит ошибка!

Листинг 15. JBehave сообщает о значении null вместо того, чтобы выдавать исключение

1) StackBehavior should pop pushed value:
VerificationException: Expected: 
same instance as <test>
but got: 
null:

По крайней мере, ошибка из листинга 15 отличается от ошибки из листинга 13. В данном случае произошло не исключение, а не было обнаружено ожидаемое значение «test»: вместо этого из стека был возвращен null. Если посмотреть на листинг 10, станет понятна причина: метод pop() изначально был закодирован так, чтобы возвращать null, если во внутреннем контейнере есть элементы. Эту проблему достаточно просто исправить.

Листинг 16. Окончательная реализация метода pop

public E pop() 
{
  if(this.list.size() > 0)
    return this.list.remove(this.list.size());
  else
    throw new RuntimeException("nothing to pop");
}

Но если запустить код для проверки поведения, то возникает новая ошибка.

Листинг 17. Опять ошибка

1) StackBehavior should pop pushed value:
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1

После изучения листинга 17 проблема становится понятной: в ArrayList индексы начинаются с нуля.

Листинг 18. Отсчет индексов с нуля исправляет эту проблему

public E pop() 
{
  if(this.list.size() > 0)
    return this.list.remove(this.list.size() - 1);
  else
    throw new RuntimeException("Nothing to pop");
}

Функциональность стека

Сейчас методы push() и pop() реализованы так, чтобы некоторые методы описания поведения могли успешно выполниться. Однако еще не реализована основная функциональность стека, например, логика, связанная с множественными вызовами методов push() и pop(), перемежающихся вызовами метода peek().

Сначала с помощью метода shouldPopSecondPushedValueFirst() необходимо убедиться, что выполняется базовый алгоритм стека (первым вошел, последним вышел).

Листинг 19. Проверка стандартного поведения стека

public void shouldPopSecondPushedValueFirst() throws Exception
{
  stStack.push("test 1");
  stStack.push("test 2");
  Ensure.that(stStack.pop(), m.is("test 2"));
}

Код в листинге 19 работает, как и запланировано, так что можно реализовать другой метод для проверки поведения из листинга 20, чтобы проверить, что двойной вызов метода pop() работает правильно.

Листинг 20. Дополнительные проверки функциональности стека

public void shouldPopValuesInReverseOrder() throws Exception
{
  stStack.push("test 1");
  stStack.push("test 2");
  Ensure.that(stStack.pop(), m.is("test 2"));
  Ensure.that(stStack.pop(), m.is("test 1"));
}

Далее необходимо убедиться, что метод peek() работает, как запланировано. По словам Линды, метод peek() работает точно так же, как и метод pop(), но «должен оставлять верхний элемент в стеке». В соответствии с этим в листинге 21 подготовлен метод shouldLeaveValueOnStackAfterPeek().

Листинг 21. Проверка того, что метод peek() оставляет верхний элемент в стеке

public void shouldLeaveValueOnStackAfterPeek() throws Exception
{
  stStack.push("test 1");
  stStack.push("test 2");
  Ensure.that(stStack.peek(), m.is("test 2"));
  Ensure.that(stStack.pop(), m.is("test 2"));
}

Так как метод peek() еще не определен, то код в листинге 21 не скомпилируется. В листинге 22 определен «скелет» реализации метода peek().

Листинг 22. Определение метода peek()

public E peek() 
{
 return null;
}

Теперь класс StackBehavior компилируется, но по-прежнему не работает.

Листинг 23. Не работает – возвращается null

1) StackBehavior should leave value on stack after peek:
VerificationException: Expected: 
same instance as <test 2>
but got: 
null:

Логически метод peek() не удаляет элемент из внутренней коллекции, а просто передает ссылку на него. Следовательно, нужно вызвать метод get() у объекта ArrayList, как показано в листинге 24, а не remove().

Листинг 24. Возвращаем, но не удаляем!

public E peek() 
{
  return this.list.get(this.list.size() - 1);
}

Дополнительные проверки поведения стека

Теперь, если перезапустить код из листинга 21, выводится отчет об успешном прохождении теста. Однако в этом упражнении остался один нераскрытый аспект: как должен вести себя метод peek(), если в стеке ничего нет? Метод pop() в этом случае должен выдать исключение, должен ли метод peek() поступить так же?

Линда про это ничего не говорила, так что придется реализовать проверку подобного поведения самостоятельно. В листинге 25 приведен код для сценария «что произойдет, если вызвать метод peek() без предварительного вызова метода push()?»

Листинг 25. Что произойдет, если вызвать peek() без предварительного вызова push()?

public void shouldReturnNullOnPeekWithoutPush() throws Exception
{
  Ensure.that(stStack.peek(), m.is(null));
}

Снова ничего удивительного. Запуск кода зафиксировал ошибку, как показано в листинге 26.

Листинг 26. Методу peek() нечего возвращать

1) StackBehavior should return null on peek without push:
java.lang.ArrayIndexOutOfBoundsException: -1

Чтобы устранить этот дефект, подойдет логика, сходная с логикой в методе pop(), как показано в листинге 27.

Листинг 27. Необходимые исправления для метода peek()

public E peek() 
{
  if(this.list.size() > 0)
    return this.list.get(this.list.size() - 1);
  else
    return null;
}

После всех модификаций и исправлений код класса Stack выглядит, как показано в листинге 28.

Как замечательно демонстрирует автор, самые продвинутые методы тестирования не могут заменить голову. Если этот класс будет широко использоваться, за подобную реализацию метода peek() автора четвертуют без выходного пособия. Ведь сплошь и рядом люди будут получать null, и ничего не подозревая, вызывать метод, из-за чего то и дело будет генерироваться исключение nullPointerException. Причем, так как результат peek() можно получить в некоторое поле или переменную, место появления исключения будет совершенно непредсказуемым, и никак не укажет на место реальной ошибки. Не делайте так, пожалуйста. Если вам нужен метод позволяющий проверить наличие элементов в стеке, то лучше создать метод вроде IsEmpty() напрямую отвечающий на интересующий вопрос и не генерирующий исключения – прим.ред.

Листинг 28. Работающий стек

import java.util.ArrayList;

public class Stack<E> 
{
  private ArrayList<E> list;

  public Stack() 
  {
    this.list = new ArrayList<E>();
  }

  public void push(E value) 
  {
    if(value == null)
      throw new RuntimeException("Can't push null");
    else
      this.list.add(value);
  }

  public E pop() 
  {
    if(this.list.size() > 0)
      return this.list.remove(this.list.size()-1);
    else
      throw new RuntimeException("Nothing to pop");
  }

  public E peek() 
  {
    if(this.list.size() > 0)
      return this.list.get(this.list.size()-1);
    else
     return null;
  }
}

К этому моменту класс StackBehavior обладает семью методами для проверки поведения, гарантирующими, что класс Stack работает согласно спецификации Линды и моим дополнениям. Возможно, классу Stack требуется некоторый рефакторинг, например, метод pop() должен для проверки вызывать метод peek(), а не метод size(), но благодаря процессу, ориентированному на спецификацию, у меня имеется инфраструктура, чтобы вносить изменения с определенной долей уверенности. Если что-нибудь будет нарушено, об этом тут же станет известно.

Заключение

Что можно заметить из этой статьи, посвященной исследованию BDD, - это то, что Линда – это на самом деле клиент. Соответственно можно говорить, что Фрэнк – это разработчик. Если отвлечься от конкретной предметной области (структуры данных) и заменить эту область другой, например, приложением для call-центра, суть процесса останется той же. Линда – клиент или эксперт в предметной области – говорит, что именно система, функция или приложение должны делать, а кто-нибудь вроде Фрэнка использует BDD для проверки, что он правильно услышал и реализовал требования клиента.

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

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

Ресурсы


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

Copyright © 1994-2016 ООО "К-Пресс"