Behat is an open-source testing framework that supports Behavior-Driven Development. Focused on requirements communication, it has a reputation for helping engineers build towards great systems, versus building systems and testing their greatness. Symfony remains one of the top PHP frameworks. It is agnostic and allows you to work with any testing framework. In this tutorial, we will set up a continuous integration pipeline for a Symfony application with a functional test powered by Behat. The application will return a list of customers. To keep things simple, we won’t interact with a database. Instead, we will hardcode the customer’s details.

Chronologically, we will:

  1. Create a new Symfony application
  2. Install Behat via Composer and initialize it within our application
  3. Create a GitHub repository
  4. Create an endpoint with default data and write a test for it
  5. Run the test locally, then configure CircleCI to automate it

Prerequisites

To successfully achieve the objectives of this tutorial, you will need the following:

Installing a Symfony application

Using Composer, create a new Symfony application by running the following command:

composer create-project symfony/website-skeleton symfony-behat

Once the installation process has been completed, you will have a new application with all its dependencies installed in a folder named symfony-behat.

Move into the project folder and install the required tools to facilitate the functional testing of the application by running the following commands:

// move into project
cd symfony-behat

// install web server and Behat
composer require behat/behat symfony/web-server-bundle --dev ^4.4.2

The last command above will install:

  • behat/behat: The latest version of Behat
  • symfony/web-server-bundle: The web server for running a Symfony application locally

Initializing Behat

After installing Behat, the first thing to do is initialize it within our application. This is crucial. It comes with a boilerplate that we can build on and it configures the test suite that will tell Behat where to find and how to test our application. Initialize using the following command:

vendor/bin/behat --init

You will see the following output:

+d features - place your *.feature files here
+d features/bootstrap - place your context classes here
+f features/bootstrap/FeatureContext.php - place your definitions, transformations and hooks here

Behat created a features directory that will hold the test script for features. It will also create a FeatureContext class in the features/bootstrap folder.

Some important concepts in Behat

If you are new to Behat, the following definitions will be quite helpful:

  • Feature: A file that represents a unit of functionality that includes the definitions, scenarios, and steps to facilitate testing that particular functionality.
  • Scenario: A collection of steps that recreate conditions and user behavior patterns.
  • Step: Plain language patterns that set up preconditions, trigger events, or make assertions about your application. Steps are responsible for the real behavior of a site.
  • Keyword: A specific set of words used as the beginning of step patterns for readability and to group steps into preconditions, actions, and assertions, e.g., Given, When, Then.
  • Context: A class that provides new ways to interact with your application. Primarily this means providing additional steps.

Creating the feature file for the customer endpoint

One of the expected features of our application is that any user should be able to visit the customer endpoint as an unauthenticated user, and then be able to view the list of customers without any issue. This is the feature’s story and in this section, we will create a feature file with the details of how we prefer the customer endpoint to function. Navigate to the features folder and create a file named customer.feature within it. Paste the following content into the new file:

Feature: List of customers
  In order to retrieve the list of customers
  As a user
  I must visit the customers page

  Scenario: I want a list of customers
    Given I am an unauthenticated user
    When I request a list of customers from "http://localhost:8000/customer"
    Then The results should include a customer with ID "1"

The language used by Behat to describe the expected behavior of sets of features within an application is referred to as Gherkin. It is a business readable domain specific language created specifically for behavior description. From the file above, we described one of the features expected of our application and created a context to describe the business value that the proposed feature will bring to our system. Then we used the Scenario keyword to define the determinable business situation of the feature. The steps that follow describe what needs to be done for the feature to be actualized.

Creating the feature scenario’s steps definition

Now that we have the feature of our application properly outlined, we need to define the step definitions within the FeatureContext. If you execute Behat right now with:

vendor/bin/behat

you will see the following output:

Feature: List of customers
  In order to retrieve the list of customers
  As a user
  I must visit the customers page

  Scenario: I want a list of customers
    Given I am an unauthenticated user
    When I request a list of customers from "http://localhost:8000/customer"
    Then The results should include a customer with ID "1"

1 scenario (1 undefined)
3 steps (3 undefined)
0m0.02s (9.58Mb)

 >> default suite has undefined steps. Please choose the context to generate snippets:

  [0] None
  [1] FeatureContext
 >

The output indicates that Behat recognizes our scenario with the three steps that we defined. However, the FeatureContext class has some missing methods that represent each of the steps created in the customer.feature file. Behat provides a route to easily map every scenario step with an actual method called step definitions.

You can either create these methods manually or allow Behat to automatically generate them for you. For this tutorial, we are opting for the latter. To proceed, select option 1.

--- FeatureContext has missing steps. Define them with these snippets:

    /**
     * @Given I am an unauthenticated user
     */
    public function iAmAnUnauthenticatedUser()
    {
        throw new PendingException();
    }

    /**
     * @When I request a list of customers from :arg1
     */
    public function iRequestAListOfCustomersFrom($arg1)
    {
        throw new PendingException();
    }

    /**
     * @Then The results should include a customer with ID :arg1
     */
    public function theResultsShouldIncludeACustomerWithId($arg1)
    {
        throw new PendingException();
    }

Copy these methods and update the FeatureContext.php file with them:

<?php

use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
use Behat\Behat\Tester\Exception\PendingException;
use Symfony\Component\HttpClient\HttpClient;

/**
 * Defines application features from the specific context.
 */
class FeatureContext implements Context
{
    /**
     * Initializes context.
     *
     * Every scenario gets its own context instance.
     * You can also pass arbitrary arguments to the
     * context constructor through behat.yml.
     */
    public function __construct()
    {
    }

    /**
     * @Given I am an unauthenticated user
     */
    public function iAmAnUnauthenticatedUser()
    {
        throw new PendingException();
    }

    /**
     * @When I request a list of customers from :arg1
     */
    public function iRequestAListOfCustomersFrom($arg1)
    {
        throw new PendingException();
    }

    /**
     * @Then The results should include a customer with ID :arg1
     */
    public function theResultsShouldIncludeACustomerWithId($arg1)
    {
        throw new PendingException();
    }
}

These are just the definitions of methods derived from each step in the customer.feature file. With the methods properly defined, we still need to add the required code to complete our scenario. Replace the contents of features/bootstrap/FeatureContext.php with the following code:

<?php

use Behat\Behat\Context\Context;
use Symfony\Component\HttpClient\HttpClient;

/**
 * Defines application features from the specific context.
 */
class FeatureContext implements Context
{
    protected $response;
    /**
     * Initializes context.
     *
     * Every scenario gets its own context instance.
     * You can also pass arbitrary arguments to the
     * context constructor through behat.yml.
     */
    public function __construct()
    {
    }

    /**
     * @Given I am an unauthenticated user
     */
    public function iAmAnUnauthenticatedUser()
    {
        $httpClient = HttpClient::create();
        $this->response = $httpClient->request("GET", "http://localhost:8000/customer");

        if ($this->response->getStatusCode() != 200) {
            throw new Exception("Not able to access");
        }

        return true;
    }

    /**
     * @When I request a list of customers from :arg1
     */
    public function iRequestAListOfCustomersFrom($arg1)
    {
        $httpClient = HttpClient::create();
        $this->response = $httpClient->request("GET", $arg1);

        $responseCode = $this->response->getStatusCode();

        if ($responseCode != 200) {
            throw new Exception("Expected a 200, but received " . $responseCode);
        }

        return true;
    }

    /**
     * @Then The results should include a customer with ID :arg1
     */
    public function theResultsShouldIncludeACustomerWithId($arg1)
    {
        $customers = json_decode($this->response->getContent());

        foreach($customers as $customer) {
            if ($customer->id == $arg1) {
                return true;
            }
        }

        throw new Exception('Expected to find customer with an ID of ' . $arg1 . ' , but didnt');
    }
}

From the scenario created in the customer.feature file, we begin by creating a method named iAmAnUnauthenticatedUser() for the first step. This will determine whether the customer endpoint has been created and whether a user who is not authenticated can access it.

public function iAmAnUnauthenticatedUser()
{
    $httpClient = HttpClient::create();
    $this->response = $httpClient->request("GET", "http://localhost:8000/customer");

    if ($this->response->getStatusCode() != 200) {
        throw new Exception("Not able to access");
    }

    return true;
}

Next, we created a method to assert that we can retrieve a list of customers from the customer endpoint.

public function iRequestAListOfCustomersFrom($arg1)
{
    $httpClient = HttpClient::create();
    $this->response = $httpClient->request("GET", $arg1);

    $responseCode = $this->response->getStatusCode();

    if ($responseCode != 200) {
        throw new Exception("Expected a 200, but received " . $responseCode);
    }

    return true;
}

Lastly, to be sure that the list of customers retrieved contains the expected record, we will write another method to check for an item with a specific id.

public function theResultsShouldIncludeACustomerWithId($arg1)
{
    $customers = json_decode($this->response->getContent());

    foreach($customers as $customer) {
        if ($customer->id == $arg1) {
            return true;
        }
    }

    throw new Exception('Expected to find customer with an ID of ' . $arg1 . ' , but didnt');
}

Running Behat right now will definitely fail. We have not yet created the customer endpoint to return the appropriate records.

Creating a customer controller

Generate a controller for the customer endpoint by running the following command:

php bin/console make:controller CustomerController

Replace the contents of the src/Controller/CustomerController.php file with the following code:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class CustomerController extends AbstractController
{
    /**
     * @Route("/customer", name="customer")
     */
    public function index()
    {
        $customers = [
            [
                'id' => 1,
                'name' => 'Olususi Oluyemi',
                'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation',
            ],
            [
                'id' => 2,
                'name' => 'Camila Terry',
                'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation',
            ],
            [
                'id' => 3,
                'name' => 'Joel Williamson',
                'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation',
            ],
            [
                'id' => 4,
                'name' => 'Deann Payne',
                'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation',
            ],
            [
                'id' => 5,
                'name' => 'Donald Perkins',
                'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation',
            ]
        ];

        $response = new Response();

        $response->headers->set('Content-Type', 'application/json');
        $response->headers->set('Access-Control-Allow-Origin', '*');

        $response->setContent(json_encode($customers));

        return $response;
    }
}

Here, we defined a route /customer, created a default list of customers, and returned it in a JSON format.

Running the feature test locally

Our feature test requires us to make a call to a particular endpoint. For that, we need to keep the server running. Run the following command to start the server:

php bin/console server:run

Once you are done, in another tab or window of your terminal, execute Behat with the following command:

vendor/bin/behat

You will see the following output.

Feature: List of customers
  In order to retrieve the list of customers
  As a user
  I must visit the customers page

  Scenario: I want a list of customers
    Given I am an unauthenticated user
    When I request a list of customers from "http://localhost:8000/customer"
    Then The results should include a customer with ID "1"

1 scenario (1 passed)
3 steps (3 passed)
0m0.10s (10.01Mb)

Our test now runs as expected. It’s time to create a GitHub repository and push this application’s codebase to it. Follow this guide to learn how to push a project to GitHub.

Adding a CircleCI configuration

We first need to update the .env.test file, since our pipeline will need it. Replace the contents with the following:

# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
APP_ENV=dev
DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7

To add a CircleCI configuration, create a .circleci folder inside the root directory of your application and add a new file named config.yml within it. Open the newly created file and paste the following code:

version: 2
jobs:
  build:
    docker:
      - image: circleci/php:7.4-node-browsers

    steps:
      - checkout

      - run: sudo apt update
      - run: sudo docker-php-ext-install zip

      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "composer.json" }}
            - v1-dependencies-

      - run:
          name: "Create Environment file"
          command: mv .env.test .env

      - run:
          name: "Install Dependencies"
          command: composer install -n --prefer-dist

      - save_cache:
          key: v1-dependencies-{{ checksum "composer.json" }}
          paths:
            - ./vendor
      - run:
          name: Run web server
          command: php bin/console server:run
          background: true

      # run Behat test
      - run:
          name: "Run Behat test"
          command: vendor/bin/behat

In the above config file, we pulled the circleci/php:7.4-node-browsers Docker image from the CircleCI image registry and installed all the required packages for our test environment. We then proceeded to install all the dependencies for our project.

Included is a command to start a local server and run it in the background.

- run:
    name: Run web server
    command: php bin/console server:run
    background: true

The last portion is a command to execute Behat for our feature test.

# run Behat test
- run:
    name: "Run Behat test"
    command: vendor/bin/behat

Go ahead and update the repository with the new code. In the next section, we will set up our project on CircleCI.

Connecting a project to CircleCI

Log into your account on CircleCI. From the console, locate the project created on GitHub and click Set Up Project.

Set up project

You will be redirected to a new page. Click Start Building.

Start building

A prompt will appear giving the option to either add the config file to a new branch on the repository or do it manually. Click Add Manually to proceed.

Add config manually

Click Start Building.

Added config

This will run successfully without glitches.

Successful build

Click on the job to view the details of the build.

Successful build result

Conclusion

In this tutorial, we followed the fundamental philosophy of behavior driven development (BDD) for building a Symfony application with Behat and we automated the testing with CircleCI. There is so much more you can do with Behat for your application. The information here is enough to get you started building the right features for your applications and this same approach can also be used for any of your other PHP projects.


Oluyemi is a tech enthusiast with a background in Telecommunication Engineering. With a keen interest in solving day-to-day problems encountered by users, he ventured into programming and has since directed his problem solving skills at building software for both web and mobile. A full stack software engineer with a passion for sharing knowledge, Oluyemi has published a good number of technical articles and blog posts on several blogs around the world. Being tech savvy, his hobbies include trying out new programming languages and frameworks.

Read more posts by Olususi Oluyemi