Testing PHP Applications

Behat And Gherkin Orientation

Behat is a PHP tool for executing examples written in Gherkin. It can connect readable behaviour specifications to PHP code, HTTP calls, browser automation, command-line programs, or other test boundaries.

Behat supports behaviour-driven development, but installing Behat does not make a team practise BDD. The important work is still the conversation that produces concrete examples of valuable behaviour.

From Conversation To Executable Example

A Gherkin feature groups examples around a capability:

Feature: Free delivery
  Customers should know when their basket qualifies for free delivery.

  Scenario: Basket reaches the free-delivery threshold
    Given my basket total is 4500 pence
    When I add an eligible product costing 700 pence
    Then the delivery charge should be 0 pence

The scenario describes context, an action, and an observable outcome. It does not mention a controller method, database table, CSS selector, or framework service unless that implementation detail is itself important to the requirement.

Use And or But to make a scenario readable, not to hide several unrelated behaviours inside one scenario.

Install And Initialize Behat

Install Behat as a development dependency:

composer require --dev behat/behat
vendor/bin/behat --init
vendor/bin/behat

By default, feature files live under features/, and initialization creates a context class under features/bootstrap/.

Commit the feature files, context code, and relevant Behat configuration so the same suite can run locally and in continuous integration.

Contexts And Step Definitions

A context class contains PHP methods that connect scenario steps to test actions. Current Behat versions support PHP attributes for step definitions:

PHP example
<?php

declare(strict_types=1);

use Behat\Behat\Context\Context;
use Behat\Step\Given;
use Behat\Step\Then;
use Behat\Step\When;

final class DeliveryContext implements Context
{
    private int $basketTotalPence = 0;
    private int $deliveryChargePence = 500;

    #[Given('my basket total is :total pence')]
    public function myBasketTotalIs(string $total): void
    {
        $this->basketTotalPence = $this->pence($total);
    }

    #[When('I add an eligible product costing :price pence')]
    public function iAddAnEligibleProduct(string $price): void
    {
        $this->basketTotalPence += $this->pence($price);
        $this->deliveryChargePence = $this->basketTotalPence >= 5000 ? 0 : 500;
    }

    #[Then('the delivery charge should be :charge pence')]
    public function theDeliveryChargeShouldBe(string $charge): void
    {
        $expectedChargePence = $this->pence($charge);

        if ($this->deliveryChargePence !== $expectedChargePence) {
            throw new RuntimeException(sprintf(
                'Expected a %d pence delivery charge, got %d pence.',
                $expectedChargePence,
                $this->deliveryChargePence,
            ));
        }
    }

    private function pence(string $value): int
    {
        if (!ctype_digit($value)) {
            throw new InvalidArgumentException(sprintf(
                'Expected a non-negative whole-pence amount, got "%s".',
                $value,
            ));
        }

        return (int) $value;
    }
}

The attributes match readable step text to PHP methods. Values captured from step text arrive as strings, even when they contain digits. Cast or transform them deliberately and reject invalid values instead of relying on coercion.

This compact context stores state directly to make the execution model visible. In an application suite, the When method would normally call the real delivery service and the Then method would inspect its result. Keep the wording meaningful to the business domain. A step such as When I call calculateDeliveryFee() leaks implementation detail and makes the specification less useful during refactoring.

Choose The Test Boundary

The same business example can be checked at different boundaries:

  • call a domain service directly for fast business-rule feedback
  • send an HTTP request to verify routing, validation, and serialization
  • run a command to verify a console workflow
  • automate a browser when the rendered user journey matters

Behat does not require browser automation. Mink and browser drivers can extend it for web interaction, but running every scenario through a browser creates a slower and more fragile suite.

Prefer the narrowest boundary that proves the behaviour. Add a small number of browser scenarios for critical journeys that genuinely depend on browser-visible integration.

Organize A Growing Suite

Suites can separate contexts or application areas. Tags can identify groups such as @smoke, @browser, or @slow, and profiles can provide configuration for different environments.

These tools should express real execution needs. A tag collection that nobody understands or maintains becomes another source of accidental test gaps.

Keep context responsibilities focused. A delivery context should not gradually become a single class containing every database fixture, API client, browser selector, and assertion in the application.

Good Step Design

Use Outlines And Tables For Different Jobs

A Scenario Outline runs the same scenario once for each row in its Examples section. It is useful when several boundary examples express one rule:

Scenario Outline: Delivery charge around the threshold
  Given my eligible basket total is <starting> pence
  When I add a product costing <added> pence
  Then the delivery charge should be <charge> pence

  Examples:
    | starting | added | charge |
    | 4000     | 999   | 500    |
    | 4000     | 1000  | 0      |
    | 4000     | 1001  | 0      |

Use an outline when the action and meaning are the same and only example values change. Separate scenarios are clearer when rows would need different steps, explanations, or business outcomes.

A table attached to a step has a different purpose. It supplies one scenario with a set of records or expected values:

Given these eligible products exist:
  | sku   | price |
  | A-100 | 700   |
  | B-200 | 900   |

That table is passed to one step definition as a TableNode; it does not create two scenario executions. Keep tables small enough that a reader can understand why each row matters.

Background can hold short context genuinely shared by every scenario in a feature. It runs before every scenario, so expensive or irrelevant setup hidden there slows and obscures the whole feature. If a scenario cannot be understood without reading a long background, make its important preconditions explicit instead.

Keep Scenarios Independent

Behat creates a fresh context instance for each scenario. Instance properties can pass state between the Given, When, and Then steps within that scenario, but they should not be used to communicate with the next one.

External state still needs isolation. Database records, files, queues, mailboxes, and browser sessions do not become independent merely because the PHP context object is new. Give each scenario deterministic setup and cleanup, and never rely on feature-file order.

Hooks are appropriate for technical setup that is not part of the behaviour conversation, such as starting a transaction or collecting failure artifacts. Do not hide meaningful business preconditions in hooks; a reader should see those in the scenario.

Useful steps:

  • use stable domain language
  • describe intent rather than clicks and selectors
  • expose meaningful parameters
  • fail with diagnostic messages
  • reuse wording only when the meaning is truly the same
  • isolate test data so scenarios can run independently

Avoid highly generic steps such as Given the system is ready. They hide important state and make failures difficult to diagnose.

Do not create one step definition for every sentence by using broad regular expressions. Controlled vocabulary is valuable only when the resulting scenarios remain clear to the people discussing the behaviour.

Configuration And CI

Behat can be configured with PHP or YAML configuration, depending on the project and Behat version. Record environment-specific values outside the feature prose and do not place production secrets in test configuration.

In CI:

  1. install development dependencies
  2. prepare isolated test services and data
  3. run the fast suites first
  4. run tagged browser or slower suites where appropriate
  5. preserve useful logs, screenshots, or other failure artifacts

The command should return a non-zero status when a scenario fails so CI can block an unsafe change.

When Behat Helps

Behat is useful when examples need to be readable across development, test, and product roles, and when the team will maintain that shared language.

It may add unnecessary translation overhead for a small internal unit with no meaningful behaviour conversation. PHPUnit or Pest can be clearer for low-level class behaviour, algorithms, and implementation-focused tests.

Use Behat alongside those tools, not automatically instead of them.

What To Remember

Gherkin captures concrete examples, context classes automate them, and Behat executes the resulting specifications. Keep scenarios focused on observable behaviour, choose the cheapest reliable test boundary, and treat browser automation as one option rather than the default.

Before moving on, make sure you can explain the difference between a Gherkin scenario and its PHP step definitions, and why a readable scenario can still be backed by a fast service-level test.

Practice

Task: Design A Behat Feature

Delivery is free when an eligible basket reaches 5000 pence. Otherwise, delivery costs 500 pence.

Use one Scenario Outline with examples for:

  • one penny below the threshold;
  • exactly the threshold;
  • one penny above the threshold.

Each example must start from a concrete basket total, add a concrete product price, and state the observable delivery charge.

Then propose:

  • the responsibility of each required step definition;
  • how captured monetary strings will be validated and converted;
  • whether the outline should call a PHP service, send an HTTP request, or drive a browser;
  • how database or other external state would be isolated between example rows;
  • one tag that is useful only if it represents a real execution distinction.

Explain why a step argument table would not be equivalent to the Scenario Outline in this case. Do not describe CSS selectors, controller method names, or database tables in the feature.

Show solution
Feature: Free delivery threshold
  Customers should see the correct delivery charge for an eligible basket.

  Scenario Outline: Delivery charge around the free-delivery threshold
    Given my eligible basket total is <starting> pence
    When I add a product costing <added> pence
    Then the delivery charge should be <charge> pence

    Examples:
      | starting | added | charge |
      | 4000     | 999   | 500    |
      | 4000     | 1000  | 0      |
      | 4000     | 1001  | 0      |

Each row runs as a separate scenario example. A complete service-level context can implement the shared steps as follows:

PHP example
<?php

declare(strict_types=1);

use Behat\Behat\Context\Context;
use Behat\Step\Given;
use Behat\Step\Then;
use Behat\Step\When;

final class DeliveryContext implements Context
{
    private int $basketTotalPence = 0;
    private int $deliveryChargePence = 500;

    #[Given('my eligible basket total is :total pence')]
    public function myEligibleBasketTotalIs(string $total): void
    {
        $this->basketTotalPence = $this->pence($total);
    }

    #[When('I add a product costing :price pence')]
    public function iAddAProduct(string $price): void
    {
        $this->basketTotalPence += $this->pence($price);
        $this->deliveryChargePence = $this->basketTotalPence >= 5000 ? 0 : 500;
    }

    #[Then('the delivery charge should be :charge pence')]
    public function theDeliveryChargeShouldBe(string $charge): void
    {
        $expectedChargePence = $this->pence($charge);

        if ($this->deliveryChargePence !== $expectedChargePence) {
            throw new RuntimeException(sprintf(
                'Expected %d pence, got %d pence.',
                $expectedChargePence,
                $this->deliveryChargePence,
            ));
        }
    }

    private function pence(string $value): int
    {
        if (!ctype_digit($value)) {
            throw new InvalidArgumentException(
                sprintf('Invalid whole-pence amount: "%s".', $value),
            );
        }

        return (int) $value;
    }
}

Behat passes values captured from step text as strings, so the context validates whole non-negative digits before conversion. The Given step establishes the starting basket, the When step performs one business action, and the Then step compares an observable charge while reporting expected and actual values.

In an application suite, the When step should call the real delivery service instead of repeating the inline calculation. This rule does not require routing, JSON serialization, or browser rendering, so the service boundary gives faster and more focused feedback. Add an HTTP or browser scenario only when that boundary has distinct behaviour to prove.

Behat constructs a fresh context for every example row. If the real service uses a database, each row still needs isolated records and cleanup because database state exists outside the context object. No example may depend on another row having run first.

A table passed to one step would create one scenario containing a set of values. The outline is correct here because below, equal, and above are three independent executions of the same rule.

No tag is needed for this fast service-level outline. If a separate browser journey is later added, @browser would identify a genuine driver and CI dependency rather than serving as decorative classification.