BDD is the art of using examples in conversations to illustrate behaviour -- Liz Keogh
We are starting a new budget airline flying between London and Manchester
- Travellers can collect 1 point for every £1 they spend on flights
- 100 points can be redeemed for £10 off a future flight
- Flights are taxed at 20%
- When redeeming points do I still earn points?
- Can I redeem more than 100 points on one flight?
- Does tax apply to the discounted fare or the full fare?
If a flight from London to Manchester costs £50:
- If you pay cash it will cost £50 + £10 tax, and you will earn 50 new points
- If you pay entirely with points it will cost 500 points + £10 tax and you will earn 0 new points
- If you pay with 100 points it will cost 100 points + £40 + £10 tax and you will earn 0 new points
Feature: Earning and spending points on flights
Rules:
- Travellers can collect 1 point for every £1 they spend on flights
- 100 points can be redeemed for £10 off a future flight
Scenario: Earning points when paying cash
Given ...
Scenario: Redeeming points for a discount on a flight
Given ...
Scenario: Paying for a flight entirely using points
Given ...
- Given sets up context for a behaviour
- When specifies some action
- Then specifies some outcome
Action + Outcome = Behaviour
Scenario: Earning points when paying cash
Given a flight costs £50
When I pay with cash
Then I should pay £50 for the flight
And I should pay £10 tax
And I should get 50 points
Scenario: Redeeming points for a discount on a flight
Given a flight costs £50
When I pay with cash plus 100 points
Then I should pay £40 for the flight
And I should pay £10 tax
And I should pay 100 points
Scenario: Paying for a flight entirely using points
Given a flight costs £50
When I pay with points only
Then I should pay £0 for the flight
And I should pay £10 tax
And I should pay 500 points
Business expert Testing expert Development expert
All discussing the feature together
- Before you start work on the feature
- Not too long before!
- Whenever you have access to the right people
- When would this outcome not be true?
- What other outcomes are there?
- But what would happen if...?
- Does this implementation detail matter?
- Create a shared understanding of a feature
- Give a starting definition of done
- Provide an objective indication of how to test a feature
DDD tackles complexity by focusing the team's attention on knowledge of the domain -- Eric Evans
- A shared way of speaking about domain concepts
- Reduces the cost of translation when business and development communicate
- Try to establish and use terms the business will understand
By embedding Ubiquitous Language in your scenarios, your scenarios naturally become your domain model -- Konstantin Kudryashov (@everzet)
- The best way to understand the domain is by discussing examples
- Write scenarios that capture ubiquitous language
- Write scenarios that illustrate real situations
- Directly drive the code model from those examples
- Slow to execute
- Brittle
- Don't let you think about the code
Scenario: Earning points when paying cash
Given a flight costs £50
When I pay with cash
Then I should pay £50 for the flight
And I should pay £10 tax
And I should get 50 points
Scenario: Redeeming points for a discount on a flight
Given a flight costs £50
When I pay with cash plus 100 points
Then I should pay £40 for the flight
And I should pay £10 tax
And I should pay 100 points
Scenario: Paying for a flight entirely using points
Given a flight costs £50
When I pay with points only
Then I should pay £0 for the flight
And I should pay £10 tax
And I should pay 500 points
Background:
Given a flight from "London" to "Manchester" costs £50
Scenario: Earning points when paying cash
When I fly from "London" to "Manchester"
And I pay with cash
Then I should pay £50 for the flight
And I should pay £10 tax
And I should get 50 points
- What words do you use to talk about these things?
- Points? Paying? Cash Fly?
- Is the cost really attached to a flight?
- Do you call this thing "tax"?
- How do you think about these things?
Background:
Given a flight "XX-100" flies the "LHR" to "MAN" route
And the current listed fare for the "LHR" to "MAN" route is £50
Scenario: Earning points when paying cash
When I am issued a ticket on flight "XX-100"
And I pay £50 cash for the ticket
Then the ticket should be completely paid
And the ticket should be worth 50 loyalty points
- Price belongs to a Fare for a specific Route
- Flight is independently assigned to a Route
- Some sort of fare listing system controls Fares
- I get quoted a cost at the point I purchase a ticket
This is really useful to know!
default:
suites:
core:
contexts: [ FlightsContext ]
class FlightsContext implements Context
{
/**
* @Given a flight :arg1 flies the :arg2 to :arg3 route
*/
public function aFlightFliesTheRoute($arg1, $arg2, $arg3)
{
throw new PendingException();
}
// ...
}
class FlightsContext implements Context
{
/**
* @Given a flight :flightnumber flies the :origin to :destination route
*/
public function aFlightFliesTheRoute($flightnumber, $origin, $destination)
{
$this->flight = new Flight(
FlightNumber::fromString($flightnumber),
Route::between(
Airport::fromCode($origin),
Airport::fromCode($destination)
)
);
}
// ...
}
/**
* @Transform :flightnumber
*/
public function transformFlightNumber($number)
{
return FlightNumber::fromString($number);
}
/**
* @Transform :origin
* @Transform :destination
*/
public function transformAirport($code)
{
return Airport::fromCode($code);
}
/**
* @Given a flight :flightnumber flies the :origin to :destination route
*/
public function aFlightFliesTheRoute(
FlightNumber $flightnumber,
Airport $origin,
Airport $destination
)
{
$this->flight = new Flight(
$flightnumber, Route::between($origin, $destination)
);
}
> vendor/bin/behat
PHP Fatal error: Class 'Flight' not found
class AirportSpec extends ObjectBehavior
{
function it_can_be_represented_as_a_string()
{
$this->beConstructedFromCode('LHR');
$this->asCode()->shouldReturn('LHR');
}
function it_cannot_be_created_with_invalid_code()
{
$this->beConstructedFromCode('1234566XXX');
$this->shouldThrow(\Exception::class)->duringInstantiation();
}
}
class Airport
{
private $code;
private function __construct($code)
{
if (!preg_match('/^[A-Z]{3}$/', $code)) {
throw new \InvalidArgumentException('Code is not valid');
}
$this->code = $code;
}
public static function fromCode($code)
{
return new Airport($code);
}
public function asCode()
{
return $this->code;
}
}
/**
* @Given the current listed fare for the :arg1 to :arg2 route is £:arg3
*/
public function theCurrentListedFareForTheToRouteIsPs($arg1, $arg2, $arg3)
{
throw new PendingException();
}
interface FareList
{
public function listFare(Route $route, Fare $fare);
}
namespace Fake;
class FareList implements \FareList
{
private $fares = [];
public function listFare(\Route $route, \Fare $fare)
{
$this->fares[$route->asString()] = $fare;
}
}
/**
* @Given the current listed fare for the :origin to :destination route is £:fare
*/
public function theCurrentListedFareForTheToRouteIsPs(
Airport $origin,
Airport $destination,
Fare $fare
)
{
$this->fareList = new Fake\FareList();
$this->fareList->listFare(
Route::between($origin, $destination),
Fare::fromString($fare)
);
}
/**
* @When Iam issued a ticket on flight :arg1
*/
public function iAmIssuedATicketOnFlight($arg1)
{
throw new PendingException();
}
/**
* @When I am issued a ticket on flight :flight
*/
public function iAmIssuedATicketOnFlight()
{
$ticketIssuer = new TicketIssuer($this->fareList);
$this->ticket = $ticketIssuer->issueOn($this->flight);
}
> vendor/bin/behat
PHP Fatal error: Class 'TicketIssuer' not found
class TicketIssuerSpec extends ObjectBehavior
{
function it_can_issue_a_ticket_for_a_flight(\Flight $flight)
{
$this->issueOn($flight)->shouldHaveType(\Ticket::class);
}
}
class TicketIssuer
{
public function issueOn(Flight $flight)
{
return Ticket::costing(Fare::fromString('10000.00'));
}
}
/**
* @When I pay £:fare cash for the ticket
*/
public function iPayPsCashForTheTicket(Fare $fare)
{
$this->ticket->pay($fare);
}
PHP Fatal error: Call to undefined method Ticket::pay()
class TicketSpec extends ObjectBehavior
{
function it_can_be_paid()
{
$this->pay(\Fare::fromString("10.00"));
}
}
class Ticket
{
public function pay(Fare $fare)
{
}
}
/**
* @Then the ticket should be completely paid
*/
public function theTicketShouldBeCompletelyPaid()
{
throw new PendingException();
}
/**
* @Then the ticket should be completely paid
*/
public function theTicketShouldBeCompletelyPaid()
{
assert($this->ticket->isCompletelyPaid() == true);
}
PHP Fatal error: Call to undefined method Ticket::isCompletelyPaid()
class TicketSpec extends ObjectBehavior
{
function let()
{
$this->beConstructedCosting(\Fare::fromString("50.00"));
}
function it_is_not_completely_paid_initially()
{
$this->shouldNotBeCompletelyPaid();
}
function it_can_be_paid_completely()
{
$this->pay(\Fare::fromString("50.00"));
$this->shouldBeCompletelyPaid();
}
}
class Ticket
{
private $fare;
// ...
public function pay(Fare $fare)
{
$this->fare = $this->fare->deduct($fare);
}
public function isCompletelyPaid()
{
return $this->fare->isZero();
}
}
class FareSpec extends ObjectBehavior
{
function let()
{
$this->beConstructedFromString('100.00');
}
function it_can_deduct_an_amount()
{
$this->deduct(\Fare::fromString('10'))->shouldBeLike(\Fare::fromString('90.00'));
}
}
class Fare
{
private $pence;
private function __construct($pence)
{
$this->pence = $pence;
}
// ...
public function deduct(Fare $amount)
{
return new Fare($this->pence - $amount->pence);
}
}
class FareSpec extends ObjectBehavior
{
// ...
function it_knows_when_it_is_zero()
{
$this->beConstructedFromString('0.00');
$this->shouldBeZero();
}
function it_is_not_zero_when_it_has_a_value()
{
$this->beConstructedFromString('10.00');
$this->shouldNotBeZero();
}
}
class Fare
{
private $pence;
private function __construct($pence)
{
$this->pence = $pence;
}
// ...
public function isZero()
{
return $this->pence == 0;
}
}
class TicketIssuerSpec extends ObjectBehavior
{
function it_issues_a_ticket_with_the_correct_fare(\FareList $fareList)
{
$route = Route::between(Airport::fromCode('LHR'), Airport::fromCode('MAN'));
$flight = new Flight(FlightNumber::fromString('XX001'), $route);
$fareList->findFareFor($route)->willReturn(Fare::fromString('50'));
$this->beConstructedWith($fareList);
$this->issueOn($flight)->shouldBeLike(Ticket::costing(Fare::fromString('50')));
}
}
class TicketIssuer
{
private $fareList;
public function __construct(FareList $fareList)
{
$this->fareList = $fareList;
}
public function issueOn(Flight $flight)
{
return Ticket::costing($this->fareList->findFareFor($flight->getRoute()));
}
}
interface FareList
{
public function listFare(Route $route, Fare $fare);
public function findFareFor(Route $route);
}
class FareList implements \FareList
{
private $fares = [];
public function listFare(\Route $route, \Fare $fare)
{
$this->fares[$route->asString()] = $fare;
}
public function findFareFor(\Route $route)
{
return $this->fares[$route->asString()];
}
}
/**
* @Then I the ticket should be worth :points loyalty points
*/
public function iTheTicketShouldBeWorthLoyaltyPoints(Points $points)
{
assert($this->ticket->getPoints() == $points);
}
class FareSpec extends ObjectBehavior
{
function let()
{
$this->beConstructedFromString('100.00');
}
// ...
function it_calculates_points()
{
$this->getPoints()->shouldBeLike(\Points::fromString('100'));
}
}
class TicketSpec extends ObjectBehavior
{
function let()
{
$this->beConstructedCosting(\Fare::fromString("100.00"));
}
// ...
function it_gets_points_from_original_fare()
{
$this->pay(\Fare::fromString("50"));
$this->getPoints()->shouldBeLike(\Points::fromString('100'));
}
}
<?php
class Ticket
{
private $revenueFare;
private $fare;
private function __construct(Fare $fare)
{
$this->revenueFare = $fare;
$this->fare = $fare;
}
// ...
public function getPoints()
{
return $this->revenueFare->getPoints();
}
}
Feature: Earning and spending points on flights
Rules:
- Travellers can collect 1 point for every £1 they spend on flights
- 100 points can be redeemed for £10 off a future flight
Background:
Given a flight "XX-100" flies the "LHR" to "MAN" route
And the current listed fare for the "LHR" to "MAN" route is £50
Scenario: Earning points when paying cash
When I am issued a ticket on flight "XX-100"
And I pay £50 cash for the ticket
Then the ticket should be completely paid
And I the ticket should be worth 50 loyalty points
> bin/phpspec run -f pretty
Airport
10 ✔ can be represented as a string
16 ✔ cannot be created with invalid code
Fare
15 ✔ can deduct an amount
20 ✔ knows when it is zero
26 ✔ is not zero when it has a value
31 ✔ calculates points
FlightNumber
10 ✔ can be represented as a string
Flight
13 ✔ exposes route
Points
10 ✔ is constructed from string
Route
12 ✔ has a string representation
TicketIssuer
16 ✔ issues a ticket with the correct fare
Ticket
15 ✔ is not completely paid initially
20 ✔ is not paid completely if it is partly paid
27 ✔ can be paid completely
34 ✔ gets points from original fare
- UI tests do not have to be comprehensive
- Can focus on intractions and UX
- Actual UI code is easier to write!
default:
suites:
core:
contexts: [ FlightsContext ]
web:
contexts: [ WebFlightsContext ]
filters: { tags: @ui }
Feature: Earning and spending points on flights
Scenario: Earning points when paying cash Given ...
@ui Scenario: Redeeming points for a discount on a flight Given ...
Scenario: Paying for a flight entirely using points Given ...
- Focuses attention on use cases
- Helps developers understand core business domains
- Encourages layered architecture
- Speeds up test suites
- Project is core to your business
- You are likely to support business changes in future
- You can have conversations with stakeholders
- Not core to the business
- Prototype or short-term project
- It can be thrown away when the business changes
- You have no access to business experts (but try and change this)
- @ciaranmcnulty
- Lead Maintainer of PhpSpec
- Senior Trainer at: Inviqa / Sensio Labs UK / Session Digital / iKOS