By Brian Scaturro / @scaturr
Code samples extracted from Behavior Driven Todos
BDD = Behavior Driven Development
(Start with Behat)
Focus on one scenario
Write a failing step definition
(Drop down to Peridot)
Write a failing spec
Get the spec to pass
Refactor
(When the step is passing)
Refactor
Focus on the how, not the what
highly extensible, highly enjoyable, BDD testing framework for PHP
<?php
describe('ArrayObject', function () {
beforeEach(function () {
$this->arrayObject = new ArrayObject(['one', 'two', 'three']);
});
describe('->count()', function () {
it('should return the number of items', function () {
$count = $this->arrayObject->count();
assert($count === 3, 'expected 3');
});
});
});
An easy to use BDD/TDD tool. Useful for testing units, libraries, and apps!
A BDD framework for PHP to help you test business expectations.
Feature: toggling the status of a todo
As a user
I want to be able to mark todos complete
Scenario: viewing a done todo
Given I have a done todo "Get groceries"
When I am on "/"
Then I should see 1 ".todo-complete" element after waiting
<?php
class FeatureContext
{
/**
* @Given I have a done todo :arg1
*/
public function iHaveADoneTodo($todoText)
{
$collection = self::getTodoCollection();
$collection->insert(['label' => $todoText, 'done' => true]);
}
/**
* Opens specified page.
*
* @Given /^(?:|I )am on "(?P<page>[^"]+)"$/
* @When /^(?:|I )go to "(?P<page>[^"]+)"$/
*/
public function visit($page)
{
$this->visitPath($page);
}
/**
* @Then I should see :arg2 :arg1 element(s) after waiting
*/
public function iShouldSeeElementAfterWaiting($number, $selector)
{
$this->getSession()->wait(10000, "document.querySelectorAll('$selector').length === $number");
$this->assertNumElements($number, $selector);
}
}
Great for acceptance tests, planning, and testing business expectations.
Browser emulation for PHP. The Mink extension for Behat is great for automating application acceptance tests that use Selenium WebDriver.
+ = BDD Nirvana
In an ideal world, we gather around this acceptance criteria during planning and write testable acceptance criteria with stakeholders.
We can translate our user stories into automated acceptance tests with Behat!
Feature: initial visit to the todo app
As a user
I want to input todos
So I can track my business
Scenario: visiting todos for the first time
Given I am on "/"
Then I should see "Todos"
And I should see a "#todo" element
<!-- public/index.php -->
<h1>Todos<h1>
<input type="text" id="todo" />
Feature: adding a todo
As a user
I want my todos to be persisted
So I don't have to retype them
Scenario: adding a todo
Given I am on "/"
When I fill in "todo" with "Get groceries"
And I press "add"
And I reload the page
Then I should see "Get groceries"
<!-- public/index.php -->
<h1>Todos<h1>
<input type="text" id="todo" /> <button id="add">add</button>
We've started our acceptance test cadence. However - we need to come to terms with reality: A single index.php probably won't cut it. We need to start writing real code.
The decision has been made to use a framework for the code behind the features. We happen to like Silex.
We need to start writing code to support the features of our todo application. We are at a point where we want to pursue BDD at the unit level.
<?php
use Symfony\Component\HttpFoundation\Request;
describe('TodosController', function () {
beforeEach(function () {
$this->controller = new TodosController();
});
describe('->create()', function () {
it('should insert a todo', function () {
$request = Request::create('/todos', 'POST', [], [], [], [], json_encode([
'label' => 'Get groceries'
]));
$this->controller->create($request);
// what is our behavior?
});
});
});
Mocks and spies are a great way to verify behavior. Peridot has a plugin for using the amazing mocking framework: Prophecy
Plugins are installed like any other package, and are included via the peridot.php file contained in the project root.
<?php //peridot.php
use Peridot\Plugin\Prophecy\ProphecyPlugin;
// if peridot.php returns a closure it will be called with
// the Peridot event emitter, a key component to extending Peridot
return function($emitter) {
$prophecy = new ProphecyPlugin($emitter);
};
This plugin adds Prophecy functionality to our specs.
<?php
use Symfony\Component\HttpFoundation\Request;
use Brianium\Todos\Controller\TodosController;
describe('TodosController', function () {
beforeEach(function () {
$this->collection = $this->getProphet()->prophesize('MongoCollection');
$this->controller = new TodosController($this->collection->reveal());
});
describe('->create()', function () {
beforeEach(function () {
$this->data = ['label' => 'Get groceries'];
});
it('should insert a todo', function () {
$request = Request::create('/todos', 'POST', [], [], [], [], json_encode([
'label' => 'Get groceries'
]));
$this->controller->create($request);
$this->collection->insert($this->data)->shouldBeCalled(); //thanks Prophecy!
$this->getProphet()->checkPredictions();
});
});
});
<?php
namespace Brianium\Todos\Controller;
class TodosController
{
}
<?php
namespace Brianium\Todos\Controller;
class TodosController
{
public function create()
{
}
}
<?php
namespace Brianium\Todos\Controller;
use MongoCollection;
use Symfony\Component\HttpFoundation\Request;
class TodosController
{
private $todos;
public function __construct(MongoCollection $todos)
{
$this->todos = $todos;
}
public function create(Request $request)
{
$payload = json_decode($request->getContent(), true);
$this->todos->insert($payload);
}
}
To make our API useful, we should return our new resource
<?php
use Symfony\Component\HttpFoundation\Request;
use Brianium\Todos\Controller\TodosController;
describe('TodosController', function () {
beforeEach(function () {
$this->collection = $this->getProphet()->prophesize('MongoCollection');
$this->controller = new TodosController($this->collection->reveal());
});
describe('->create()', function () {
beforeEach(function () {
$this->data = ['label' => 'Get groceries'];
$this->request = Request::create('/todos', 'POST', [], [], [], [], json_encode($this->data));
});
it('should insert a todo', function () {
$this->controller->create($this->request);
$this->collection->insert($this->data)->shouldBeCalled();
$this->getProphet()->checkPredictions();
});
it('should return the new todo', function () {
$response = $this->controller->create($this->request);
$todo = json_decode($response->getContent());
assert($todo->label == 'Get groceries');
});
});
});
<?php
namespace Brianium\Todos\Controller;
use MongoCollection;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
class TodosController
{
private $todos;
public function __construct(MongoCollection $todos)
{
$this->todos = $todos;
}
public function create(Request $request)
{
$payload = json_decode($request->getContent(), true);
$this->todos->insert($payload);
return JsonResponse::create($payload);
}
}
<html>
<head>
<title>Todo List</title>
</head>
<body>
<h1>Todos</h1>
<input type="text" id="todo" /> <button id="add">add</button>
<ul id="todos">
{% for todo in todos %}
<li>{{ todo.label }}</li>
{% endfor %}
</ul>
<script src="//cdnjs.cloudflare.com/ajax/libs/lodash.js/3.5.0/lodash.min.js"></script>
<script src="//code.jquery.com/jquery-2.1.3.min.js"></script>
<script src="js/todos.js"></script>
</body>
</html>
<?php
namespace Brianium\Todos\Controller;
use MongoCollection;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Twig_Environment;
class TodosController
{
private $todos;
private $twig;
public function __construct(MongoCollection $todos, Twig_Environment $twig)
{
$this->todos = $todos;
$this->twig = $twig;
}
public function index()
{
$content = $this->twig->render('index.twig', ['todos' => $this->todos->find()]);
return Response::create($content);
}
public function create(Request $request)
{
$payload = json_decode($request->getContent(), true);
$this->todos->insert($payload);
return JsonResponse::create($payload);
}
}
(function () {
var todo = $('#todo'),
add = $('#add'),
list = $('#todos');
/**
* Returns an event handler that appends
* text from input to a list element.
*
* @param {jQuery} list
* @param {jQuery} input
* @return {Function}
*/
function append(list, input) {
return function () {
var val = input.val();
list.append('<li>' + val + '</li>');
return val;
};
}
/**
* Persist the todo
*
* @param {String} val the todo label
*/
function create(val) {
$.ajax({
method: 'POST',
url: '/todos',
data: JSON.stringify({label: val}),
contentType: 'application/json',
dataType: 'json'
});
}
add.click(_.compose(create, append(list, todo)));
})();
How Silex works is beyond the scope of this talk, but if you want to refer to the todo app to see how a controller is tied to a route, take a look at the app file.
A lot of behavior, especially for APIs, ends up outside of the context of a controller - usually in the form of middleware. This is a good thing, especially when it is repeated everywhere.
<?php
use Peridot\Plugin\Prophecy\ProphecyPlugin;
use Peridot\Plugin\HttpKernel\HttpKernelPlugin;
return function($emitter) {
$prophecy = new ProphecyPlugin($emitter);
//set up app tests
$app = include __DIR__ . '/app/app.php';
$app['debug'] = true;
$app['exception_handler']->disable();
$app['todos-collection'] = $app['mongo-client']->test->todos;
HttpKernelPlugin::register($emitter, $app);
};
<?php
describe('/todos/{id}', function () {
beforeEach(function () {
$app = $this->getHttpKernelApplication();
$app['todos-collection']->remove();
$this->document = ['label' => 'Do a thing!'];
$app['todos-collection']->insert($this->document);
});
describe('POST', function () {
it('should return an error response if the todo already exists', function () {
$this->client->request('POST', '/todos', [], [], [], '{"label":"Do a thing!"}');
$response = $this->client->getResponse();
assert($response->getStatusCode() === 422);
});
});
});