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.