In the previous video we looked at the basic configuration to make our Symfony REST API actually load, but in a very basic capacity.

Before we proceed with adding features, it is my opinion that laying a foundation for testing as one of the earliest steps in a project is extremely important.

By adding in our testing setup at this stage we will ideally form the habit of testing our code as we go, rather than making the oft-reneged-on promise of "we will add tests later".

Now, here's my opinion on testing this setup. The word "opinion" is by far and away the most important part of that sentence.

We are going to use Behat.

Behat is all about testing business expectations through Behaviour Driven Development (BDD).

Behat is also, in my opinion, a scary and seemingly difficult tool to get working.

That said, once it is working, it is by far and away my favourite testing tool. As you will see throughout this course, the way in which we write Behat tests make it extremely easy to involve less technical folk in the discussions about software, and that helps deliver software that does what they want, rather than what you guessed they might have thought they wanted :)

Behat Test Environment

As discussed in the previous video, we are going to use the app_acceptance environment for running all our automated Behat tests.

We've already created the environment, and associated configuration. If you haven't already done so, be sure to either watch the video and read through the notes of the previous video, and / or check out the start tag for our project from GitHub.

We also added the various dependencies for Behat to our composer.json file. Here they are again if you are unsure:

    "require-dev": {
        "sensio/generator-bundle": "^3.0",
        "symfony/phpunit-bridge": "^3.0",
        "behat/behat": "^3.1",
        "behat/symfony2-extension": "^2.1",
        "phpunit/phpunit": "^5.5",
        "guzzlehttp/guzzle": "^6.2",
        "csa/guzzle-bundle": "^2.1"
    },

These dependencies are to be used in development / test only, so have been added to the require-dev section, rather than the usual require section.

We haven't yet initialised Behat, nor configured it. Let's fix that now.

Initialising Behat is easy enough:

php vendor/bin/behat --init

This will create a new file for your under features/bootstrap/FeatureContext.php.

Now, again, going back to my opinionated approach, I don't use the root level /features directory. Instead, I add a Features directory to the AppBundle directory. If you have multiple bundles, the setup for Behat may differ slightly.

I do make use of the Behat init generated FeatureContext.php file. I use it for any generic steps, which in this instance is simply to drop and recreate the table schema before each scenario (think: test) runs.

<?php

use Behat\Behat\Context\Context;

/**
 * Defines application features from the specific context.
 */
class FeatureContext implements Context
{
    private $doctrine;
    private $manager;
    private $schemaTool;
    private $classes;

    /**
     * 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(\Doctrine\Common\Persistence\ManagerRegistry $doctrine)
    {
        $this->doctrine = $doctrine;
        $this->manager = $doctrine->getManager();
        $this->schemaTool = new \Doctrine\ORM\Tools\SchemaTool($this->manager);
        $this->classes = $this->manager->getMetadataFactory()->getAllMetadata();
    }

    /**
     * @BeforeScenario
     */
    public function createSchema()
    {
        $this->schemaTool->dropSchema($this->classes);
        $this->schemaTool->createSchema($this->classes);
    }
}

The way this works is quite clever, and I can't be credited for writing this code - though in truth I forget where I originally found it.

We start by injecting Doctrine in via constructor injection. We haven't configured this yet, we will do so in a moment.

From here we get the object manager: $doctrine->getManager();

We pass the object manager into Doctrine's SchemaTool, a class which can actually create, drop, and update our underlying database schema. This means all the columns, associations, and that sort of thing.

Finally, the clever part of this whole setup is in using the various annotations (meta data) we have defined on our entities to figure out all the configured classes in our application. This means we don't need to add specific tables to be created / dropped per test run, they will be automatically discovered simply by annotating them correctly, which we must do anyway to make them work.

This information will be stored in $this->classes as an array.

Finally, before each scenario (again, think: individual test), we drop and re-create our schema for each of the classes in the array we just created.

Awesome.

Ok, but this won't work without a bit of configuration. For this, we need to create ourselves a behat.yml file in the root of our project and add in the required config:

# /behat.yml

default:

  suites:
    default:
      type: symfony_bundle
      bundle: AppBundle
      contexts:
        - FeatureContext:
            doctrine: "@doctrine"
        - AppBundle\Features\Context\RestApiContext:
            client: "@csa_guzzle.client.local_test_api"
        - AppBundle\Features\Context\UserSetupContext:
            userManager: "@fos_user.user_manager"
            em: "@doctrine.orm.entity_manager"


  extensions:
    Behat\Symfony2Extension:
      kernel:
        env: "acceptance"
        debug: "true"

Note that you can move the behat.yml file to anywhere you like, but convention is in the site root.

We will only have one configuration profile, which we have called default.

Likewise, we will only have one test suite, which we will also call default.

Because we have added in the Symfony2Extension to our Behat setup, we can register our suite as type: symfony_bundle, and register the bundle our suite will test against.

Looking at the Behat\Symfony2Extension setup - firstly, it's not a typo, we are using the Symfony2Extension with our Symfony 3 project. The naming is misleading, but don't worry about it.

We created our acceptance environment for this specific purpose, so thats the kernel environment we will be using during test.

By using the option of debug: true we can ensure that our kernel will be booted with the debug option set to true.

In the contexts section is where I differ from the Behat best practice. I may be misinformed here, but let me explain why I do this the way I do it, and if you know of a better way then please do shout up (comments, email, however you like, I'm always open to improving).

I like to keep my 'setup' classes separated. It helps me to find what I am looking for faster.

Therefore I put each of my setups steps in its own context. I know this is wrong, but it works for me. I don't like one massive file with everything in it.

As we have already covered, we get the FeatureContext file generated for us during a behat --init. This is the first entry in our contexts section.

What we do here is very similar to a Symfony services.yml file. We define 'things' that will be injected. By virtue of the setup we already done, we can now inject other Symfony services we have defined, and Behat will ensure they are injected as expected. Awesome.

The FeatureContext expects an instance of Doctrine's ManagerRegistry to be injected. We can get this by injecting @doctrine. Inside our FeatureContext constructor, we used the variable name of $doctrine, which is why we have the key of doctrine:

      contexts:
        - FeatureContext:
            doctrine: "@doctrine"

We haven't defined the other two 'contexts' as of yet. But knowing what we now know, we can see that into our RestApiContext we will inject the Guzzle test client (configured in app/config/config_acceptance.yml in the previous video) as the $client variable, and likewise, our UserSetupContext will get the FOSUserBundle user_manager service, and the entity_manager also.

UserSetupContext

I can already feel the Behat purists seething with Sith level rage.

Yes, as mentioned, this is not a true Behat context.

Let's look at the code anyway:

<?php

// src/AppBundle/Features/Context/UserSetupContext.php

namespace AppBundle\Features\Context;

use Behat\Behat\Context\Context;
use Behat\Behat\Context\SnippetAcceptingContext;
use Behat\Gherkin\Node\TableNode;
use Doctrine\ORM\EntityManagerInterface;
use FOS\UserBundle\Model\UserManagerInterface;

class UserSetupContext implements Context, SnippetAcceptingContext
{
    /**
     * @var UserManagerInterface
     */
    private $userManager;
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * UserSetupContext constructor.
     *
     * @param UserManagerInterface   $userManager
     * @param EntityManagerInterface $em
     */
    public function __construct(UserManagerInterface $userManager, EntityManagerInterface $em)
    {
        $this->userManager = $userManager;
        $this->em = $em;
    }

    /**
     * @Given there are Users with the following details:
     */
    public function thereAreUsersWithTheFollowingDetails(TableNode $users)
    {
        foreach ($users->getColumnsHash() as $key => $val) {

            $confirmationToken = isset($val['confirmation_token']) && $val['confirmation_token'] != ''
                ? $val['confirmation_token']
                : null;

            $user = $this->userManager->createUser();

            $user->setEnabled(true);
            $user->setUsername($val['username']);
            $user->setEmail($val['email']);
            $user->setPlainPassword($val['password']);
            $user->setConfirmationToken($confirmationToken);

            if ( ! empty($confirmationToken)) {
                $user->setPasswordRequestedAt(new \DateTime('now'));
            }

            $this->userManager->updateUser($user);
        }
    }
}

Again, the injected variable names match up with those in our behat.yml configuration:

        - AppBundle\Features\Context\UserSetupContext:
            userManager: "@fos_user.user_manager"
            em: "@doctrine.orm.entity_manager"

And as you will soon see, this will allow us to write definitions in our Behat Features that create Users for us. Something like this:

  Background:
    Given there are Users with the following details:
      | id | username | email          | password |
      | 1  | peter    | peter@test.com | testpass |
      | 2  | john     | john@test.org  | johnpass |
      | 3  | tim      | tim@blah.net   | timpass  |

When our tests are run, Behat will use the UserSetupContext to create our users as expected. Again, more on this as we work our way through.

What I've noticed throughout creating and testing RESTful API's using Behat and Symfony is that a common set of step definitions are used throughout the vast majority of tests.

Rather than focus on writing out each of these steps, instead I will share with you the RestApiContext I use (again, Behat heresy) and this will enable us to use Behat for testing our API without really having to worry about the underlying way in which it works.

Of course, you are completely free to dive into the code, tweak, change, and improve as you see fit. This implementation is opinionated, and should be used a guideline, rather than a defacto standard. For completeness, here is the full RestApiContext file:

<?php

namespace AppBundle\Features\Context;

use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Post\PostFile;
use GuzzleHttp\Psr7;
use PHPUnit_Framework_Assert as Assertions;
use Sanpi\Behatch\Json\JsonInspector;
use Sanpi\Behatch\Json\JsonSchema;
use Symfony\Component\HttpFoundation\Request;

/**
 * Class RestApiContext
 * @package AppBundle\Features\Context
 */
class RestApiContext implements Context
{
    /**
     * @var ClientInterface
     */
    protected $client;

    /**
     * @var string
     */
    private $authorization;

    /**
     * @var array
     */
    private $headers = [];

    /**
     * @var \GuzzleHttp\Message\RequestInterface
     */
    private $request;

    /**
     * @var \GuzzleHttp\Message\ResponseInterface
     */
    private $response;

    /**
     * @var array
     */
    private $placeHolders = array();

    /**
     * RestApiContext constructor.
     * @param ClientInterface   $client
     */
    public function __construct(ClientInterface $client)
    {
        $this->client = $client;
    }

    /**
     * Adds Basic Authentication header to next request.
     *
     * @param string $username
     * @param string $password
     *
     * @Given /^I am authenticating as "([^"]*)" with "([^"]*)" password$/
     */
    public function iAmAuthenticatingAs($username, $password)
    {
        $this->removeHeader('Authorization');
        $this->authorization = base64_encode($username . ':' . $password);
        $this->addHeader('Authorization', 'Basic ' . $this->authorization);
    }

    /**
     * Adds JWT Token to Authentication header for next request
     *
     * @param string $username
     * @param string $password
     *
     * @Given /^I am successfully logged in with username: "([^"]*)", and password: "([^"]*)"$/
     */
    public function iAmSuccessfullyLoggedInWithUsernameAndPassword($username, $password)
    {
        try {

            $this->iSendARequest('POST', 'login', [
                'json' => [
                    'username' => $username,
                    'password' => $password,
                ]
            ]);

            $this->theResponseCodeShouldBe(200);

            $responseBody = json_decode($this->response->getBody(), true);
            $this->addHeader('Authorization', 'Bearer ' . $responseBody['token']);

        } catch (RequestException $e) {

            echo Psr7\str($e->getRequest());

            if ($e->hasResponse()) {
                echo Psr7\str($e->getResponse());
            }

        }
    }

    /**
     * @When I have forgotten to set the :header
     */
    public function iHaveForgottenToSetThe($header)
    {
        $this->addHeader($header, null);
    }

    /**
     * Sets a HTTP Header.
     *
     * @param string $name  header name
     * @param string $value header value
     *
     * @Given /^I set header "([^"]*)" with value "([^"]*)"$/
     */
    public function iSetHeaderWithValue($name, $value)
    {
        $this->addHeader($name, $value);
    }

    /**
     * Sends HTTP request to specific relative URL.
     *
     * @param string $method request method
     * @param string $url    relative url
     *
     * @When /^(?:I )?send a "([A-Z]+)" request to "([^"]+)"$/
     */
    public function iSendARequest($method, $url, array $data = [])
    {
        $url = $this->prepareUrl($url);
        $data = $this->prepareData($data);

//        print_r($data);

        try {
            $this->response = $this->getClient()->request($method, $url, $data);
        } catch (RequestException $e) {
            if ($e->hasResponse()) {
                $this->response = $e->getResponse();
            }
        }
    }

    /**
     * Sends HTTP request to specific URL with field values from Table.
     *
     * @param string    $method request method
     * @param string    $url    relative url
     * @param TableNode $post   table of post values
     *
     * @When /^(?:I )?send a ([A-Z]+) request to "([^"]+)" with values:$/
     */
    public function iSendARequestWithValues($method, $url, TableNode $post)
    {
        $url = $this->prepareUrl($url);
        $fields = array();

        foreach ($post->getRowsHash() as $key => $val) {
            $fields[$key] = $this->replacePlaceHolder($val);
        }

        $bodyOption = array(
            'body' => json_encode($fields),
        );
        $this->request = $this->getClient()->createRequest($method, $url, $bodyOption);
        if (!empty($this->headers)) {
            $this->request->addHeaders($this->headers);
        }

        $this->sendRequest();
    }

    /**
     * Sends HTTP request to specific URL with raw body from PyString.
     *
     * @param string       $method request method
     * @param string       $url    relative url
     * @param PyStringNode $string request body
     *
     * @When /^(?:I )?send a "([A-Z]+)" request to "([^"]+)" with body:$/
     */
    public function iSendARequestWithBody($method, $url, PyStringNode $string)
    {
        $url = $this->prepareUrl($url);
        $string = $this->replacePlaceHolder(trim($string));

        $this->request = $this->iSendARequest(
            $method,
            $url,
            [ 'body' => $string, ]
        );
    }

    /**
     * Sends HTTP request to specific URL with form data from PyString.
     *
     * @param string       $method request method
     * @param string       $url    relative url
     * @param PyStringNode $body   request body
     *
     * @When /^(?:I )?send a "([A-Z]+)" request to "([^"]+)" with form data:$/
     */
    public function iSendARequestWithFormData($method, $url, PyStringNode $body)
    {
        $url = $this->prepareUrl($url);
        $body = $this->replacePlaceHolder(trim($body));

        $fields = array();
        parse_str(implode('&', explode("\n", $body)), $fields);
        $this->request = $this->getClient()->createRequest($method, $url);
        /** @var \GuzzleHttp\Post\PostBodyInterface $requestBody */
        $requestBody = $this->request->getBody();
        foreach ($fields as $key => $value) {
            $requestBody->setField($key, $value);
        }

        $this->sendRequest();
    }

    /**
     * @When /^(?:I )?send a multipart "([A-Z]+)" request to "([^"]+)" with form data:$/
     */
    public function iSendAMultipartRequestToWithFormData($method, $url, TableNode $post)
    {
        $url = $this->prepareUrl($url);

        $this->request = $this->getClient()->createRequest($method, $url);

        $data = $post->getColumnsHash()[0];

        $hasFile = false;

        if (array_key_exists('filePath', $data)) {
            $filePath = $this->dummyDataPath . $data['filePath'];
            unset($data['filePath']);
            $hasFile = true;
        }


        /** @var \GuzzleHttp\Post\PostBodyInterface $requestBody */
        $requestBody = $this->request->getBody();
        foreach ($data as $key => $value) {
            $requestBody->setField($key, $value);
        }


        if ($hasFile) {
            $file = fopen($filePath, 'rb');
            $postFile = new PostFile('uploadedFile', $file);
            $requestBody->addFile($postFile);
        }


        if (!empty($this->headers)) {
            $this->request->addHeaders($this->headers);
        }
        $this->request->setHeader('Content-Type', 'multipart/form-data');

        $this->sendRequest();
    }

    /**
     * Checks that response has specific status code.
     *
     * @param string $code status code
     *
     * @Then the response code should be :arg1
     */
    public function theResponseCodeShouldBe($code)
    {
        $expected = intval($code);
        $actual = intval($this->response->getStatusCode());
        Assertions::assertSame($expected, $actual);
    }

    /**
     * Checks that response body contains specific text.
     *
     * @param string $text
     *
     * @Then /^(?:the )?response should contain "((?:[^"]|\\")*)"$/
     */
    public function theResponseShouldContain($text)
    {
        $expectedRegexp = '/' . preg_quote($text) . '/i';
        $actual = (string) $this->response->getBody();
        Assertions::assertRegExp($expectedRegexp, $actual);
    }

    /**
     * Checks that response body doesn't contains specific text.
     *
     * @param string $text
     *
     * @Then /^(?:the )?response should not contain "([^"]*)"$/
     */
    public function theResponseShouldNotContain($text)
    {
        $expectedRegexp = '/' . preg_quote($text) . '/';
        $actual = (string) $this->response->getBody();
        Assertions::assertNotRegExp($expectedRegexp, $actual);
    }

    /**
     * Checks that response body contains JSON from PyString.
     *
     * Do not check that the response body /only/ contains the JSON from PyString,
     *
     * @param PyStringNode $jsonString
     *
     * @throws \RuntimeException
     *
     * @Then /^(?:the )?response should contain json:$/
     */
    public function theResponseShouldContainJson(PyStringNode $jsonString)
    {
        $etalon = json_decode($this->replacePlaceHolder($jsonString->getRaw()), true);
        $actual = json_decode($this->response->getBody(), true);

        if (null === $etalon) {
            throw new \RuntimeException(
                "Can not convert etalon to json:\n" . $this->replacePlaceHolder($jsonString->getRaw())
            );
        }

        Assertions::assertGreaterThanOrEqual(count($etalon), count($actual));
        foreach ($etalon as $key => $needle) {
            Assertions::assertArrayHasKey($key, $actual);
            Assertions::assertEquals($etalon[$key], $actual[$key]);
        }
    }

    /**
     * Prints last response body.
     *
     * @Then print response
     */
    public function printResponse()
    {
        $response = $this->response;

        echo sprintf(
            "%d:\n%s",
            $response->getStatusCode(),
            $response->getBody()
        );
    }

    /**
     * @Then the response header :header should be equal to :value
     */
    public function theResponseHeaderShouldBeEqualTo($header, $value)
    {
        $header = $this->response->getHeaders()[$header];
        Assertions::assertContains($value, $header);
    }

    /**
     * Prepare URL by replacing placeholders and trimming slashes.
     *
     * @param string $url
     *
     * @return string
     */
    private function prepareUrl($url)
    {
        return ltrim($this->replacePlaceHolder($url), '/');
    }

    /**
     * Sets place holder for replacement.
     *
     * you can specify placeholders, which will
     * be replaced in URL, request or response body.
     *
     * @param string $key   token name
     * @param string $value replace value
     */
    public function setPlaceHolder($key, $value)
    {
        $this->placeHolders[$key] = $value;
    }

    /**
     * @Then I follow the link in the Location response header
     */
    public function iFollowTheLinkInTheLocationResponseHeader()
    {
        $location = $this->response->getHeader('Location')[0];

        $this->iSendARequest(Request::METHOD_GET, $location);
    }

    /**
     * @Then the JSON should be valid according to this schema:
     */
    public function theJsonShouldBeValidAccordingToThisSchema(PyStringNode $schema)
    {
        $inspector = new JsonInspector('javascript');

        $json = new \Sanpi\Behatch\Json\Json(json_encode($this->response->json()));

        $inspector->validate(
            $json,
            new JsonSchema($schema)
        );
    }

    /**
     * Checks, that given JSON node is equal to given value
     *
     * @Then the JSON node :node should be equal to :text
     */
    public function theJsonNodeShouldBeEqualTo($node, $text)
    {
        $json = new \Sanpi\Behatch\Json\Json(json_encode($this->response->json()));

        $inspector = new JsonInspector('javascript');

        $actual = $inspector->evaluate($json, $node);

        if ($actual != $text) {
            throw new \Exception(
                sprintf("The node value is '%s'", json_encode($actual))
            );
        }
    }

    /**
     * Replaces placeholders in provided text.
     *
     * @param string $string
     *
     * @return string
     */
    protected function replacePlaceHolder($string)
    {
        foreach ($this->placeHolders as $key => $val) {
            $string = str_replace($key, $val, $string);
        }

        return $string;
    }

    /**
     * Returns headers, that will be used to send requests.
     *
     * @return array
     */
    protected function getHeaders()
    {
        return $this->headers;
    }

    /**
     * Adds header
     *
     * @param string $name
     * @param string $value
     */
    protected function addHeader($name, $value)
    {
        if ( ! isset($this->headers[$name])) {
            $this->headers[$name] = $value;
        }

        if (!is_array($this->headers[$name])) {
            $this->headers[$name] = [$this->headers[$name]];
        }

        $this->headers[$name] = $value;
    }

    /**
     * Removes a header identified by $headerName
     *
     * @param string $headerName
     */
    protected function removeHeader($headerName)
    {
        if (array_key_exists($headerName, $this->headers)) {
            unset($this->headers[$headerName]);
        }
    }

    /**
     * @return ClientInterface
     */
    private function getClient()
    {
        if (null === $this->client) {
            throw new \RuntimeException('Client has not been set in WebApiContext');
        }

        return $this->client;
    }

    private function prepareData($data)
    {
        if (!empty($this->headers)) {
            $data = array_replace(
                $data,
                ["headers" => $this->headers]
            );
        }

        return $data;
    }
}

Again, I cannot take credit for this. This is a modification of a couple of context files I came across during researching and developing not only this series, but many prior codebases before. The original code from this context came from Everzet, and the Senpai Behatch Context (as best I recall).

With all these pieces in place we are now ready to start creating our Behat features, and as a pleasent side effect, actually writing some code :)


Code For This Course

Get the code for this course.

Share This Episode

If you have found this video helpful, please consider sharing. I really appreciate it.


Episodes in this series

# Title Duration
1 Course Overview & API Walkthrough 04:23
2 Setup - Bundles & Config 08:37
3 Setup - Behat 04:45
4 Login - Part 1 - Happy Path 06:58
5 Login - Part 2 - Being Careful of Edge Cases 02:28
6 Profile - Part 1 - Happy Path 05:33
7 Profile - Part 2 - Unhappy Paths 01:50
8 Profile - Part 3 - Updating (PUT) - Happy Path 09:12
9 Profile - Part 4- Adding PATCH - Happy Path 06:21
10 Password Management - Change Password - Part 1 07:30
11 Password Management - Change Password - Part 2 04:13
12 Password Management - Reset Password - Part 1 06:14
13 Password Management - Reset Password - Part 2 05:37
14 Password Management - Reset Password - Part 3 05:57
15 Password Management - Reset Password - Part 4 03:51
16 Registration - Part 1 - Happy Path 06:56
17 Registration - Part 2 - Happy Path 06:42
18 Registration - Part 3 - Unhappy Paths 02:08
19 FOSUserBundle User Entity Serialization Improvements 02:11
20 Customising Your Encoded JWT 03:56