Behat is a PHP framework for Behavior-Driven Development (BDD) that uses human-readable Gherkin syntax to describe application behavior. It bridges the gap between business requirements and automated tests, making specifications executable. This guide covers implementing BDD effectively from a senior developer's perspective.
Why BDD with Behat
Behat offers significant advantages:
- Living Documentation: Tests serve as up-to-date specifications
- Stakeholder Communication: Non-technical people can read and write scenarios
- Business-First Testing: Focus on behavior, not implementation
- API and UI Testing: Test at any layer of your application
- PHP Integration: Native PHP, works with Laravel, Symfony, and others
Installation and Setup
Install via Composer
composer require --dev behat/behat
composer require --dev guzzlehttp/guzzle # For API testing
composer require --dev behat/mink behat/mink-extension # For browser testing
Initialize Project
vendor/bin/behat --init
This creates:
features/ - Directory for feature filesfeatures/bootstrap/ - Directory for context classesfeatures/bootstrap/FeatureContext.php - Default context
Configuration
Create behat.yml:
default:
suites:
default:
contexts:
- FeatureContext
- ApiContext
- WebContext
extensions:
Behat\MinkExtension:
base_url: http://localhost:8000
sessions:
default:
goutte: ~
Gherkin Syntax
Feature Files
Create features/user-registration.feature:
Feature: User Registration
As a visitor
I want to create an account
So that I can access member features
Background:
Given the system is running
And no user exists with email "[email protected]"
Scenario: Successful registration with valid data
Given I am on the registration page
When I fill in "name" with "John Doe"
And I fill in "email" with "[email protected]"
And I fill in "password" with "SecurePass123!"
And I fill in "password_confirmation" with "SecurePass123!"
And I press "Register"
Then I should see "Welcome, John Doe"
And I should be logged in
Scenario: Registration fails with invalid email
Given I am on the registration page
When I fill in "email" with "invalid-email"
And I press "Register"
Then I should see "Please enter a valid email address"
And I should not be logged in
Scenario Outline: Password validation
Given I am on the registration page
When I fill in "password" with "<password>"
And I press "Register"
Then I should see "<error_message>"
Examples:
| password | error_message |
| short | Password must be at least 8 chars |
| nodigits | Password must contain a number |
| 12345678 | Password must contain a letter |
Gherkin Keywords
- Feature: Describes the feature being tested
- Background: Steps run before each scenario
- Scenario: A specific test case
- Scenario Outline: Template for data-driven tests
- Examples: Data table for Scenario Outline
- Given: Preconditions (setup)
- When: Actions (what user does)
- Then: Assertions (expected outcomes)
- And/But: Continue previous step type
Implementing Step Definitions
Context Classes
Edit features/bootstrap/FeatureContext.php:
<?php
use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
class FeatureContext implements Context
{
private $response;
private $currentUser;
/**
* @Given the system is running
*/
public function theSystemIsRunning()
{
// Verify the application is accessible
$client = new GuzzleHttp\Client();
$response = $client->get('http://localhost:8000/health');
if ($response->getStatusCode() !== 200) {
throw new Exception("System is not running");
}
}
/**
* @Given no user exists with email :email
*/
public function noUserExistsWithEmail($email)
{
// Clean up test data
$this->deleteUserByEmail($email);
}
/**
* @When I fill in :field with :value
*/
public function iFillInWith($field, $value)
{
$this->formData[$field] = $value;
}
/**
* @Then I should see :text
*/
public function iShouldSee($text)
{
if (strpos($this->response->getBody(), $text) === false) {
throw new Exception("Text '$text' not found in response");
}
}
/**
* @Then I should be logged in
*/
public function iShouldBeLoggedIn()
{
if (!isset($this->currentUser)) {
throw new Exception("User is not logged in");
}
}
}
API Testing Context
Create features/bootstrap/ApiContext.php:
<?php
use Behat\Behat\Context\Context;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
class ApiContext implements Context
{
private Client $client;
private $response;
private array $headers = [];
private $requestBody;
public function __construct()
{
$this->client = new Client([
'base_uri' => 'http://localhost:8000',
'http_errors' => false,
]);
}
/**
* @Given I am an unauthenticated user
*/
public function iAmAnUnauthenticatedUser()
{
unset($this->headers['Authorization']);
}
/**
* @Given I am authenticated as :email
*/
public function iAmAuthenticatedAs($email)
{
// Log in and get token
$response = $this->client->post('/api/login', [
'json' => [
'email' => $email,
'password' => 'password',
],
]);
$data = json_decode($response->getBody(), true);
$this->headers['Authorization'] = 'Bearer ' . $data['token'];
}
/**
* @When I send a :method request to :endpoint
*/
public function iSendARequestTo($method, $endpoint)
{
$options = ['headers' => $this->headers];
if ($this->requestBody) {
$options['json'] = $this->requestBody;
}
$this->response = $this->client->request($method, $endpoint, $options);
$this->requestBody = null;
}
/**
* @When I send a :method request to :endpoint with body:
*/
public function iSendARequestToWithBody($method, $endpoint, PyStringNode $body)
{
$this->requestBody = json_decode($body->getRaw(), true);
$this->iSendARequestTo($method, $endpoint);
}
/**
* @Then the response status code should be :code
*/
public function theResponseStatusCodeShouldBe($code)
{
$actual = $this->response->getStatusCode();
if ($actual !== (int) $code) {
throw new Exception("Expected status $code, got $actual");
}
}
/**
* @Then the response should contain :text
*/
public function theResponseShouldContain($text)
{
$body = $this->response->getBody()->getContents();
if (strpos($body, $text) === false) {
throw new Exception("Response does not contain '$text'");
}
}
/**
* @Then the response should be JSON containing:
*/
public function theResponseShouldBeJsonContaining(PyStringNode $expected)
{
$actual = json_decode($this->response->getBody(), true);
$expected = json_decode($expected->getRaw(), true);
foreach ($expected as $key => $value) {
if (!isset($actual[$key]) || $actual[$key] !== $value) {
throw new Exception("JSON mismatch for key '$key'");
}
}
}
/**
* @Then the response should include an announcement with ID :id
*/
public function theResponseShouldIncludeAnAnnouncementWithId($id)
{
$data = json_decode($this->response->getBody(), true);
$announcements = $data['_embedded']['announcement'] ?? [];
foreach ($announcements as $announcement) {
if ($announcement['id'] == $id) {
return;
}
}
throw new Exception("Announcement with ID $id not found");
}
}
API Feature Example
Create features/api/announcements.feature:
Feature: Announcements API
As an API consumer
I want to manage announcements
So that I can display them to users
Scenario: List all announcements
Given I am an unauthenticated user
When I send a "GET" request to "/api/announcements"
Then the response status code should be 200
And the response should include an announcement with ID "12345"
Scenario: Create announcement requires authentication
Given I am an unauthenticated user
When I send a "POST" request to "/api/announcements" with body:
"""
{
"title": "New Feature",
"body": "We launched something amazing!"
}
"""
Then the response status code should be 401
Scenario: Authenticated user can create announcement
Given I am authenticated as "[email protected]"
When I send a "POST" request to "/api/announcements" with body:
"""
{
"title": "New Feature",
"body": "We launched something amazing!"
}
"""
Then the response status code should be 201
And the response should be JSON containing:
"""
{
"title": "New Feature"
}
"""
Web Testing with Mink
Web Context
Create features/bootstrap/WebContext.php:
<?php
use Behat\MinkExtension\Context\MinkContext;
class WebContext extends MinkContext
{
/**
* @Given I am logged in as :email
*/
public function iAmLoggedInAs($email)
{
$this->visit('/login');
$this->fillField('email', $email);
$this->fillField('password', 'password');
$this->pressButton('Login');
$this->assertPageContainsText('Dashboard');
}
/**
* @When I click on the :link link in the navigation
*/
public function iClickOnTheLinkInTheNavigation($link)
{
$this->getSession()
->getPage()
->find('css', 'nav')
->clickLink($link);
}
/**
* @Then the page title should be :title
*/
public function thePageTitleShouldBe($title)
{
$actual = $this->getSession()
->getPage()
->find('css', 'title')
->getText();
if ($actual !== $title) {
throw new Exception("Expected title '$title', got '$actual'");
}
}
/**
* @Then I should see :count items in the list
*/
public function iShouldSeeItemsInTheList($count)
{
$items = $this->getSession()
->getPage()
->findAll('css', '.list-item');
if (count($items) !== (int) $count) {
throw new Exception("Expected $count items, found " . count($items));
}
}
}
Web Scraping with Goutte
<?php
use Behat\Behat\Context\Context;
use Goutte\Client;
class ScrapingContext implements Context
{
private Client $client;
private $crawler;
public function __construct()
{
$this->client = new Client();
}
/**
* @Given I open the page :url
*/
public function iOpenThePage($url)
{
$this->crawler = $this->client->request('GET', $url);
}
/**
* @When I click the link :text
*/
public function iClickTheLink($text)
{
$link = $this->crawler->selectLink($text)->link();
$this->crawler = $this->client->click($link);
}
/**
* @Then the page heading should be :heading
*/
public function thePageHeadingShouldBe($heading)
{
$actual = trim($this->crawler->filter('h1')->first()->text());
if ($actual !== $heading) {
throw new Exception("Expected h1 '$heading', got '$actual'");
}
}
}
Running Tests
Basic Commands
# Run all features
vendor/bin/behat
# Run a specific feature
vendor/bin/behat features/user-registration.feature
# Run a specific scenario by line number
vendor/bin/behat features/user-registration.feature:15
# Run with a specific tag
vendor/bin/behat --tags="@api"
# Dry run (check step definitions without executing)
vendor/bin/behat --dry-run
# Generate missing step definitions
vendor/bin/behat --append-snippets
Using Tags
Tag scenarios for organization:
@api @authentication
Feature: Authentication API
@smoke
Scenario: Valid login returns token
...
@slow
Scenario: Rate limiting after failed attempts
...
Run tagged scenarios:
vendor/bin/behat --tags="@api"
vendor/bin/behat --tags="@smoke"
vendor/bin/behat --tags="~@slow" # Exclude slow tests
CI Integration
GitHub Actions
name: BDD Tests
on: [push, pull_request]
jobs:
behat:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: testing
ports:
- 3306:3306
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: pdo_mysql
- name: Install dependencies
run: composer install
- name: Start application
run: php artisan serve &
- name: Run Behat
run: vendor/bin/behat --format=progress
Key Takeaways
- Write scenarios first: Define behavior before implementation
- Use domain language: Scenarios should read like requirements
- Keep steps reusable: Build a library of step definitions
- Separate concerns: Use different contexts for API, web, and database
- Tag strategically: Organize tests for selective execution
- Integrate with CI: Run BDD tests on every commit
Behat transforms specifications into executable tests, ensuring your application behaves exactly as stakeholders expect while creating living documentation that stays current.