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

Недавно создатель Behat Константин Кудряшов (a.k.a. everzet) написал действительно замечательную статью под названием «Представление моделирования по примерам». Рабочий процесс, который мы собираемся использовать, был вдохновлен тем, который был представлен everzet.

Короче говоря, мы собираемся .feature для разработки как нашего основного домена, так и нашего пользовательского интерфейса. Я часто чувствовал, что у меня было много дублирования в моих функциях в моих фичах в приемочных/функциональных и интеграционных пакетах. Когда я прочитал предложение Everzet об использовании одной и той же фичи для нескольких контекстов, я наконец понял как нужно делать.

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

Небольшой рефакторинг

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

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

$ git rm features/functional/welcome.feature

Во-вторых, у нас будут свои фичи в корне папки features, поэтому мы можем продолжить и удалить атрибут path из нашего файла behat.yml. Мы также переименуем LaravelFeatureContext в FunctionalFeatureContext (не забудьте также изменить имя класса):

default:
suites:
functional:
contexts: [ FunctionalFeatureContext ]

Наконец, чтобы немного все подчистить, я думаю, что мы должны переместить все связанные с Laravel вещи в отдельный трейт:

# features/bootstrap/LaravelTrait.php

<?php

use IlluminateFoundationTestingApplicationTrait;

trait LaravelTrait
{
/**
* Responsible for providing a Laravel app instance.
*/
use ApplicationTrait;

/**
* @BeforeScenario
*/
public function setUp()
{
if ( ! $this->app)
{
$this->refreshApplication();
}
}

/**
* Creates the application.
*
* @return SymfonyComponentHttpKernelHttpKernelInterface
*/
public function createApplication()
{
$unitTesting = true;

$testEnvironment = ‘testing’;

return require __DIR__.’/../../bootstrap/start.php’;
}
}

В FunctionalFeatureContext мы можем затем использовать этот трейт и удалить то, что мы только что переместили:

/**
* Behat context class.
*/
class FunctionalFeatureContext implements SnippetAcceptingContext
{
use LaravelTrait;

/**
* Initializes context.
*
* Every scenario gets its own context object.
* You can also pass arbitrary arguments to the context constructor through behat.yml.
*/
public function __construct()
{
}

Трейты — отличный способ очистить ваши контексты.

Совместное использование фичи

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

Feature: Tracking time
In order to track time spent on tasks
As an employee
I need to manage a time sheet with time entries

Scenario: Generating time sheet
Given I have the following time entries
| task | duration |
| coding | 90 |
| coding | 30 |
| documenting | 150 |
When I generate the time sheet
Then my total time spent on coding should be 120 minutes
And my total time spent on documenting should be 150 minutes
And my total time spent on meetings should be 0 minutes
And my total time spent should be 270 minutes

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

Хорошо, давайте попробуем Behat создать сценарии для нас:

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

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

/**
* @Given I have the following time entries
*/
public function iHaveTheFollowingTimeEntries(TableNode $table)
{
throw new PendingException();
}

/**
* @When I generate the time sheet
*/
public function iGenerateTheTimeSheet()
{
throw new PendingException();
}

/**
* @Then my total time spent on :task should be :expectedDuration minutes
*/
public function myTotalTimeSpentOnTaskShouldBeMinutes($task, $expectedDuration)
{
throw new PendingException();
}

/**
* @Then my total time spent should be :expectedDuration minutes
*/
public function myTotalTimeSpentShouldBeMinutes($expectedDuration)
{
throw new PendingException();
}

Наш функциональный контекст готов к работе, но нам также нужен контекст для нашего пакета интеграции. Сначала мы добавим пакет в файл behat.yml:

default:
suites:
functional:
contexts: [ FunctionalFeatureContext ]
integration:
contexts: [ IntegrationFeatureContext ]

Затем мы можем просто скопировать объект FeatureContext по умолчанию:

$ cp features/bootstrap/FeatureContext.php features/bootstrap/IntegrationFeatureContext.php

Не забудьте изменить имя класса на IntegrationFeatureContext, а также скопировать оператор use для PendingException.

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

Проектирование домена

На этом этапе мы готовы начать заполнять шаги в нашем контексте интеграции, чтобы разработать основной домен нашего приложения. Первый шаг: Given I have the following time entries , за которыми следует таблица с записями времени. Сделаем все просто и перейдем к строкам таблицы, попробуем создать экземпляр времени для каждого из них и добавить их в массив записей в контексте:

use TimeTrackerTimeEntry;

/**
* @Given I have the following time entries
*/
public function iHaveTheFollowingTimeEntries(TableNode $table)
{
$this->entries = [];

$rows = $table->getHash();

foreach ($rows as $row) {
$entry = new TimeEntry;

$entry->task = $row[‘task’];
$entry->duration = $row[‘duration’];

$this->entries[] = $entry;
}
}

Запуск Behat приведет к фатальной ошибке, так как класс TimeTrackerTimeEntry еще не существует. Именно здесь PhpSpec выходит на сцену. В конце концов, TimeEntry станет классом Eloquent, хотя мы пока не беспокоимся об этом. PhpSpec и ORM, такие как Eloquent, не так хорошо сочетаются, но мы все еще можем использовать PhpSpec для генерации класса и даже специфицировать некоторые основные действия. Давайте используем генераторы PhpSpec для генерации класса TimeEntry:

$ vendor/bin/phpspec desc «TimeTrackerTimeEntry»
$ vendor/bin/phpspec run
Do you want me to create `TimeTrackerTimeEntry` for you? y

После создания класса нам необходимо обновить раздел автозагрузки нашего файла composer.json:

«autoload»: {
«classmap»: [
«app/commands»,
«app/controllers»,
«app/models»,
«app/database/migrations»,
«app/database/seeds»
],
«psr-4»: {
«TimeTracker\»: «src/TimeTracker»
}
},

И, конечно, запустите composer dump-autoload.

Запуск PhpSpec дает нам зеленый цвет. Бегущий Беат дает нам зеленый цвет. Какое прекрасное начало!

Перейдем к следующему шагу: When I generate the time sheet.

Ключевое слово здесь «generate», которое выглядит как термин из нашего домена. В мире программистов перевод «генерировать расписание» в код может означать только создание экземпляра класса TimeSheet с кучей записей времени. При проектировании нашего кода важно попытаться придерживаться языка из домена. Таким образом, наш код поможет описать предполагаемое поведение нашего приложения.

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

Вместо того, чтобы просто использовать массив, я думаю, будет иметь смысл использовать класс IlluminateSupportCollection, который поставляется с Laravel. Поскольку TimeEntry будет моделью Eloquent, когда мы запрашиваем базу данных для записей времени, мы получим одну из этих коллекций Laravel в любом случае. Как насчет чего-то вроде этого:

use IlluminateSupportCollection;
use TimeTrackerTimeSheet;
use TimeTrackerTimeEntry;

/**
* @When I generate the time sheet
*/
public function iGenerateTheTimeSheet()
{
$this->sheet = TimeSheet::generate(Collection::make($this->entries));
}

Кстати, TimeSheet не будет классом Eloquent. По крайней мере, на данный момент нам нужно только сохранить записи времени, а затем временные листы будут только генерироваться из записей.

Запуск Behat снова приведет к фатальной ошибке, потому что TimeSheet не существует. PhpSpec может помочь нам решить эту проблему:

$ vendor/bin/phpspec desc «TimeTrackerTimeSheet»
$ vendor/bin/phpspec run
Do you want me to create `TimeTrackerTimeSheet` for you? y
$ vendor/bin/phpspec run

$ vendor/bin/behat

PHP Fatal error: Call to undefined method TimeTrackerTimeSheet::generate()

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

<?php

namespace TimeTracker;

use IlluminateSupportCollection;

class TimeSheet
{
protected $entries;

public function __construct(Collection $entries)
{
$this->entries = $entries;
}

public static function generate(Collection $entries)
{
return new static($entries);
}
}

Это вернет Behat к зеленому прохождению тестов, но PhpSpec теперь ругается, говоря: Argument 1 passed to TimeTrackerTimeSheet::__construct() must be an instance of IlluminateSupportCollection, none given. Мы можем решить это, написав простую функцию let(), которая будет вызываться перед каждой спецификацией:

<?php

namespace specTimeTracker;

use PhpSpecObjectBehavior;
use ProphecyArgument;

use IlluminateSupportCollection;
use TimeTrackerTimeEntry;

class TimeSheetSpec extends ObjectBehavior
{
function let(Collection $entries)
{
$entries->put(new TimeEntry);

$this->beConstructedWith($entries);
}

function it_is_initializable()
{
$this->shouldHaveType(‘TimeTrackerTimeSheet’);
}
}

Это вернет нас к зеленому цвету. Функция гарантирует, что временной лист всегда строится с моком класса Collection.

Теперь мы можем безопасно перейти к следующему шагу Then my total time spent on...  Нам нужен метод, который принимает имя задачи и возвращает накопленную продолжительность всех записей с этим именем задачи. Непосредственно переведенный из gherkin в код, это может быть что-то вроде totalTimeSpentOn($task):

/**
* @Then my total time spent on :task should be :expectedDuration minutes
*/
public function myTotalTimeSpentOnTaskShouldBeMinutes($task, $expectedDuration)
{
$actualDuration = $this->sheet->totalTimeSpentOn($task);

PHPUnit::assertEquals($expectedDuration, $actualDuration);
}

Метод не существует, поэтому вызов Behat даст нам вызов неопределенного метода TimeTrackerTimeSheet::totalTimeSpentOn().

Чтобы специфицировать метод, мы напишем спецификацию, которая выглядит как-то похожее на то, что мы уже имеем в нашем сценарии:

function it_should_calculate_total_time_spent_on_task()
{
$entry1 = new TimeEntry;
$entry1->task = ‘sleeping’;
$entry1->duration = 120;

$entry2 = new TimeEntry;
$entry2->task = ‘eating’;
$entry2->duration = 60;

$entry3 = new TimeEntry;
$entry3->task = ‘sleeping’;
$entry3->duration = 120;

$collection = Collection::make([$entry1, $entry2, $entry3]);

$this->beConstructedWith($collection);

$this->totalTimeSpentOn(‘sleeping’)->shouldBe(240);
$this->totalTimeSpentOn(‘eating’)->shouldBe(60);
}

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

Двигаемся вперед:

$ vendor/bin/phpspec run
Do you want me to create `TimeTrackerTimeSheet::totalTimeSpentOn()` for you? y

$ vendor/bin/phpspec run

25 ✘ it should calculate total time spent on task
expected [integer:240], but got null.

Чтобы отфильтровать записи, мы можем использовать метод filter() в классе Collection. Простое решение, которое дает нам зеленый цвет:

public function totalTimeSpentOn($task)
{
$entries = $this->entries->filter(function($entry) use ($task)
{
return $entry->task === $task;
});

$duration = 0;

foreach ($entries as $entry) {
$duration += $entry->duration;
}

return $duration;
}

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

public function totalTimeSpentOn($task)
{
$entries = $this->entries->filter(function($entry) use ($task)
{
return $entry->task === $task;
});

return $this->sumDuration($entries);
}

protected function sumDuration($entries)
{
$duration = 0;

foreach ($entries as $entry) {
$duration += $entry->duration;
}

return $duration;
}

PhpSpec по-прежнему зеленый, и теперь у нас есть три зеленых шага в Бехате. Последний шаг должен быть легко реализован, так как он несколько похож на тот, который мы только что сделали.

/**
* @Then my total time spent should be :expectedDuration minutes
*/
public function myTotalTimeSpentShouldBeMinutes($expectedDuration)
{
$actualDuration = $this->sheet->totalTimeSpent();

PHPUnit::assertEquals($expectedDuration, $actualDuration);
}

Запуск Behat даст нам Call to undefined method TimeTrackerTimeSheet::totalTimeSpent(). Вместо того, чтобы делать отдельный пример в нашей спецификации для этого метода, как насчет того, чтобы просто добавить его к тому, который у нас уже есть? Это может быть не совсем в соответствии с тем, как делать «правильно», но давайте быть немного прагматичными:

$this->beConstructedWith($collection);

$this->totalTimeSpentOn(‘sleeping’)->shouldBe(240);
$this->totalTimeSpentOn(‘eating’)->shouldBe(60);
$this->totalTimeSpent()->shouldBe(300);

Пусть PhpSpec генерирует метод:

$ vendor/bin/phpspec run
Do you want me to create `TimeTrackerTimeSheet::totalTimeSpent()` for you? y

$ vendor/bin/phpspec run

25 ✘ it should calculate total time spent on task
expected [integer:300], but got null.

Получить зеленые тесты теперь легко, когда у нас есть метод sumDuration():

public function totalTimeSpent()
{
return $this->sumDuration($this->entries);
}

И теперь у нас есть зеленая фича. Наш домен постепенно развивается!

Проектирование пользовательского интерфейса

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

Во время работы в набором тестов мы можем добавить флаг -s, чтобы инструктировать Behat запускать наши фичи только через FunctionalFeatureContext:

$ vendor/bin/behat -s functional

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

use TimeTrackerTimeEntry;

/**
* @Given I have the following time entries
*/
public function iHaveTheFollowingTimeEntries(TableNode $table)
{
$rows = $table->getHash();

foreach ($rows as $row) {
$entry = new TimeEntry;

$entry->task = $row[‘task’];
$entry->duration = $row[‘duration’];

$entry->save();
}
}

Запуск Behat даст нам фатальную ошибку Call to undefined method TimeTrackerTimeEntry::save(), поскольку TimeEntry по-прежнему не является моделью Eloquent. Это легко исправить:

namespace TimeTracker;

class TimeEntry extends Eloquent
{
}

Если мы снова запустим Behat, Laravel пожалуется, что он не сможет подключиться к базе данных. Мы можем исправить это, добавив файл database.php в каталог app/config/testing, чтобы добавить сведения о соединении для нашей базы данных. Для более крупных проектов вы, вероятно, захотите использовать один и тот же сервер базы данных для своих тестов и производственного кода, но в нашем случае мы просто используем базу данных в памяти SQLite. Это очень просто настроить с помощью Laravel:

<?php

return array(

‘default’ => ‘sqlite’,

‘connections’ => array(

‘sqlite’ => array(
‘driver’ => ‘sqlite’,
‘database’ => ‘:memory:’,
‘prefix’ => »,
),

),

);

Теперь, если мы запустим Behat, который скажет нам, что нет таблицы time_entries. Чтобы исправить это, нам нужно выполнить миграцию:

$ php artisan migrate:make createTimeEntriesTable —create=»time_entries»

Schema::create(‘time_entries’, function(Blueprint $table)
{
$table->increments(‘id’);
$table->string(‘task’);
$table->integer(‘duration’);
$table->timestamps();
});

Мы все еще не зеленые, так как нам нужен способ научить Behat выполнять наши миграции перед каждым сценарием, поэтому каждый раз мы получаем чистый список. Используя аннотации Behat, мы можем добавить эти два метода к трейту LaravelTrait:

/**
* @BeforeScenario
*/
public function setupDatabase()
{
$this->app[‘artisan’]->call(‘migrate’);
}

/**
* @AfterScenario
*/
public function cleanDatabase()
{
$this->app[‘artisan’]->call(‘migrate:reset’);
}

Это довольно аккуратно, и наш первый шаг к зеленому.

Следующий шаг — When I generate the time sheet. Я его вижу следующим образом: создание листа времени — это эквивалент посещения действия index ресурса записи времени, поскольку временной лист — это сбор всех временных записей. Таким образом, объект временного листа похож на контейнер для всех записей времени и дает нам хороший способ обработки записей. Вместо того, чтобы переходить в /time-entries, чтобы увидеть временной лист, я думаю, что работник должен перейти на /time-sheet. Мы должны добавить это в наше определение шага:

/**
* @When I generate the time sheet
*/
public function iGenerateTheTimeSheet()
{
$this->call(‘GET’, ‘/time-sheet’);

$this->crawler = new Crawler($this->client->getResponse()->getContent(), url(‘/’));
}

Это вызовет исключение NotFoundHttpException, поскольку маршрут еще не определен. Как я только что объяснил, я думаю, что этот URL-адрес должен сопоставляться с действием index на ресурсе записи времени:

Route::get(‘time-sheet’, [‘as’ => ‘time_sheet’, ‘uses’ => ‘TimeEntriesController@index’]);

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

$ php artisan controller:make TimeEntriesController
$ composer dump-autoload

Готово.

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

/**
* @Then my total time spent on :task should be :expectedDuration minutes
*/
public function myTotalTimeSpentOnTaskShouldBeMinutes($task, $expectedDuration)
{
$actualDuration = $this->crawler->filter(‘td#’ . $task . ‘TotalDuration’)->text();

PHPUnit::assertEquals($expectedDuration, $actualDuration);
}

/**
* @Then my total time spent should be :expectedDuration minutes
*/
public function myTotalTimeSpentShouldBeMinutes($expectedDuration)
{
$actualDuration = $this->crawler->filter(‘td#totalDuration’)->text();

PHPUnit::assertEquals($expectedDuration, $actualDuration);
}

Мы ищем тег <td> с идентификатором [task_name]TotalDuration или totalDuration в последнем примере.

Поскольку у нас пока нет представления, сканер сообщает нам, что The current node list is empty.

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

use TimeTrackerTimeSheet;
use TimeTrackerTimeEntry;

class TimeEntriesController extends BaseController {

/**
* Display a listing of the resource.
*
* @return Response
*/
public function index()
{
$entries = TimeEntry::all();
$sheet = TimeSheet::generate($entries);

return View::make(‘time_entries.index’, compact(‘sheet’));
}

В настоящее время представление будет состоять из простой таблицы с суммарными значениями продолжительности:

<h2>Time Sheet</h2>

<table>
<thead>
<th>Task</th>
<th>Total duration</th>
</thead>
<tbody>
<tr>
<td>coding</td>
<td id=»codingTotalDuration»>{{ $sheet->totalTimeSpentOn(‘coding’) }}</td>
</tr>
<tr>
<td>documenting</td>
<td id=»documentingTotalDuration»>{{ $sheet->totalTimeSpentOn(‘documenting’) }}</td>
</tr>
<tr>
<td>meetings</td>
<td id=»meetingsTotalDuration»>{{ $sheet->totalTimeSpentOn(‘meetings’) }}</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td id=»totalDuration»>{{ $sheet->totalTimeSpent() }}</td>
</tr>
</tbody>
</table>

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

Вывод

Если вы запустите vendor / bin / behat, чтобы запустить оба набора Behat, вы увидите, что оба они теперь зеленые. Если вы запустите PhpSpec, то, к сожалению, вы увидите, что наши спецификации сломаны. Мы получаем фатальную ошибку. Класс «Красноречивый» не найден в …. Это потому, что Eloquent является псевдонимом. Если вы посмотрите в app/config/app.php под псевдонимами, вы увидите, что Eloquent на самом деле является псевдонимом для IlluminateDatabaseEloquentModel. Чтобы вернуть PhpSpec в зеленый цвет, нам нужно импортировать этот класс:

namespace TimeTracker;

use IlluminateDatabaseEloquentModel as Eloquent;

class TimeEntry extends Eloquent
{
}

Если вы запустите эти две команды:

$ vendor/bin/phpspec run; vendor/bin/behat

Вы увидите, что мы вернулись к зеленому, как с Behat, так и с PhpSpec. Ура!

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

В следующей статье мы собираемся немного порефакторить, чтобы избежать слишком большой логики для наших моделей Eloquent, поскольку их сложнее тестировать изолированно и они тесно связаны с Laravel. Будьте на связи!

Источник: code.tutsplus.com