Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Practicing BDD: Plan, then Build

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

One of the most beautiful things about Behat is having the opportunity to do behavior driven development. This is when you write the feature first, the scenarios second and then you code it. You plan the behavior, and then make it come to life.

So far... we haven't really been doing that. We have a existing site and we've been writing features and scenarios to describe how it already behaves. That's ok, and sometimes you'll do that in your real development life. But now it's time to do BDD correctly.

Step 1: Describe the Scenario

In the "Add a new product" scenario we're describing a feature that does not exist on the site yet. We planned this scenario earlier by planning - basically imagining - what the best behavior should be. And oops, I just noticed a typo. This should be:

... lines 1 - 22
When I click "New Product"
... lines 24 - 29

Step 2: Execute Behat

With that fixed, let's start to bring this feature to life by running just this scenario:

./vendor/bin/behat features/product_admin.feature:19

Our mission is clear: code just enough to fix a failure, then re-execute Behat and repeat until it's all green. The first failure is from "When I click 'New Product'". That makes sense: that link doesn't exist. But there is something else going on too. Add an "And print last response" line and try again:

./vendor/bin/behat features/product_admin.feature:19

Of course: we also haven't logged in yet. Copy that line from the other scenario:

... lines 1 - 20
Given I am logged in as an admin
And I am on "/admin/products"
... lines 23 - 29

We didn't think of this during the design phase, but clearly we need to be logged in. Try things again:

./vendor/bin/behat features/product_admin.feature:19

Step 3: Add (a little) Code to make the Step Pass

Same failure, but now for the right reason: we're missing that link. Time to add it.

Open up the template for this page - list.html.twig. A link would look real nice up top. Don't add the href yet: just put in the text "New Product" and make it look nice with some CSS classes and a little icon:

... lines 1 - 12
<a href="" class="btn btn-primary pull-right">
<span class="fa fa-plus"></span> New Product
... lines 16 - 67

Other than some easy-win styling, I want to do as little work as possible to get each step of the scenario to pass.

Refresh: there's the button. It doesn't go anywhere yet, try it:

./vendor/bin/behat features/product_admin.feature:19

Step 4: Repeat until Green!

This time, it did click "New Product", but fails because it doesn't see any fields called "Name". No surprise: the link doesn't have an href and we don't even have a new products page.

To get this step to pass, I guess we need to create that. In ProductAdminController, make a new public function newAction() and set its URL to /admin/products/new. Name the route product_new so we can link to it. Inside the method, render a template called product/new.html.twig:

... lines 1 - 26
* @Route("/admin/products/new", name="product_new")
public function newAction()
return $this->render('product/new.html.twig');
... lines 34 - 35

Easy enough!

In the product/ directory create that template: new.html.twig. Extend the base layout - layout.html.twig - and add add a body block. Add a form tag and make it submit right back to this same URL with method="POST":

{% extends 'layout.html.twig' %}
{% block body %}
<form action="{{ path('product_new') }}" method="POST">
... lines 5 - 22
{% endblock %}

I am not going to use Symfony's form system for this because (a) we don't have to and (b) my only goal is to get these tests passing. If you did want to use Symfony's form system, this is when you would start doing that!

To keep this moving, I'll paste in a bunch of code that creates the three fields we're referencing in the scenario: Name, Price and Description:

... lines 1 - 4
<legend>New Product</legend>
<div class="form-group">
<label for="article-name">Name</label>
<input type="text" class="form-control" id="article-name" name="name" />
<div class="form-group">
<label for="article-price">Price</label>
<input type="text" class="form-control" id="article-price" name="price" />
<div class="form-group">
<label for="article-body">Description</label>
<textarea class="form-control" id="article-body" name="description"></textarea>
<button type="submit" class="btn btn-primary">Save</button>
... lines 23 - 25

The important thing is that those names match up with the label text and that each label has a for attribute that points to the id of its field. This is how Mink can find the label by text and then find its field.

At the bottom, we have a save button that matches the second to last step:

... lines 1 - 19
Scenario: Add a new product
... lines 21 - 26
And I press "Save"
... lines 28 - 29

Ok, try running the scenario again!

./vendor/bin/behat features/product_admin.feature:19

It's still failing in the same spot. This might seem weird, but if you debug this, you'll see that I forgot to fill in the "New Products" href:

... lines 1 - 3
<form action="{{ path('product_new') }}" method="POST">
... lines 5 - 22
... lines 24 - 25

My bad!

Run Behat again:

./vendor/bin/behat features/product_admin.feature:19

We got further - it filled out and submitted the form, but didn't see the "Product Created FTW!" flash message. Time to add form processing logic.

Add Symfony's Request object as an argument with a type hint. Inside of newAction() add a simple if ($request->isMethod('POST')):

... lines 1 - 29
public function newAction(Request $request)
if ($request->isMethod('POST')) {
... lines 33 - 35
... lines 37 - 38
... lines 40 - 41

To be super lazy, what if we cheated by not saving the product and only showing that flash message? The site already has some flash messaging functionality, so add the message that the step is looking for: $this->addFlash('success', 'Product created FTW!'). Finish by redirecting the user to the product page:

... lines 1 - 31
if ($request->isMethod('POST')) {
$this->addFlash('success', 'Product created FTW!');
return $this->redirectToRoute('product_list');
... lines 37 - 41

Run Behat:

./vendor/bin/behat features/product_admin.feature:19

BDD for Fixing Bugs

It passes, even though the product isn't saved. Ok, don't be a big jerk and make your tests purposefully pass like this without coding the real functionality. But sometimes, you'll write a scenario and discover later that there's a bug because you forgot to test one part of the behavior. In this case I would improve my scenario before fixing the bug by adding:

... lines 1 - 19
Scenario: Add a new product
... lines 21 - 28
And I should see "Veloci-chew toy"

With this, the scenario won't pass unless the product actually shows up on the product list. This is BDD: add a step to the scenario, watch it fail, and then code until it passes.

To fix this failure, add $product = new Product(); with code to set the name of the product. Copy that and repeat for price and description:

... lines 1 - 31
if ($request->isMethod('POST')) {
... lines 33 - 34
$product = new Product();
... lines 39 - 44
... lines 46 - 50

This is missing validation, so you would do more work than this in real life, maybe with a scenario that guarantees that validation works.

Finish this with $em = $this->getDoctrine()->getManager();, then persist and flush:

... lines 1 - 31
if ($request->isMethod('POST')) {
... lines 33 - 39
$em = $this->getDoctrine()->getManager();
... lines 43 - 44
... lines 46 - 50

Bug fixed! Try it:

./vendor/bin/behat features/product_admin.feature:19

It's green! In fact, we can go to the browser, refresh and see the Veloci-chew toy for $20.

But there's a problem: it says that the author is "anonymous". This should be "admin" since I created it under that user. That's definitely a bug: we forgot to set the author in ProductAdminController. Ok, we know how to fix bugs using BDD: add a step to prove the bug:

... lines 1 - 19
Scenario: Add a new product
... lines 21 - 30
And I should not see "Anonymous"

This is safe because there should only be the 1 product in the list. Back over to the terminal and run the test:

./vendor/bin/behat features/product_admin.feature:19

Yes! The new step fails, phew!

In ProductAdminController, set the author when a product is created: $product->setAuthor($this->getUser());:

... lines 1 - 31
if ($request->isMethod('POST')) {
... lines 33 - 34
$product = new Product();
... lines 36 - 38
... lines 40 - 45
... lines 47 - 51

Run Behat again:

./vendor/bin/behat features/product_admin.feature:19


And that folks, is behavior driven development. It's useful, and a bucket of fun. It forces you to design the behavior of your code. But it also helps you know when you're finished. If the scenario is green, stop coding and over-perfecting things. And yes, someone will need to add a designer's touch for a really nice UI, But from a behavioral perspective, this feature does what it needs to do. So move onto what's next.

Leave a comment!

Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses a very old version of Symfony. The fundamentals of Behat are still valid, but integration with Symfony will be different.

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=5.4.0, <7.3.0",
        "symfony/symfony": "^2.7", // v2.7.4
        "twig/twig": "^1.22", // v1.22.1
        "sensio/framework-extra-bundle": "^3.0", // v3.0.16
        "doctrine/doctrine-bundle": "^1.5", // v1.5.1
        "doctrine/orm": "^2.5", // v2.5.1
        "doctrine/doctrine-fixtures-bundle": "^2.2", // v2.2.1
        "behat/symfony2-extension": "^2.0" // v2.0.0
    "require-dev": {
        "behat/mink-extension": "^2.0", // v2.0.1
        "behat/mink-goutte-driver": "^1.1", // v1.1.0
        "behat/mink-selenium2-driver": "^1.2", // v1.2.0
        "phpunit/phpunit": "^4.8" // 4.8.18