haijin / specs
A testing framework that uses a simple DSL, inspired by RSpec.
This package's canonical repository appears to be gone and the package has been frozen as a result.
Requires
- php: >=5.5.0
- clue/commander: ^1.3
- questocat/console-color: ^1.0.0
Requires (Dev)
README
A testing framework that uses a simple DSL, inspired by RSpec.
Version 2.0.0
If you like it a lot you may contribute by financing its development.
Table of contents
- Installation
- Usage
- Spec definitions
- Built-in expectations
- Specs structure
- Evaluating code before and after running expectations
- Defining values with let(...) expressions
- Defining methods with def(...)
- Defining custom expectations
- Temporary skipping a spec
- Factorizing and reusing specs behaviour
- Running the specs from the command line
- Generating a coverage report
- Running this project tests
Installation
Include this library in your project composer.json
file:
{ ... "require-dev": { ... "haijin/specs": "^2.0", ... }, ... }
Usage
In the project folder run
composer install
php ./vendor/bin/specs init
This will create a directory named tests/specs
and a specsBoot.php
file.
In any nested subdirectory of tests/specs
create files with specs definitions. No naming convention is needed for these files, all of them will be considered spec files.
tests/specsBoot.php
is an optional regular PHP script file loaded before any other spec file and can be used to customize the specs runner.
Spec definitions
A spec file contains expectations on a feature or functionality.
A spec file looks like this:
<?php $spec->describe( "When formatting a user's full name", function() { $this->it( "appends the user's last name to the user's name", function() { $user = new User( "Lisa", "Simpson" ); $this->expect( $user->getFullName() ) ->to() ->equal( "Lisa Simpson" ); }); });
Built-in expectations
Expectations are the equivalent to the assertions used in PHPUnit.
A expectation has two main parts, the value on which the expectation is expressed, for instance a string with the user's full name:
$this->expect( $user->getFullName() )
and the expectations on that value:
->to() ->equal('Lisa Simpson');
Specs library comes with the most common expectations built-in:
// Comparison expectations $this->expect( $value ) ->to() ->equal( $anotherValue ); $this->expect( $value ) ->to() ->be( ">" ) ->than( $anotherValue ); $this->expect( $value ) ->to() ->be( "===" ) ->than( $anotherValue ); $this->expect( $value ) ->to() ->be() ->null(); $this->expect( $value ) ->to() ->be() ->true(); $this->expect( $value ) ->to() ->be() ->false(); $this->expect( $value ) ->to() ->be() ->like([ "name" => "Lisa", "lastName" => "Simpson", "address" => [ "streetName" => "Evergreen", "streetNumber" => 742 ] ]); $this->expect( $value ) ->to() ->be() ->exactlyLike([ "name" => "Lisa", "lastName" => "Simpson", "address" => [ "streetName" => "Evergreen", "streetNumber" => 742 ] ]); // Types expectations $this->expect( $value ) ->to() ->be() ->string(); $this->expect( $value ) ->to() ->be() ->int(); $this->expect( $value ) ->to() ->be() ->double(); $this->expect( $value ) ->to() ->be() ->number(); $this->expect( $value ) ->to() ->be() ->bool(); $this->expect( $value ) ->to() ->be() ->array(); $this->expect( $value ) ->to() ->be() ->a( SomeClass::class ); $this->expect( $value ) ->to() ->be() ->instanceOf( SomeClass::class ); // String expectations $this->expect( $stringValue ) ->to() ->beginWith( $substring ); $this->expect( $stringValue ) ->to() ->endWith( $substring ); $this->expect( $stringValue ) ->to() ->contain( $substring ); $this->expect( $stringValue ) ->to() ->match( $regexp ); $this->expect( $stringValue ) ->to() ->match( $regexp, function($matched) { // further expectations on the $matched elements, for instance: $this->expect( $matched[1] ) ->to() ->equal(...) ; }); // Array expectations $this->expect( $arrayValue ) ->to() ->include( $value ); $this->expect( $arrayValue ) ->to() ->includeAll( $values ); $this->expect( $arrayValue ) ->to() ->includeAny( $values ); $this->expect( $arrayValue ) ->to() ->includeNone( $values ); $this->expect( $arrayValue ) ->to() ->includeKey( $key ); $this->expect( $arrayValue ) ->to() ->includeKey( $key, funtion($value) { // further expectations on the $value, for instance: $this->expect( $value ) ->to() ->equal(...) ; }); $this->expect( $arrayValue ) ->to() ->includeValue( $value ); // File expectations $this->expect( $filePath ) ->to() ->be() ->aFile(); $this->expect( $filePath ) ->to() ->haveFileContents( function($contents) { // further expectations on the $contents, for instance: $this->expect( $contents ) ->to() ->match(...) ; }); $this->expect( $filePath ) ->to() ->be() ->aDirectory(); $this->expect( $filePath ) ->to() ->haveDirectoryContents( function($files, $filesBasePath) { // further expectations on the $files }); // Exceptions $this->expect( function() { throw Exception(); }) ->to() ->raise( Exception::class ); $this->expect( function() { throw Exception( "Some message." ); }) ->to() ->raise( Exception::class, function($e) { // further expectations on the Exception instance, for instance: $this->expect( $e->getMessage() ) ->to() ->equal(...); });
Most expectations can also be negated with
$this->expect( $value ) ->not() ->to() ->equal( $anotherValue ); $this->expect( function() { throw Exception(); }) ->not() ->to() ->raise( Exception::class );
expect( $object ) ->to() ->be() ->like(...)
The expectation expect( $object ) ->to() ->be() ->like(...)
evaluates nested expectations on arrays, associative arrays, objects and any mix of them.
Example:
$user = [ 'name' => "Lisa", 'lastName' => "Simpson", 'address' => [ 'streetName' => "Evergreen", 'streetNumber' => 742 ], 'ignoredAttribute' => "" ]; $this->expect( $user ) ->to() ->be() ->like([ 'name' => "Lisa", 'lastName' => "Simpson", 'address' => [ 'streetName' => "Evergreen", 'streetNumber' => 742 ] ]);
It also works with getter functions:
$this->expect( $user ) ->to() ->be() ->like([ 'getName()' => "Lisa", 'getLastName()' => "Simpson", 'getAddress()' => [ 'getStreetName()' => "Evergreen", 'getStreetNumber()' => 742 ] ]);
The expectation uses equality (==
) to compare values. To use a custom expectation on a single value use a closure:
$this->expect( $user ) ->to() ->be() ->like([ 'getName()' => function($value) { $this->expect( $value ) ->not() ->to() ->be() ->null(); }, 'getLastName()' => "Simpson", 'getAddress()' => [ 'getStreetName()' => "Evergreen", 'getStreetNumber()' => 742 ] ]);
expect( $object ) ->to() ->be() ->exactlyLike(...)
Same as expect( $object ) ->to() ->be() ->like(...)
but if the object is an array and has more or less attributes than the expected value the expectation fails.
Specs structure
A spec begins with a $spec->decribe(...)
statement, and can include any number of additional nested $this->describe()
statements. Each describe()
statement documents a group of expectations that are somehow related, for instance because they declare different expected behaviours for the same functionality.
The ->it(...)
statement is where expectations are declared.
$spec->describe( "When formatting a user's full name", function() { $this->describe( "with both name and last name defined", function() { $this->it( "appends the user's last name to the user's name", function() { $user = new User( "Lisa", "Simpson" ); $this->expect( $user->getFullName() ) ->to() ->equal( "Lisa Simpson" ); }); }); $this->describe( "with the name undefined", function() { $this->it( "returns only the last name", function() { $user = new User( "", "Simpson" ); $this->expect( $user->getFullName() ) ->to() ->equal( "Simpson" ); }); }); $this->describe( "with the last name undefined", function() { $this->it( "returns only the name", function() { $user = new User( "Lisa", "" ); $this->expect( $user->getFullName() ) ->to() ->equal( "Lisa" ); }); }); });
Evaluating code before and after running expectations
To evaluate statements before and after each spec is run use beforeEach($closure)
and afterEach($closure)
at any describe
statement.
They are the equivalent to setUp()
and tearDown()
function on TestCase
.
$spec->describe( "When formatting a user's full name", function() { $this->beforeEach( function() { $this->n = 0; }); $this->afterEach( function() { $this->n = null; }); $this->describe( "with both name and last name defined", function() { $this->beforeEach( function() { $this->n += 1; }); $this->afterEach( function() { $this->n -= 1; }); $this->it( "...", function() { print $this->n; }); }); });
To evaluate statements before and after all the specs of a describe
statement are run use beforeAll($closure)
and afterAll($closure)
statements:
$spec->describe( "When formatting a user's full name", function() { $this->beforeAll( function() { $this->n = 0; }); $this->afterAll( function() { $this->n = null; }); $this->describe( "with both name and last name defined", function() { $this->beforeAll( function() { $this->n += 1; }); $this->afterAll( function() { $this->n -= 1; }); $this->it( "...", function() { print $this->n; }); }); });
To evaluate statements before and after any spec is run, like establishing connections to databases, creating tables or creating complex folder structures, or before and after each single statement, create or add to the tests/specsBoot.php
file the following configuration:
// tests/specsBoot.php $specs->beforeAll( function() { }); $specs->afterAll( function() { }); $specs->beforeEach( function() { }); $specs->afterEach( function() { });
It is possible to use and mix multiple beforeAll
, afterAll
, beforeEach
and afterEach
at any level.
Defining values with let(...) expressions
Define expressions and constants using the let( $expressionName, $closure )
statement.
Using $this->let()
is similar to the initialization of instance variables during the setUp()
method in TestCase
.
Expressions defined with let(...)
are lazily evaluated the first time they are referenced by each spec.
let(...)
expressions are inherit by child describe(...)
specs and can be safely overridden within the scope of a child describe(...)
.
A let(...)
expression can reference another let(...)
expression.
Example:
$spec->describe( "When searching for users", function() { $this->let( "userId", function() { return 1; }); $this->it( "finds the user by id", function(){ $user = Users::findById( $this->userId ); $this->expect( $user ) ->not() ->to() ->beNull(); }); $this->describe( "the retrieved user data", function() { $this->let( "user", function() { return Users::findById( $this->userId ); }); $this->it( "includes the name", function() { $this->expect( $this->user->getName() ) ->to() ->equal( "Lisa" ); }); $this->it( "includes the lastname", function() { $this->expect( $this->user->getLastname() ) ->to() ->equal( "Simpson" ); }); }); });
It is also possible to define named expressions at a global level in the specsBoot.php
file, but keep in mind that that will make each spec less expressive and will make it more difficult to understand:
// tests/specsBoot.php $specs->let( "userId", function() { return 1; });
Defining methods with def(...)
Define methods using the def($methodName, $closure)
statement.
The behaviour and scope of the methods is the same as for let(...)
expressions.
Example:
$spec->describe( "...", function() { $this->def( "sum", function($n, $m) { return $n + $m; }); $this->it( "...", function(){ $this->expect( $this->sum( 3, 4 ) ->to() ->equal( 7 ); }); });
Defining custom expectations
Expectation definition structure
A expectation definition has 4 parts, each part defined with a closure.
The first one is the $this->before($closure)
closure. This closure is evaluated before evaluating an expectation on a value. This block is optional but it can be used to perform complex calculations needed by the expectations for both the assertive and the negated closures.
The second one is the $this->assertWith($closure)
closure. This closure is evaluated to evaluate a positive expectation on a value.
The third one is the $this->negateWith($closure)
closure. This closure is evaluated to evaluate a negated expectation on a value.
The fourth one is the $this->after($closure)
closure. This closure is evaluated after the expectation is run, even when an ExpectationFailure was raised. This closure is optional but it can be used to release resources allocated during the evaluation of the previous closures.
Getting the value being validated
To get the value being validated use $this->actualValue
.
Parameters of the definition closures
The parameters of the 4 closures are the ones passed to the expectation in the Spec. For instance, if the spec is declared as
$this->expect( 1 ) ->not() ->to() ->equal( 2 );
the parameters for the 4 closures of the the equal
expectation will be the expected value 2
:
$this->before( function($expectedValue) { }); $this->assertWith( function($expectedValue) { }); $this->negateWith( function($expectedValue) { }); $this->after( function($expectedValue) { });
Raising expectation errors
To raise an expectation failure use $this->raiseFailure($failureMessage)
.
Evaluating closures within custom expectations
The validated value or some of the expectation parameters may be closures.
To evaluate closures within a custom expectation definition use evaluateClosure($closure, ...$params)
.
This is required for the closure to evaluate with the proper binding.
Example:
Value_Expectations::defineExpectation( "customExpectation", function() { $this->assertWith( function($expectedClosure) { $this->evaluateClosure( $expectedClosure, $this->actualValue ); // ... }); );
Complete example
Here is a complete example of a custom validation:
Value_Expectations::defineExpectation( "equal", function() { $this->before( function($expectedValue) { $this->gotExpectedValue = $expectedValue == $this->actualValue; }); $this->assertWith( function($expectedValue) { if( $this->gotExpectedValue ) { return; } $this->raiseFailure( "Expected value to equal {$expectedValue}, got {$this->actualValue}." ); }); $this->negateWith( function($expectedValue) { if( ! $this->gotExpectedValue ) { return; } $this->raiseFailure( "Expected value not to equal {$expectedValue}, got {$this->actualValue}." ); }); $this->after( function($expectedValue) { }); });
Temporary skipping a spec
To temporary skip a spec or a group of specs prepend an x
to its definition:
$spec->describe( "When searching for users", function() { $this->let( "userId", function() { return 1; }); $this->xit( "finds the user by id", function(){ $user = Users::findById( $this->userId ); $this->expect( $user ) ->not() ->to() ->beNull(); }); $this->xdescribe( "the retrieved user data", function() { $this->let( "user", function() { return Users::findById( $this->userId ); }); $this->it( "includes the name", function() { $this->expect( $this->user->getName() ) ->to() ->equal( "Lisa" ); }); $this->it( "includes the lastname", function() { $this->expect( $this->user->getLastname() ) ->to() ->equal( "Simpson" ); }); }); });
Factorizing and reusing specs behaviour
To reuse custom specs methods and properties define them in a static function of a class:
class HtmlSpecsMethods { static public function addTo($spec) { $spec->def( "navigateTo", function($requestUri) { /// ... }); $spec->def( "clickLink", function($id) { /// ... }); } }
and include the methods with:
// tests/specsBoot.php HtmlSpecsMethods::addTo( $specs );
Running the specs from the command line
Run all specs with:
php ./vendor/bin/specs
or add to the composer.json
of the project the line:
"scripts": { "specs": "php ./vendor/bin/specs" }
and then run the specs with
composer specs
Run all the specs in a single file or folder with:
composer specs tests/specs/variables-scope/variables-scope.php
Run a single spec at a line number with:
composer specs tests/specs/variables-scope/variables-scope.php:49
The line number must be in the scope of the spec.
When specs are run from the command line failures are logged with the file name and line number in the same format the runner expects to run that single spec:
To run a single failing spec copy the failing spec line from the console summary and paste it in a new command:
composer specs /home/php-specs/tests/specs/variablesScope/variablesScope.php:49
Generating a coverage report
To generate an html report of the tests code coverage follow these steps:
Install the PHP debugging tool of your preference.
Such as xdebug.
The Docker image haijin/php-dev:7.2
has xdebug installed already.
Add the php-code-coverage
package to the dev requirements of the project.
In the project composer.json
file add:
"require-dev": { ... "phpunit/php-code-coverage": "^7.0" ... },
Initialize php-code-coverage
before evaluating the specs.
In the tests/specsBoot.php
file add:
// tests/specsBoot.php declare(strict_types=1); use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Report\Html\Facade; $specs->beforeAll( function() { $this->coverage = initializeCoverageReport(); }); $specs->afterAll( function() { generateCoverageReport($this->coverage); }); function initializeCoverageReport() { $coverage = new CodeCoverage; $coverage->filter()->addDirectoryToWhitelist('src/'); $coverage->start('specsCoverage'); return $coverage; }; function generateCoverageReport($coverage) { $coverage->stop(); $writer = new Facade; $writer->process($coverage, 'coverage-report/'); };
This will leave an HTML coverage report in the project folder coverage-report/
.
Running this project tests
To run the tests of this project do:
composer specs
Or if you want to run the tests using a Docker image with PHP 7.2:
sudo docker run -ti -v $(pwd):/home/php-specs --rm --name php-specs haijin/php-dev:7.2 bash
cd /home/php-specs/
composer install
composer specs