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

FunctionPurpose
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:

PlaceholderMatchesPHP 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

StateColorMeaning
passedgreenStep executed successfully
failedredStep threw an exception or expectation failed
pendingyellowStep calls pending(), shown with the reason it gave
undefinedblueNo matching step definition found
skippedcyanStep 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:

  1. Write a .feature file describing desired behavior
  2. Run vendor/bin/phpspec run features/: steps are undefined
  3. Generate step definitions, implement them
  4. Steps reference classes that don't exist: PhpSpec offers to generate specs and classes
  5. Switch to Spec BDD: write specs, generate classes, implement
  6. Return to features: steps now pass
Feature (acceptance) -> Steps -> Specs (unit) -> Classes -> Green

File Conventions

PathPurpose
features/*.featureGherkin feature files
features/steps/steps.phpThe default step definitions file
features/steps/*.steps.phpStep definition files grouped as you choose
features/**/*.steps.phpStep files in subdirectories (also scanned)