1. php
  2. /testing
  3. /bdd

Behavior-Driven Development (BDD) in PHP

Introduction to Behavior-Driven Development

Behavior-Driven Development (BDD) is a software development approach that combines Test-Driven Development (TDD) with ideas from Domain-Driven Design and object-oriented analysis. BDD focuses on the behavior of an application for business users.

BDD vs TDD

Traditional TDD

<?php
// TDD Test Example
class CalculatorTest extends PHPUnit\Framework\TestCase
{
    public function testAddition()
    {
        $calculator = new Calculator();
        $result = $calculator->add(2, 3);
        $this->assertEquals(5, $result);
    }
}
?>

BDD Approach

# BDD Feature Example
Feature: Calculator
  As a user
  I want to perform mathematical operations
  So that I can solve calculations

  Scenario: Adding two numbers
    Given I have a calculator
    When I add 2 and 3
    Then I should get 5

Gherkin Language

Gherkin is a business-readable, domain-specific language that describes software behavior without detailing implementation.

Basic Gherkin Syntax

Feature: User Authentication
  As a registered user
  I want to log into the system
  So that I can access my account

  Background:
    Given there is a user account with email "[email protected]" and password "secret123"

  Scenario: Successful login
    Given I am on the login page
    When I enter "[email protected]" as email
    And I enter "secret123" as password
    And I click the login button
    Then I should be redirected to the dashboard
    And I should see "Welcome, John" message

  Scenario: Failed login with wrong password
    Given I am on the login page
    When I enter "[email protected]" as email
    And I enter "wrongpassword" as password
    And I click the login button
    Then I should stay on the login page
    And I should see "Invalid credentials" error message

  Scenario Outline: Multiple login attempts
    Given I am on the login page
    When I enter "<email>" as email
    And I enter "<password>" as password
    And I click the login button
    Then I should see "<message>"

    Examples:
      | email               | password    | message             |
      | [email protected]    | secret123   | Welcome, John       |
      | [email protected]    | wrong       | Invalid credentials |
      | [email protected] | secret123   | User not found      |

Behat Framework

Behat is the most popular BDD framework for PHP that executes Gherkin scenarios.

Installation and Setup

# Install Behat via Composer
composer require --dev behat/behat

# Initialize Behat
vendor/bin/behat --init

# Run tests
vendor/bin/behat

Directory Structure

features/
├── bootstrap/
│   └── FeatureContext.php
├── user_authentication.feature
└── user_registration.feature
behat.yml

Configuration

# behat.yml
default:
  suites:
    default:
      contexts:
        - FeatureContext
        - UserContext
        - DatabaseContext
  extensions:
    Behat\MinkExtension:
      default_session: 'goutte'
      sessions:
        goutte:
          goutte: ~
        selenium2:
          selenium2:
            browser: chrome
            wd_host: 'http://localhost:4444/wd/hub'

Writing Step Definitions

Basic Context Class

<?php
// features/bootstrap/FeatureContext.php
use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
use PHPUnit\Framework\Assert;

class FeatureContext implements Context
{
    private $calculator;
    private $result;

    /**
     * @Given I have a calculator
     */
    public function iHaveACalculator()
    {
        $this->calculator = new Calculator();
    }

    /**
     * @When I add :num1 and :num2
     */
    public function iAddAnd($num1, $num2)
    {
        $this->result = $this->calculator->add((int)$num1, (int)$num2);
    }

    /**
     * @Then I should get :expected
     */
    public function iShouldGet($expected)
    {
        Assert::assertEquals((int)$expected, $this->result);
    }
}
?>

Web Application Context

<?php
// features/bootstrap/WebContext.php
use Behat\Behat\Context\Context;
use Behat\MinkExtension\Context\MinkContext;

class WebContext extends MinkContext implements Context
{
    private $baseUrl = 'http://localhost:8000';

    /**
     * @Given I am on the login page
     */
    public function iAmOnTheLoginPage()
    {
        $this->visitPath('/login');
    }

    /**
     * @When I enter :value as email
     */
    public function iEnterAsEmail($value)
    {
        $this->fillField('email', $value);
    }

    /**
     * @When I enter :value as password
     */
    public function iEnterAsPassword($value)
    {
        $this->fillField('password', $value);
    }

    /**
     * @When I click the login button
     */
    public function iClickTheLoginButton()
    {
        $this->pressButton('Login');
    }

    /**
     * @Then I should be redirected to the dashboard
     */
    public function iShouldBeRedirectedToTheDashboard()
    {
        $this->assertPageAddress('/dashboard');
    }

    /**
     * @Then I should see :message message
     */
    public function iShouldSeeMessage($message)
    {
        $this->assertPageContainsText($message);
    }

    /**
     * @Then I should stay on the login page
     */
    public function iShouldStayOnTheLoginPage()
    {
        $this->assertPageAddress('/login');
    }

    /**
     * @Then I should see :message error message
     */
    public function iShouldSeeErrorMessage($message)
    {
        $this->assertElementContainsText('.error', $message);
    }
}
?>

Database Context

<?php
// features/bootstrap/DatabaseContext.php
use Behat\Behat\Context\Context;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Behat\Hook\Scope\AfterScenarioScope;

class DatabaseContext implements Context
{
    private $pdo;
    private $users = [];

    public function __construct()
    {
        $this->pdo = new PDO('sqlite::memory:');
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->setupDatabase();
    }

    /**
     * @BeforeScenario
     */
    public function setUp(BeforeScenarioScope $scope)
    {
        $this->cleanDatabase();
    }

    /**
     * @AfterScenario
     */
    public function tearDown(AfterScenarioScope $scope)
    {
        $this->cleanDatabase();
    }

    /**
     * @Given there is a user account with email :email and password :password
     */
    public function thereIsAUserAccountWithEmailAndPassword($email, $password)
    {
        $hashedPassword = password_hash($password, PASSWORD_DEFAULT);
        
        $stmt = $this->pdo->prepare(
            'INSERT INTO users (email, password, name) VALUES (?, ?, ?)'
        );
        $stmt->execute([$email, $hashedPassword, 'John Doe']);
    }

    /**
     * @Given the following users exist:
     */
    public function theFollowingUsersExist(TableNode $table)
    {
        foreach ($table->getColumnsHash() as $row) {
            $hashedPassword = password_hash($row['password'], PASSWORD_DEFAULT);
            
            $stmt = $this->pdo->prepare(
                'INSERT INTO users (email, password, name) VALUES (?, ?, ?)'
            );
            $stmt->execute([$row['email'], $hashedPassword, $row['name']]);
        }
    }

    private function setupDatabase()
    {
        $this->pdo->exec('
            CREATE TABLE users (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                email VARCHAR(255) UNIQUE,
                password VARCHAR(255),
                name VARCHAR(255),
                created_at DATETIME DEFAULT CURRENT_TIMESTAMP
            )
        ');
    }

    private function cleanDatabase()
    {
        $this->pdo->exec('DELETE FROM users');
    }

    public function getPdo()
    {
        return $this->pdo;
    }
}
?>

Advanced BDD Patterns

Page Object Pattern with BDD

<?php
// features/bootstrap/PageObjects/LoginPage.php
class LoginPage
{
    private $session;

    public function __construct($session)
    {
        $this->session = $session;
    }

    public function visit()
    {
        $this->session->visit('/login');
    }

    public function fillEmail($email)
    {
        $this->session->getPage()->fillField('email', $email);
    }

    public function fillPassword($password)
    {
        $this->session->getPage()->fillField('password', $password);
    }

    public function clickLogin()
    {
        $this->session->getPage()->pressButton('Login');
    }

    public function getErrorMessage()
    {
        $errorElement = $this->session->getPage()->find('css', '.error');
        return $errorElement ? $errorElement->getText() : null;
    }

    public function isOnLoginPage()
    {
        return $this->session->getCurrentUrl() === '/login';
    }
}

// Updated WebContext using Page Objects
class WebContext extends MinkContext implements Context
{
    private $loginPage;

    public function __construct()
    {
        $this->loginPage = new LoginPage($this->getSession());
    }

    /**
     * @Given I am on the login page
     */
    public function iAmOnTheLoginPage()
    {
        $this->loginPage->visit();
    }

    /**
     * @When I enter :email as email
     */
    public function iEnterAsEmail($email)
    {
        $this->loginPage->fillEmail($email);
    }

    /**
     * @When I enter :password as password
     */
    public function iEnterAsPassword($password)
    {
        $this->loginPage->fillPassword($password);
    }

    /**
     * @When I click the login button
     */
    public function iClickTheLoginButton()
    {
        $this->loginPage->clickLogin();
    }

    /**
     * @Then I should see :message error message
     */
    public function iShouldSeeErrorMessage($message)
    {
        $actualMessage = $this->loginPage->getErrorMessage();
        PHPUnit\Framework\Assert::assertEquals($message, $actualMessage);
    }
}
?>

API Testing with BDD

<?php
// features/bootstrap/ApiContext.php
use Behat\Behat\Context\Context;
use GuzzleHttp\Client;
use PHPUnit\Framework\Assert;

class ApiContext implements Context
{
    private $client;
    private $response;
    private $requestData;

    public function __construct()
    {
        $this->client = new Client(['base_uri' => 'http://localhost:8000/api/']);
    }

    /**
     * @Given I have the following JSON data:
     */
    public function iHaveTheFollowingJsonData(PyStringNode $jsonData)
    {
        $this->requestData = json_decode($jsonData->getRaw(), true);
    }

    /**
     * @When I send a :method request to :endpoint
     */
    public function iSendARequestTo($method, $endpoint)
    {
        $options = [];
        
        if ($this->requestData) {
            $options['json'] = $this->requestData;
        }

        $this->response = $this->client->request($method, $endpoint, $options);
    }

    /**
     * @Then the response status code should be :statusCode
     */
    public function theResponseStatusCodeShouldBe($statusCode)
    {
        Assert::assertEquals((int)$statusCode, $this->response->getStatusCode());
    }

    /**
     * @Then the response should contain JSON:
     */
    public function theResponseShouldContainJson(PyStringNode $expectedJson)
    {
        $actualData = json_decode($this->response->getBody(), true);
        $expectedData = json_decode($expectedJson->getRaw(), true);
        
        Assert::assertEquals($expectedData, $actualData);
    }

    /**
     * @Then the JSON response should have :key with value :value
     */
    public function theJsonResponseShouldHaveWithValue($key, $value)
    {
        $responseData = json_decode($this->response->getBody(), true);
        
        Assert::assertArrayHasKey($key, $responseData);
        Assert::assertEquals($value, $responseData[$key]);
    }
}
?>

API Feature Example

# features/api_user_management.feature
Feature: User Management API
  As an API client
  I want to manage users through the API
  So that I can integrate with the user system

  Background:
    Given the API is available

  Scenario: Create a new user
    Given I have the following JSON data:
      """
      {
        "name": "John Doe",
        "email": "[email protected]",
        "password": "secret123"
      }
      """
    When I send a "POST" request to "users"
    Then the response status code should be 201
    And the JSON response should have "id" with value "1"
    And the JSON response should have "email" with value "[email protected]"

  Scenario: Get user by ID
    Given there is a user with ID 1 and email "[email protected]"
    When I send a "GET" request to "users/1"
    Then the response status code should be 200
    And the response should contain JSON:
      """
      {
        "id": 1,
        "name": "John Doe",
        "email": "[email protected]"
      }
      """

  Scenario: Update user information
    Given there is a user with ID 1
    And I have the following JSON data:
      """
      {
        "name": "John Smith",
        "email": "[email protected]"
      }
      """
    When I send a "PUT" request to "users/1"
    Then the response status code should be 200
    And the JSON response should have "name" with value "John Smith"

  Scenario: Delete a user
    Given there is a user with ID 1
    When I send a "DELETE" request to "users/1"
    Then the response status code should be 204

  Scenario: Handle non-existent user
    When I send a "GET" request to "users/999"
    Then the response status code should be 404
    And the JSON response should have "error" with value "User not found"

Domain-Specific Languages

Custom Step Definitions

<?php
// features/bootstrap/EcommerceContext.php
use Behat\Behat\Context\Context;

class EcommerceContext implements Context
{
    private $cart;
    private $products = [];
    private $total;

    /**
     * @Given the following products are available:
     */
    public function theFollowingProductsAreAvailable(TableNode $table)
    {
        foreach ($table->getColumnsHash() as $row) {
            $this->products[$row['name']] = [
                'name' => $row['name'],
                'price' => (float)$row['price'],
                'stock' => (int)$row['stock']
            ];
        }
    }

    /**
     * @Given I have an empty shopping cart
     */
    public function iHaveAnEmptyShoppingCart()
    {
        $this->cart = new ShoppingCart();
    }

    /**
     * @When I add :quantity of :productName to my cart
     */
    public function iAddOfToMyCart($quantity, $productName)
    {
        if (!isset($this->products[$productName])) {
            throw new Exception("Product {$productName} not available");
        }

        $product = $this->products[$productName];
        $this->cart->addItem($product, (int)$quantity);
    }

    /**
     * @When I apply discount code :code
     */
    public function iApplyDiscountCode($code)
    {
        $this->cart->applyDiscountCode($code);
    }

    /**
     * @When I proceed to checkout
     */
    public function iProceedToCheckout()
    {
        $this->total = $this->cart->getTotal();
    }

    /**
     * @Then my cart should contain :quantity items
     */
    public function myCartShouldContainItems($quantity)
    {
        Assert::assertEquals((int)$quantity, $this->cart->getItemCount());
    }

    /**
     * @Then the total should be :amount
     */
    public function theTotalShouldBe($amount)
    {
        Assert::assertEquals((float)$amount, $this->total);
    }

    /**
     * @Then I should see :message
     */
    public function iShouldSee($message)
    {
        // Implementation depends on your UI testing approach
        Assert::assertTrue($this->cart->hasMessage($message));
    }
}
?>

E-commerce Feature

# features/shopping_cart.feature
Feature: Shopping Cart
  As a customer
  I want to add products to my cart
  So that I can purchase them

  Background:
    Given the following products are available:
      | name      | price | stock |
      | Laptop    | 999.99| 5     |
      | Mouse     | 29.99 | 20    |
      | Keyboard  | 79.99 | 15    |
    And I have an empty shopping cart

  Scenario: Add single item to cart
    When I add 1 of "Laptop" to my cart
    Then my cart should contain 1 items

  Scenario: Add multiple items to cart
    When I add 2 of "Mouse" to my cart
    And I add 1 of "Keyboard" to my cart
    Then my cart should contain 3 items

  Scenario: Calculate cart total
    When I add 1 of "Laptop" to my cart
    And I add 2 of "Mouse" to my cart
    And I proceed to checkout
    Then the total should be 1059.97

  Scenario: Apply discount code
    Given I add 1 of "Laptop" to my cart
    When I apply discount code "SAVE10"
    And I proceed to checkout
    Then the total should be 899.99
    And I should see "Discount applied: 10% off"

  Scenario: Handle out of stock items
    When I add 10 of "Laptop" to my cart
    Then I should see "Insufficient stock for Laptop"
    And my cart should contain 0 items

BDD Best Practices

Writing Good Scenarios

# Good: Clear, focused scenario
Scenario: User successfully resets password
  Given I am a registered user with email "[email protected]"
  And I am on the forgot password page
  When I enter my email address
  And I click the "Send Reset Link" button
  Then I should see "Reset link sent to your email"
  And I should receive a password reset email

# Bad: Too many details, testing multiple things
Scenario: Complete user management workflow
  Given I am an admin user
  When I go to the users page
  And I click "Add User"
  And I fill in the user form with valid data
  And I save the user
  Then the user should be created
  And I should see the user in the list
  When I click "Edit" for the user
  And I change the user's name
  And I save the changes
  Then the user's name should be updated
  # ... continues testing deletion, etc.

Parameterized Scenarios

Feature: User Input Validation

  Scenario Outline: Validate email format
    Given I am on the registration page
    When I enter "<email>" as email
    And I submit the form
    Then I should see "<result>"

    Examples:
      | email               | result                    |
      | [email protected]   | Registration successful   |
      | invalid.email       | Invalid email format      |
      | @example.com        | Invalid email format      |
      | test@              | Invalid email format      |
      |                     | Email is required         |

  Scenario Outline: Password strength validation
    Given I am on the registration page
    When I enter "<password>" as password
    And I submit the form
    Then I should see "<message>"

    Examples:
      | password    | message                              |
      | Password123!| Registration successful              |
      | pass        | Password must be at least 8 characters |
      | password    | Password must contain a number       |
      | PASSWORD123 | Password must contain a lowercase letter |
      | password123 | Password must contain an uppercase letter |

Background vs. Scenario Setup

# Good: Use Background for common setup
Feature: Blog Management

  Background:
    Given I am logged in as an admin
    And there are the following blog posts:
      | title           | status    | author |
      | First Post      | published | admin  |
      | Second Post     | draft     | admin  |
      | Third Post      | published | editor |

  Scenario: View all posts
    When I go to the admin posts page
    Then I should see 3 posts

  Scenario: Filter by status
    Given I am on the admin posts page
    When I filter by "published" status
    Then I should see 2 posts

# Bad: Repeating setup in every scenario
Feature: Blog Management

  Scenario: View all posts
    Given I am logged in as an admin
    And there are 3 blog posts
    When I go to the admin posts page
    Then I should see 3 posts

  Scenario: Filter by status
    Given I am logged in as an admin
    And there are 3 blog posts with different statuses
    When I go to the admin posts page
    And I filter by "published" status
    Then I should see 2 posts

BDD Tools Ecosystem

Behat Extensions

# Mink Extension for web testing
composer require --dev behat/mink-extension
composer require --dev behat/mink-goutte-driver
composer require --dev behat/mink-selenium2-driver

# Symfony Integration
composer require --dev friends-of-behat/symfony-extension

# API Testing
composer require --dev guzzlehttp/guzzle

Configuration for Multiple Environments

# behat.yml
default:
  suites:
    web:
      contexts:
        - WebContext
        - DatabaseContext
      filters:
        tags: '@web'
    api:
      contexts:
        - ApiContext
        - DatabaseContext
      filters:
        tags: '@api'

  extensions:
    Behat\MinkExtension:
      default_session: 'goutte'
      sessions:
        goutte:
          goutte: ~

ci:
  extensions:
    Behat\MinkExtension:
      default_session: 'selenium2'
      sessions:
        selenium2:
          selenium2:
            browser: chrome
            wd_host: 'http://selenium:4444/wd/hub'

BDD helps bridge the gap between business requirements and technical implementation by using natural language specifications that both technical and non-technical stakeholders can understand and contribute to.