phpspec 03/08/2015
Reference: This article is intended to be as complete as possible and is kept up to date.
phpspec is a highly opinionated unit test framework: it was conceived to practice specBDD (test method names should be sentences) and Mockist TDD (collaborators should always be mocked) and tries to enforce some testing practices (see Marcello Duarte's top 10 favourite phpspec limitations).
The first version was created by Pádraic Brady as a port of rspec, but Marcello Duarte and Konstantin Kudryashov took over and released a second version which added code generation. Ciaran McNulty then took the lead from v2.1 and added many features like collaborator generation, better exception specification and currently for version 2.3 better constructor specification.
Usage example
Let's have a look at how phpspec works. For this we'll need to have a project configured with
Composer. Here's the composer.json
file:
{
"name": "vendor/project",
"autoload": {
"psr-4": {
"Vendor\\Project\\": "src/Vendor/Project"
}
},
"require": {},
"require-dev": {}
}
We can install phpspec with the following:
composer require --dev phpspec/phpspec:^2.2
Let's say we want to create a class that edits text files. We can call this class
TextEditor
:
phpspec describe 'Vendor\Project\TextEditor'
Tip: make your vendor's binaries available by adding
vendor/bin
to your$PATH
.export PATH="vendor/bin:$PATH"
.
We should now have the spec/Vendor/Project/TextEditorSpec.php
file, bootstraped
for us by phpspec:
<?php
namespace spec\Vendor\Project;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class TextEditorSpec extends ObjectBehavior
{
function it_is_initializable()
{
$this->shouldHaveType('Vendor\Project\TextEditor');
}
}
Note: this test can only be used to test the
TextEditor
class.
Our first use case will be about creating a file if it doesn't already exist.
Those are filesystem operations, so we decide to delegate the actual logic to a
Filesystem
class (we'll create it later).
So our first step will be to create a set up method (it will be executed before every test method) that initializes our object:
<?php
// File: spec/Vendor/Project/TextEditorSpec.php
namespace spec\Vendor\Project;
use Vendor\Project\Service\Filesystem;
use PhpSpec\ObjectBehavior;
class TextEditorSpec extends ObjectBehavior
{
function let(Filesystem $filesystem)
{
$this->beConstructedWith($filesystem);
}
}
Arguments passed to test methods are actually test doubles (phpspec uses the typehint to know what to mock).
Now we can create the actual test method:
<?php
// File: spec/Vendor/Project/TextEditorSpec.php
namespace spec\Vendor\Project;
use Vendor\Project\Service\Filesystem;
use Vendor\Project\File;
use PhpSpec\ObjectBehavior;
class TextEditorSpec extends ObjectBehavior
{
const FILENAME = '/tmp/file.txt';
const FORCE_FILE_CREATION = true;
function let(Filesystem $filesystem)
{
$this->beConstructedWith($filesystem);
}
function it_can_force_file_creation_if_it_does_not_already_exists(File $file, Filesystem $filesystem)
{
$filesystem->exists(self::FILENAME)->willReturn(false);
$filesystem->create(self::FILENAME)->willReturn($file);
$this->open(self::FILENAME, self::FORCE_FILE_CREATION)->shouldBe($file);
}
}
This is roughly the equivalent of the following with PHPUnit:
<?php
namespace Vendor\Project\Tests;
use Vendor\Project\Service\Filesystem;
use Vendor\Project\File;
use PhpSpec\ObjectBehavior;
class TextEditorTest extends PHPUnit_Framewor_TestCase
{
const FILENAME = '/tmp/file.txt';
const FORCE_FILE_CREATION = true;
private $prophet;
private $filesystem;
private $textEditor;
protected function setUp()
{
$this->prophet = new \Prophecy\Prophet();
$this->filesystem = $this->prophet->prophesize('Vendor\Project\Service\Filesystem');
$this->textEditor = new TextEditor($filesystem->reveal());
}
protected function tearDown()
{
$this->prophet->checkPredictions();
}
/**
* @test
*/
public function it_can_force_file_creation_if_it_does_not_already_exists()
{
$file = $this->prophet->prophesize('Vendor\Project\File');
$this->filesystem->exists(self::FILENAME)->willReturn(false);
$this->filesystem->create(self::FILENAME)->willReturn($file);
self::assertSame($file, $this->textEditor->open(self::FILENAME, self::FORCE_FILE_CREATION));
}
}
Note: We tried to make the test method as descriptive as possible (e.g. not
testOpen()
). This is the whole point of specBDD (specification Behavior Driven Development).
phpspec uses the mocking library prophecy (also available in PHPUnit since version 4.4),
which tries to be as less verbose as possible: to describe interractions, test doubles
can almost be used as the actual object, except we need to add a ->willreturn()
or
->shouldBeCalled()
call afterwards.
Note: By mocking all collaborators, we are forced to think how our object interracts with them. This is the whole point of Mockist TDD (Mockist Test Driven Development).
Now that we have a test, we are going to execute the test suite (as advocated by TDD):
phpspec run
It will ask the following 7 questions:
- Would you like me to generate an interface
Vendor\Project\Service\Filesystem
for you? - Would you like me to generate an interface
Vendor\Project\File
for you? - Would you like me to generate a method signature
Vendor\Project\Service\Filesystem::exists()
for you? - Would you like me to generate a method signature
Vendor\Project\Service\Filesystem::create()
for you? - Do you want me to create
Vendor\Project\TextEditor
for you? - Do you want me to create
Vendor\Project\TextEditor::__construct()
for you? - Do you want me to create
Vendor\Project\TextEditor::open()
for you?
By accepting everytime, phpspec will bootstrap the following
src/Vendor/Project/Vendor/TextEditor.php
file:
<?php
namespace Vendor\Project;
class TextEditor
{
public function __construct($argument1)
{
// TODO: write logic here
}
public function open($argument1, $argument2)
{
// TODO: write logic here
}
}
In our specification, we make use of non existing class (File
and Filesystem
)
but phpspec also bootstraped them for us, for example src/Vendor/Project/Vendor/Filesystem.php
:
<?php
namespace Vendor\Project\Service;
interface Filesystem
{
public function exists($argument1);
public function create($argument1);
}
This is extremely usefull to kickstart our TDD cycle!
Once the code is written, we'll execute the test suite again, and then we'll add more use cases to the test class (e.g. what happens if the file already exists?).
For further usage example, have a look at: articles tagged with phpspec.
SpecGen
phpspec's code generator is a big part of its value, but it could do more for us. Hence the SpecGen extension!
We can install it as follow:
composer require --dev memio/spec-gen:^0.4
echo 'extensions:' > phpspec.yml
echo ' - Memio\SpecGen\MemioSpecGenExtension' >> phpspec.yml
If we remove the code generated by phpspec:
rm src/Vendor/Project/TextEditor.php
And re-run the tests:
phpspec run
Then the generated class will be:
<?php
namespace Vendor\Project;
use Vendor\Project\Service\Filesystem;
class TextEditor
{
private $filesystem;
public function __construct(Filesystem $filesystem)
{
$this->filesystem = $filesystem;
}
public function open($argument1, $argument2)
{
}
}
Emergent Design
Test are more than simple regression checks: when a class is hard to test it indicates that it does too many things or is too coupled.
They can also be used as a design tool: in our test method we can define exactly how we would like to use the future object. We can even decide on the go that some logic could be done by collaborators that don't exist yet and start defining how we like to interract with them.
This is what Emergent Design is all about: the design emerges on the go from the tests. Then they stop being simple tests, they become specifications: a live documentation of your code, which never become out of date.
Tips
Here are some personal tips:
- use
willReturn
if you need the returned value of a collaborator (official name: Mock) - use
shouldBeCalled
to check that a collaborator is called (official name: Stub) - do not chain
willReturn
andshouldBeCalled
, you have to pick one! - create a
it_is_a_
test method with ashouldImplement
check when testing implementations - the
shouldBe
matcher is usually the only one you need - if you have a lot (how many? that's your call) of test methods, maybe it's time to split your object
- if you copy paste the same block of code in many test methods, maybe it's time extract it into an object
If the tested object (official name: System Under Test, SUT) creates a value object and passes it to a collaborator you have two choices:
- pass
Argument::type('MyValueObject')
- create a factory and add it as a dependency
Conclusion
phpspec is a nice unit testing framework, with many advantages:
- it's fast: on average projects the test suite is run in less than a second
- it's a good pair-programming partner: when something is hard to test, it means that there is a better diffrent way to do it
- it's a time saver: the code bootstrapping feature is a must have!
- it isn't verbose: tests can be read as documented code examples
Give it a try!