Story BDD
PhpSpec supports Story BDD alongside Spec BDD. Write acceptance scenarios in Gherkin .feature files and implement them with step definitions.
Feature Files
Create .feature files in a features/ directory:
Feature: Greeting As a user I want to be greeted So that I feel welcome Scenario: Say hello Given a greeting service When I greet "World" Then I should see "Hello, World!"
Step Definitions
Define steps in steps.php or in *.steps.php files. They are discoverable
anywhere inside the features folder (conventionally features/steps/),
regardless of which feature path you run. An additional
directory can be searched by setting steps_path in the
configuration.
<?php given('a greeting service', function () { $this->greeter = new App\Greeter(); }); when('I greet {string}', function (string $name) { $this->result = $this->greeter->greet($name); }); then('I should see {string}', function (string $expected) { expect($this->result)->toBe($expected); });
DSL Functions
| Function | Purpose |
|---|---|
given(pattern, closure) | Register a Given step |
when(pattern, closure) | Register a When step |
then(pattern, closure) | Register a Then step |
step_and(pattern, closure) | Register an And step |
step_but(pattern, closure) | Register a But step |
PhpSpec\attach(name, text|closure) | Hand over context to report with a failure (namespaced: use function PhpSpec\attach;) |
All keywords register into the same step registry; the keyword is for readability only, matching is pattern-based.
Support Files
Helper classes for your steps live under features/support/, the Cucumber
way: every PHP file in it (and its subdirectories) is loaded before any step
definition, with no autoload configuration:
features/
support/
Server.php # class Acme\Support\Server
steps/
steps.php # use Acme\Support\Server;
A support or step file that cannot load, a syntax error or a class built on a type that does not exist yet, stops the run naming the file and the reason.
Parameter types and doc string types are functions declared there.
Step Patterns
Patterns use placeholders that capture values from the step text:
| Placeholder | Matches | PHP type |
|---|---|---|
{string} | Quoted text "..." | string |
{int} | Integer \d+ | int |
{float} | Decimal \d+.\d+ | float |
{word} | Single word \w+ | string |
{*} | Anything .+ | string |
A capture reaches the definition as text and takes the type its parameter
declares: function (int $count) receives 5, function (float $price) receives
4.5. Generated definitions declare those types.
given('there are {int} cucumbers', function (int $count) { $this->count = $count; }); when('I eat {int} cucumbers', function (int $eaten) { $this->count -= $eaten; });
Shared World
Each scenario gets a fresh StepWorld object. Steps within a scenario share state via $this:
given('I store {string} as message', function (string $msg) { $this->message = $msg; // sets $this->message on the StepWorld }); then('the message should be {string}', function (string $expected) { expect($this->message)->toBe($expected); // reads from same StepWorld });
StepWorld uses #[AllowDynamicProperties], so any property can be set dynamically.
Attaching Context
A scenario often knows things PhpSpec cannot see: a log a process is writing, the output of a command a step captured, why a polling helper gave up. Without them a failure reads Expected false to be true and the reason is somewhere you have to go and find. attach() hands them over, and they are reported with the failure:
use function PhpSpec\attach; given('the watcher is running', function () { $this->log = $this->workspace . '/watch.log'; $this->process = proc_open($cmd, [1 => ['file', $this->log, 'w']], $pipes); attach('watch log', fn() => file_get_contents($this->log)); // read at failure time attach('command', $cmd); // already final });
A closure is read when the scenario ends, so it holds what the process wrote after you attached it rather than the empty file it was then. It is read before afterScenario, so a hook that terminates the process and clears the workspace cannot empty it first. Nothing is read at all when the scenario passes, so attaching a large log costs a green run nothing. The same name twice keeps the latest.
Attachments appear under the failure in the pretty and dot formatters, and as attachments in --format=agent.
Background
Background steps run before each scenario in a feature:
Feature: Background example Background: Given a common setup Scenario: First Then the setup should be available Scenario: Second Then the setup should be available
Scenario Outline
Expand a scenario template with multiple data rows:
Feature: Cucumbers Scenario Outline: Eating Given there are <start> cucumbers When I eat <eat> cucumbers Then I should have <left> cucumbers Examples: | start | eat | left | | 12 | 5 | 7 | | 20 | 5 | 15 |
Each row in the Examples: table generates a concrete scenario with <placeholder> values substituted.
Data Tables
Pass tabular data to a step:
Scenario: User list Given the following users: | name | role | | Alice | admin | | Bob | user | Then there should be 2 users
use PhpSpec\StoryBDD\DataTable; given('the following users:', function (DataTable $table) { $this->users = $table->toArray(); // [['name' => 'Alice', 'role' => 'admin'], ['name' => 'Bob', 'role' => 'user']] });
DataTable implements ArrayAccess, Iterator, and Countable. Use $table->asClass(User::class) to hydrate rows into objects.
Doc Strings
A block of text under a step reaches the definition as a string, after the
parameters of the step's pattern:
Scenario: Posting a note Given a note titled "Opening hours": """ Closed on Sundays """
given('a note titled {string}:', function (string $title, string $note) { $this->notes[$title] = $note; });
A table is passed the same way, as a DataTable. The step definitions PhpSpec
generates for undefined steps take the table or doc string their step carries,
and import DataTable once when a step takes one.
Parameter Types
A step parameter typed with a class is made by the function marked
#[ParameterType] that returns that class. The function takes as many of the
step's captures as it has parameters, in order. Each class has one such
function.
// features/support/types.php use PhpSpec\StoryBDD\ParameterType; #[ParameterType] function book(string $author, string $isbn): Book { return new Book($author, $isbn); }
given('a book by {string} with isbn {string} costing {int}', function (Book $book, int $price) { // $book is book($author, $isbn), $price the third capture });
Doc String Types
A function marked #[DocStringType('json')] turns the doc string for the
step parameter named $json:
// features/support/types.php use PhpSpec\StoryBDD\DocStringType; #[DocStringType('json')] function json(string $text): array { return json_decode($text, true); }
given('the settings:', function (array $json) { $this->settings = $json; });
Tags
Add @tag annotations to features and scenarios:
@smoke Feature: Tagged feature @wip Scenario: Tagged scenario Given a step
Select scenarios by tag with --tags and a Cucumber tag expression; a tag on
the feature counts for every scenario in it:
$ vendor/bin/phpspec run --tags "@smoke and not @wip"
Hooks
Register lifecycle hooks for features and scenarios:
beforeFeature(function () { // Runs once before a feature starts }); beforeScenario(function () { // Runs before each scenario (on the StepWorld as $this) }); beforeStep(function () { // Runs before each step }); afterStep(function () { // Runs after each executed step (on the StepWorld as $this) }); afterScenario(function () { // Runs after each scenario (on the StepWorld as $this) }); afterFeature(function () { // Runs once after all scenarios in a feature });
The after hooks are teardown hooks: afterScenario runs even when a step in
the scenario failed, so resources are always cleaned up. afterStep runs after
every step that executed, but not for steps skipped because an earlier step
failed.
A before hook can decide for what it wraps: skip() or pending() in
beforeFeature, beforeScenario or beforeStep leaves every scenario of the
feature, that scenario, or that step skipped or pending with the reason; the
matching after hook still runs. In an after hook the step or scenario already
ran, so skip() or pending() there is an error that says so.
A hook that throws errors what it wraps, naming the hook (database down (in beforeScenario)), and the run goes on: a before hook errors the first step it
kept from running, an after hook the step that just ran. A step that had
already failed keeps its failure, with the hook's error as a warning under it.
Step States
| State | Color | Meaning |
|---|---|---|
| passed | green | Step executed successfully |
| failed | red | Step threw an exception or expectation failed |
| pending | yellow | Step calls pending(), shown with the reason it gave |
| undefined | blue | No matching step definition found |
| skipped | cyan | Step calls skip(), shown with the reason it gave, or a prior step failed |
After the first failure in a scenario, all remaining steps are skipped. A
pending or skipped step is listed under Pending: or Skipped: at the end
with its reason; a step skipped behind a failure is not, the failure is.
Running Features
$ vendor/bin/phpspec run features/ # Run all features $ vendor/bin/phpspec run features/greeting.feature # Run a specific feature
Step Generation
A scenario with an undefined step was never checked, so the run fails with exit
code 1 until the step is defined; a pending() step is work deferred on
purpose and leaves the exit code at 0. When a run finds undefined steps, in any
number of features, PhpSpec offers them once, all together, for one steps file.
With no steps file yet, the answer is a yes or a no for features/steps/steps.php:
You have undefined steps. Would you like me to generate the steps for you? [Y/n]
Once steps files exist, you pick the one the steps go to, or name a new one:
You have undefined steps. Would you like me to generate the steps for you? [0] No, skip [1] assertions.steps.php [2] web.steps.php [3] New file...
How steps are grouped is yours to decide; grouping them by what they do keeps
them reusable across features, where a steps file per feature would not. A step
already defined in any steps file is never generated again. With nobody to
answer (--no-interaction) nothing is written; --accept-offers and
vendor/bin/phpspec accept append to steps.php. The steps go to steps_path when it is
configured.
Generated steps include pending() calls so they show as pending until implemented:
given("some {int} stuff", function (int $arg1) { pending(); }); given("a user named {string}", function (string $arg1) { pending(); });
The BDD Cycle
Story BDD and Spec BDD form a full development cycle:
- Write a
.featurefile describing desired behavior - Run
vendor/bin/phpspec run features/: steps are undefined - Generate step definitions, implement them
- Steps reference classes that don't exist: PhpSpec offers to generate specs and classes
- Switch to Spec BDD: write specs, generate classes, implement
- Return to features: steps now pass
Feature (acceptance) -> Steps -> Specs (unit) -> Classes -> Green
File Conventions
| Path | Purpose |
|---|---|
features/*.feature | Gherkin feature files |
features/steps/steps.php | The default step definitions file |
features/steps/*.steps.php | Step definition files grouped as you choose |
features/**/*.steps.php | Step files in subdirectories (also scanned) |