Статьи

Рабочий процесс BDD с Behat и Phpspec

В этом руководстве мы рассмотрим два разных инструмента BDD, Behat и phpspec , и посмотрим, как они могут поддержать вас в процессе разработки. Изучение BDD может быть запутанным. Новая методология, новые инструменты и множество вопросов, таких как «что тестировать?» и «какие инструменты использовать?» Я надеюсь, что этот довольно простой пример даст вам идеи о том, как вы можете включить BDD в свой рабочий процесс.

Я вдохновился на написание этого урока Тейлором Отвеллом, создателем фреймворка Laravel . Несколько раз я слышал, как Тейлор объясняет, почему он в основном не работает с TDD / BDD, говоря, что ему нравится сначала планировать API своего кода, прежде чем фактически приступить к его реализации. Я слышал об этом от многих разработчиков, и каждый раз я размышлял про себя: «Но это идеальный вариант использования TDD / BDD!». Тейлор говорит, что ему нравится отображать API своего кода, написав код, который он хотел бы иметь. Затем он начнет кодирование и не будет удовлетворен, пока не достигнет именно этого API. Аргумент имеет смысл, если вы только тестируете / тестируете на уровне юнитов, но, используя такой инструмент, как Behat, вы начинаете с внешнего поведения вашего программного обеспечения, которое, насколько я понимаю, в основном то, чего хочет Тейлор.

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

Отказ от ответственности: Прежде всего, эта статья не является руководством по началу работы. Предполагает базовые знания BDD, Behat и phpspec. Вероятно, вы уже изучили эти инструменты, но все еще не можете понять, как их использовать в повседневной работе. Если вы хотите освежить в phpspec, взгляните на мое руководство по началу работы. Во-вторых, я использую Тейлора Отвелла в качестве примера. Я ничего не знаю о том, как работает Тейлор, кроме того, что я слышал, как он говорил в подкастах и ​​т. Д. Я использую его в качестве примера, потому что он отличный разработчик (он сделал Laravel!) И потому что он хорошо известен. С таким же успехом я мог бы использовать кого-то другого, поскольку большинство разработчиков, включая меня, пока не делают BDD все время. Кроме того, я не говорю, что рабочий процесс, описанный Тейлором, плох. Я думаю, что это прекрасная идея — подумать о вашем коде, прежде чем писать его. Этот урок предназначен только для того, чтобы показать BDD способ сделать это.

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

Во-первых, возможно, Тейлор примет решение о файлах конфигурации. Как они должны работать? Как и в Laravel, давайте просто использовать простые массивы PHP. Пример файла конфигурации может выглядеть так:

1
2
3
4
5
6
7
8
9
# config.php
 
<?php
 
return array(
 
    ‘timezone’ => ‘UTC’,
 
);

Далее, как должен работать код, который использует этот файл конфигурации? Давайте сделаем это способом Тейлора и просто напишем код, который нам нужен:

1
2
3
4
5
6
7
8
$config = Config::load(‘config.php’);
 
$config->get(‘timezone’);
 
$config->get(‘timezone’, ‘CET’);
 
$config->set(‘timezone’, ‘GMT’);
$config->get(‘timezone’);

Итак, это выглядит довольно хорошо. Сначала у нас есть статический вызов функции load() , а затем три варианта использования:

  1. Получение «часового пояса» из файла конфигурации.
  2. Получение значения по умолчанию, если «часовой пояс» еще не настроен.
  3. Изменение параметра конфигурации путем установки его на что-то другое. Мы опишем каждый из этих вариантов использования или сценариев с Behat в ближайшее время.

Имеет смысл использовать Behat для подобных вещей. Behat не заставит нас принимать какие-либо дизайнерские решения — для этого у нас есть phpspec. Мы просто перенесем требования, которые мы только что описали, в функцию Behat, чтобы убедиться, что мы правильно поняли, когда начнем строить. Наша особенность behat послужит, так сказать, приемочным тестом для наших требований.

Если вы посмотрите на код, который мы написали, то другой причиной использования Behat вместо только phpspec является статический вызов. Нелегко тестировать статические методы, особенно если вы используете только такой инструмент, как phpspec. Мы увидим, как мы можем это сделать, когда у нас есть и Behat, и phpspec.

Предполагая, что вы используете Composer, настройка Behat и phspec — это очень простой двухэтапный процесс.

Во-первых, вам нужен базовый файл composer.json . Этот включает Behat и phpspec и использует psr-4 для автозагрузки классов:

01
02
03
04
05
06
07
08
09
10
11
{
    «require-dev»: {
        «behat/behat»: «~3.0»,
        «phpspec/phpspec»: «~2.0»
    },
    «autoload»: {
        «psr-4»: {
            «»: «src/»
        }
    }
}

Запустите composer install чтобы получить зависимости.

Для запуска phpspec не требуется никакой конфигурации, тогда как Behat требует от вас выполнения следующей команды для генерации базового скаффолда:

1
$ vendor/bin/behat —init

Это все, что нужно. Это не может быть твоим оправданием для того, чтобы не делать BDD!

Итак, теперь, когда мы все настроены, мы готовы начать делать BDD. Потому что выполнение BDD означает установку и использование Behat и phpspec, верно?

Насколько я понимаю, мы уже начали делать BDD. Мы эффективно описали внешнее поведение нашего программного обеспечения. Нашими «клиентами» в этом примере являются разработчики, которые собираются взаимодействовать с нашим кодом. Под «эффективно» я подразумеваю, что мы описали поведение так, как они его поймут. Мы могли бы взять код, который мы уже обрисовали в общих чертах, поместить его в файл README, и каждый порядочный разработчик PHP поймет его использование. Так что на самом деле это очень хорошо, но у меня есть две важные вещи, на которые стоит обратить внимание. Прежде всего, описание поведения программного обеспечения с использованием кода работает только в этом примере, потому что «клиенты» являются программистами. Обычно мы тестируем что-то, что будет использоваться «нормальными» людьми. Человеческий язык лучше, чем PHP, когда мы хотим общаться с людьми. Во-вторых, почему бы не автоматизировать это? Я не собираюсь спорить, почему это может быть хорошей идеей.

При этом, я думаю, что начать использовать Behat сейчас было бы разумным решением.

Используя Behat, мы хотим описать каждый из описанных выше сценариев. Мы не хотим широко освещать каждый крайний случай, связанный с использованием программного обеспечения. У нас есть phpspec, если это необходимо для исправления ошибок и т. Д. Я думаю, что многие разработчики, в том числе и Тейлор, считают, что им нужно все продумать и все решить, прежде чем они смогут написать тесты и спецификации. Вот почему они решили начать без BDD, потому что они не хотят решать все заранее. Это не относится к Behat, так как мы описываем внешнее поведение и использование. Чтобы использовать Behat для описания функции, нам не нужно решать что-то большее, чем в примере выше, с использованием необработанного текстового файла. Нам просто нужно определить требования функции — в этом случае внешний API класса загрузчика файла конфигурации.

Теперь давайте возьмем приведенный выше код PHP и превратим его в функцию Behat, используя английский язык (фактически используя язык Gherkin).

Создайте файл в каталоге features/ именем config.feature и заполните следующие сценарии:

В этой функции мы описываем, как «разработчик» может хранить параметры конфигурации. Мы не заботимся о внутреннем поведении — мы будем, когда мы начнем использовать phpspec. Пока эта функция работает зеленым, нам все равно, что происходит за кулисами.

Давайте запустим Behat и посмотрим, что он думает о нашей функции:

1
$ vendor/bin/behat —dry-run —append-snippets

С помощью этой команды мы говорим Behat добавить необходимые определения шагов в наш контекст функции. Я не буду вдаваться в подробности о Behat, но это добавляет кучу пустых методов в наш класс FeatureContext который сопоставляется с нашими шагами функций выше.

В качестве примера рассмотрим определение шага, добавленное Behat для шага there is a configuration file который мы используем как шаг «Данные» во всех трех сценариях:

1
2
3
4
5
6
7
/**
 * @Given there is a configuration file
 */
public function thereIsAConfigurationFile()
{
    throw new PendingException();
}

Теперь все, что нам нужно сделать, это заполнить некоторый код, чтобы описать это.

Прежде чем мы начнем, у меня есть два важных замечания по поводу определений шагов:

  1. Суть в том, чтобы доказать, что поведение сейчас не такое, как мы хотим. После этого мы можем начать разрабатывать наш код, чаще всего используя phpspec, чтобы перейти к зеленому цвету.
  2. Реализация определений шагов не важна — нам просто нужно что-то, что работает и достигает «1». Мы можем рефакторинг позже.

Если вы запустите vendor/bin/behat , вы увидите, что все сценарии теперь имеют ожидающие действия.

Начнем с первого шага. Given there is a configuration file . Мы будем использовать фиксатор файла конфигурации, чтобы позже мы могли использовать метод static load() . Мы заботимся о методе static load() потому что он дает нам хороший API Config::load() , очень похожий на фасады Laravel. Этот шаг может быть реализован многими способами. На данный момент, я думаю, мы должны просто убедиться, что у нас есть доступное приспособление и что оно содержит массив:

01
02
03
04
05
06
07
08
09
10
11
12
13
/**
 * @Given there is a configuration file
 */
public function thereIsAConfigurationFile()
{
    if ( ! file_exists(‘fixtures/config.php’))
        throw new Exception(«File ‘fixtures/config.php’ not found»);
 
    $config = include ‘fixtures/config.php’;
 
    if ( ! is_array($config))
        throw new Exception(«File ‘fixtures/config.php’ should contain an array»);
}

На этом шаге мы доберемся до зеленого, не реализовав никакого кода, кроме как сделать исправление. Цель Given шага — привести систему в известное состояние. В этом случае это означает, что у нас есть файл конфигурации.

Чтобы перейти к нашему первому зеленому шагу, нам просто нужно создать прибор:

1
2
3
4
# fixtures/config.php
<?php
 
return array();

Далее у нас есть шаг And , который в данном случае является просто псевдонимом для Given . Мы хотим убедиться, что файл конфигурации содержит опцию для часового пояса. Опять же, это связано только с нашим приспособлением, поэтому нас это не сильно волнует. Я выполнил следующий (хакерский) код вместе, чтобы выполнить это:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
/**
 * @Given the option :option is configured to :value
 */
public function theOptionIsConfiguredTo($option, $value)
{
    $config = include ‘fixtures/config.php’;
 
    if ( ! is_array($config)) $config = [];
 
    $config[$option] = $value;
 
    $content = «<?php\n\nreturn » .
 
    file_put_contents(‘fixtures/config.php’, $content);
}

Приведенный выше код не очень хорош, но он выполняет то, что ему нужно. Это позволяет нам манипулировать нашим прибором изнутри нашей функции. Если вы запустите Behat, вы увидите, что он добавил опцию «timezone» к устройству config.php :

1
2
3
4
5
<?php
 
return array (
  ‘timezone’ => ‘UTC’,
);

Сейчас настало время ввести некоторые из оригинального «кода Тейлора»! Шаг, When I load the configuration file будет состоять из кода, который нам действительно нужен. Мы введем часть кода из необработанного текстового файла из предыдущего и убедимся, что он работает:

1
2
3
4
5
6
7
/**
 * @When I load the configuration file
 */
public function iLoadTheConfigurationFile()
{
    $this->config = Config::load(‘fixtures/config.php’);
}

Запуск Behat, конечно, не удастся, так как Config еще не существует. Позвольте нам принести phpspec на помощь!

Когда мы запустим Behat, мы получим фатальную ошибку:

1
PHP Fatal error: Class ‘Config’ not found…

К счастью, у нас есть phpspec, включая его потрясающие генераторы кода:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
$ vendor/bin/phpspec desc «Config»
Specification for Config created in …/spec/ConfigSpec.php.
 
$ vendor/bin/phpspec run —format=pretty
Do you want me to create `Config` for you?
 
$ vendor/bin/phpspec run —format=pretty
 
      Config
 
  10 ✔ is initializable
 
 
1 specs
1 examples (1 passed)
7ms

С помощью этих команд phpspec создал для нас следующие два файла:

1
2
3
4
spec/
`— ConfigSpec.php
src/
`— Config.php

Это избавило нас от первой фатальной ошибки, но Behat все еще не работает:

1
PHP Fatal error: Call to undefined method Config::load() in …

load() будет статическим методом, поэтому его сложно определить с помощью phpspec. По двум причинам это нормально, хотя:

  1. Поведение метода load() будет очень простым. Если позже нам понадобится больше сложности, мы можем извлечь логику для небольших тестируемых методов.
  2. Поведение, как пока, достаточно хорошо охвачено Behat. Если метод не загружает файл в массив должным образом, Behat будет работать на нас.

Это одна из тех ситуаций, когда многие разработчики попадают в стену. Они выбросят phpspec и решат, что он отстой и работает против них. Но посмотрите, как хорошо Behat и phpspec дополняют друг друга здесь?

Вместо того, чтобы пытаться получить 100% охват phpspec, давайте просто реализуем простую функцию load() и будем уверены, что она покрыта Behat:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
<?php
 
class Config
{
    protected $settings;
 
    public function __construct()
    {
        $this->settings = array();
    }
 
    public static function load($path)
    {
        $config = new static();
 
        if (file_exists($path))
            $config->settings = include $path;
 
        return $config;
    }
}

Мы уверены, что наши параметры конфигурации теперь загружены. Если нет, остальные наши шаги потерпят неудачу, и мы можем рассмотреть это снова.

Вернемся к зеленому с Behat и phpspec, теперь мы можем посмотреть на наш следующий функциональный шаг. Then I should get 'UTC' as 'timezone' option .

01
02
03
04
05
06
07
08
09
10
/**
 * @Then I should get :value as :option option
 */
public function iShouldGetAsOption($value, $option)
{
    $actual = $this->config->get($option);
 
    if ( ! strcmp($value, $actual) == 0)
        throw new Exception(«Expected {$actual} to be ‘{$option}’.»);
}

На этом этапе мы пишем больше того кода, который нам хотелось бы иметь. Запустив Behat, мы увидим, что у нас нет метода get() :

1
PHP Fatal error: Call to undefined method Config::get() in …

Настало время вернуться к phpspec и разобраться с этим.

Тестирование аксессоров и мутаторов, добытчиков и сеттеров АКА, почти похоже на эту старую дилемму с курицей или яйцом. Как мы можем протестировать метод get() если у нас еще нет метода set() и наоборот. Как я обычно это делаю, так это проверяю их обоих одновременно. Это означает, что мы на самом деле собираемся реализовать функциональность для настройки параметров конфигурации, хотя мы еще не достигли этого сценария.

Следующий пример должен сделать:

1
2
3
4
5
6
7
8
function it_gets_and_sets_a_configuration_option()
{
    $this->get(‘foo’)->shouldReturn(null);
 
    $this->set(‘foo’, ‘bar’);
 
    $this->get(‘foo’)->shouldReturn(‘bar’);
}

Во-первых, генераторы phpspec помогут нам начать:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
$ vendor/bin/phpspec run —format=pretty
Do you want me to create `Config::get()` for you?
 
$ vendor/bin/phpspec run —format=pretty
Do you want me to create `Config::set()` for you?
 
$ vendor/bin/phpspec run —format=pretty
 
      Config
 
  10 ✔ is initializable
  15 ✘ gets and sets a configuration option
        expected «bar», but got null.
 
        …
 
1 specs
2 examples (1 passed, 1 failed)
9ms

Теперь вернемся к зеленому:

01
02
03
04
05
06
07
08
09
10
11
12
public function get($option)
{
    if ( ! isset($this->settings[$option]))
        return null;
 
    return $this->settings[$option];
}
 
public function set($option, $value)
{
    $this->settings[$option] = $value;
}

И там мы идем:

01
02
03
04
05
06
07
08
09
10
11
$ vendor/bin/phpspec run —format=pretty
 
      Config
 
  10 ✔ is initializable
  15 ✔ gets and sets a configuration option
 
 
1 specs
2 examples (2 passed)
9ms

Это проделало нам долгий путь. Запустив Behat, мы видим, что сейчас мы находимся во втором сценарии. Далее нам нужно реализовать опцию по умолчанию, так как get() просто возвращает null прямо сейчас.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
/**
 * @Given the option :option is not yet configured
 */
public function theOptionIsNotYetConfigured($option)
{
    $config = include ‘fixtures/config.php’;
 
    if ( ! is_array($config)) $config = [];
 
    unset($config[$option]);
 
    $content = «<?php\n\nreturn » .
 
    file_put_contents(‘fixtures/config.php’, $content);
}

Это не красиво. Я знаю! Мы могли бы, конечно, рефакторинг, так как мы повторяем себя, но это не является целью этого урока.

Второй функциональный шаг также выглядит знакомым и состоит в основном из копирования и вставки из более ранних версий:

01
02
03
04
05
06
07
08
09
10
/**
 * @Then I should get default value :default as :option option
 */
public function iShouldGetDefaultValueAsOption($default, $option)
{
    $actual = $this->config->get($option, $default);
 
    if ( ! strcmp($default, $actual) == 0)
        throw new Exception(«Expected {$actual} to be ‘{$default}’.»);
}

get() возвращает null . Давайте перейдем к phpspec и напишем пример, чтобы решить это:

1
2
3
4
5
6
7
8
function it_gets_a_default_value_when_option_is_not_set()
{
    $this->get(‘foo’, ‘bar’)->shouldReturn(‘bar’);
 
    $this->set(‘foo’, ‘baz’);
 
    $this->get(‘foo’, ‘bar’)->shouldReturn(‘baz’);
}

Сначала мы проверяем, что получаем значение по умолчанию, если «опция» еще не настроена. Во-вторых, мы гарантируем, что опция по умолчанию не перезаписывает настроенную опцию.

На первый взгляд, в этом случае phpspec может показаться излишним, так как мы уже почти тестируем то же самое с Behat. Мне нравится использовать phpspec для спецификации крайних случаев, которые как бы подразумеваются в сценарии. А также, генераторы кода phpspec действительно хороши. Я использую их для всего, и я работаю быстрее, когда использую phpspec.

Теперь phpspec подтверждает то, что Behat уже сказал нам:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
$ vendor/bin/phpspec run —format=pretty
 
      Config
 
  10 ✔ is initializable
  15 ✔ gets and sets a configuration option
  24 ✘ gets a default value when option is not set
        expected «bar», but got null.
 
        …
 
1 specs
3 examples (2 passed, 1 failed)
9ms

Чтобы вернуться к зеленому цвету, мы добавим «ранний возврат» в метод get() :

01
02
03
04
05
06
07
08
09
10
public function get($option, $defaultValue = null)
{
    if ( ! isset($this->settings[$option]) and ! is_null($defaultValue))
        return $defaultValue;
 
    if ( ! isset($this->settings[$option]))
        return null;
 
    return $this->settings[$option];
}

Мы видим, что phpspec теперь счастлив:

01
02
03
04
05
06
07
08
09
10
11
12
$ vendor/bin/phpspec run —format=pretty
 
      Config
 
  10 ✔ is initializable
  15 ✔ gets and sets a configuration option
  24 ✔ gets a default value when option is not set
 
 
1 specs
3 examples (3 passed)
9ms

И Бехат, и мы тоже.

Мы закончили со вторым сценарием, и у нас осталось еще одно. Для последнего сценария нам нужно только написать определение шага для параметра And I set the 'timezone' configuration option to 'GMT' шага And I set the 'timezone' configuration option to 'GMT' :

1
2
3
4
5
6
7
/**
 * @When I set the :option configuration option to :value
 */
public function iSetTheConfigurationOptionTo($option, $value)
{
    $this->config->set($option, $value);
}

Поскольку мы уже реализовали метод set() , этот шаг уже зеленый:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ vendor/bin/behat
Feature: Configuration files
    In order to configure my application
    As a developer
    I need to be able to store configuration options in a file
 
  Scenario: Getting a configured option # features/config.feature:6
    Given there is a configuration file # FeatureContext::thereIsAConfigurationFile()
    And the option ‘timezone’ is configured to ‘UTC’ # FeatureContext::theOptionIsConfiguredTo()
    When I load the configuration file # FeatureContext::iLoadTheConfigurationFile()
    Then I should get ‘UTC’ as ‘timezone’ option # FeatureContext::iShouldGetAsOption()
 
  Scenario: Getting a non-configured option with a default value # features/config.feature:12
    Given there is a configuration file # FeatureContext::thereIsAConfigurationFile()
    And the option ‘timezone’ is not yet configured # FeatureContext::theOptionIsNotYetConfigured()
    When I load the configuration file # FeatureContext::iLoadTheConfigurationFile()
    Then I should get default value ‘CET’ as ‘timezone’ option # FeatureContext::iShouldGetDefaultValueAsOption()
 
  Scenario: Setting a configuration option # features/config.feature:18
    Given there is a configuration file # FeatureContext::thereIsAConfigurationFile()
    And the option ‘timezone’ is configured to ‘UTC’ # FeatureContext::theOptionIsConfiguredTo()
    When I load the configuration file # FeatureContext::iLoadTheConfigurationFile()
    And I set the ‘timezone’ configuration option to ‘GMT’ # FeatureContext::iSetTheConfigurationOptionTo()
    Then I should get ‘GMT’ as ‘timezone’ option # FeatureContext::iShouldGetAsOption()
 
3 scenarios (3 passed)
13 steps (13 passed)
0m0.04s (8.92Mb)

Все красиво и зелено, поэтому давайте подведем итоги и посмотрим, чего мы достигли.

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

  1. Если мы наблюдаем ошибку или нуждаемся в изменении некоторых внутренних компонентов нашего программного обеспечения, мы можем описать это с помощью phpspec. Напишите неудачный пример, демонстрирующий ошибку, и напишите код, необходимый для перехода к зеленому цвету.
  2. Если нам нужно добавить новый вариант использования к тому, что у нас есть, мы можем добавить сценарий в config.feature . Затем мы можем итеративно прорабатывать каждый шаг, используя Behat и phpspec.
  3. Если нам нужно реализовать новую функцию, такую ​​как поддержка файлов конфигурации YAML, мы можем написать совершенно новую функцию и начать все сначала, используя подход, который мы использовали в этом руководстве.

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

Я надеюсь, что это руководство помогло вам лучше понять, как BDD можно использовать в контексте PHP с Behat и phpspec. Если у вас есть какие-либо вопросы или комментарии, пожалуйста, оставьте их ниже в разделе комментариев.

Спасибо, что читаете вместе!