What needs to be in our Database for our Tests to work?


We've defined two Behat features - the healthcheck.feature, and the album.feature. We've seen how the Healthcheck is really all about a quick and simple validation that our API is up, and actively able to serve requests.

The Album feature is more involved. We are testing all the HTTP verbs (GET, POST, PUT, PATCH, and DELETE) and crucially, in order to test e.g. that we can GET existing data, we need to have some existing data readily available.

Now we covered how there are many ways to achieve this.

We could go with restoring a raw SQL file before each test is run.

In a "normal" project we might create new entities and then use Doctrine to persist these off to our underlying database. We covered how this isn't possible (well, not easy) in our project as we will have multiple backends in different languages, using different database, and we want our tests to be completely isolated from our implementations.

And so we're going to "dogfood" our own API to help setup our test suite.

It's unintuitive, and likely not ideal in most projects. But here, it's definitely one way :)

Cheating A Bit

If we don't have a webserver running then every time we try to run Behat (php vendor/bin/behat) then all we will see is:

In ApiClientAwareInitializer.php line 45:

  Can't connect to base_uri: "http://127.0.0.1:8000".  

To make this a little more visually interesting, I recommend pointing Behat at some existing, real endpoint:

# behat.yaml

default:
    suites:
        default:
            contexts:
                - FeatureContext
                - Imbo\BehatApiExtension\Context\ApiContext
    extensions:
        Imbo\BehatApiExtension:
            apiClient:
-                base_uri: http://127.0.0.1:8000
+                base_uri: http://example.com

We will change this to our real endpoint once we have one available.

A Little Background To Proceedings

Behat uses the concept of Background to set the stage, so to speak, before each of our test scenarios take place.

This allows us to say hey, before we run this test, make sure we have these three records in the database.

Then, when we run the tests, we can say hey, give me all the records you know about. And if the system is behaving, we should get back those three known records.

In our Background step we need to define a way that Behat can get our system into a known good state.

To do this, we're going to POST in data to our JSON API.

This tests that our system can handle POST requests. However it does so implicitly, which is why this approach is both unintuitive and likely not ideal in most projects.

What I mean here is that our Background setup will be firing in POST requests to our API before it runs a test to confirm that POSTing data actually works. Kind of weird.

This means we will need to manually implement our POST route immediately, and then validate that the POST test passes. And only then can our other tests really take place.

So yeah, if I haven't been clear enough already, this approach is interesting and likely not the way you'd do this in a real project. For a Symfony specific approach, this is more real world.

Defining The Background

Much like our Scenario, and Given, When, Then steps, the Background setup is very human friendly.

Here's what we're going with:

# features/album.feature

  Background:
    Given there are Albums with the following details:
      | title                              | track_count | release_date              |
      | some fake album name               | 12          | 2020-01-08T00:00:00+00:00 |
      | another great album                | 9           | 2019-01-07T23:22:21+00:00 |
      | now that's what I call Album vol 2 | 23          | 2018-02-06T11:10:09+00:00 |

There's a big downside to this approach. We're unable to set the ID.

This won't be a huge problem for us as we will rely on auto-incrementing integers. In a real project where you may be using UUIDs, you're almost certainly going to struggle with this approach. But again, I've stated my warnings to this method a good few times already :)

If you try to run your Behat tests now (vendor/bin/behat), you're going to hit upon a failure:

--- Imbo\BehatApiExtension\Context\ApiContext has missing steps. Define them with these snippets:

    /**
     * @Given there are Albums with the following details:
     */
    public function thereAreAlbumsWithTheFollowingDetails(TableNode $table)
    {
        throw new PendingException();
    }

The error is bit misleading. The missing step shouldn't be in Imbo\BehatApiExtension\Context\ApiContext. We're going to add it into the FeatureContext.php file instead.

As a side note here, typically when working with Behat I go rouge at this point. I like to define each part of my setup in lots of small files. I would go with an AlbumContext.php for example. This is, as best I understand it, not best practice for Behat. Still, it works for me.

Ok, so copy / paste that entire snippet - including the docblock - to your features/FeatureContext.php file.

And re-run your Behat tests, and note the error is gone, but replaced with a new one:

  Background:                                          # features/album.feature:7
    Given there are Albums with the following details: # FeatureContext::thereAreAlbumsWithTheFollowingDetails()
      | title                              | track_count | release_date              |
      | some fake album name               | 12          | 2020-01-08T00:00:00+00:00 |
      | another great album                | 9           | 2019-01-07T23:22:21+00:00 |
      | now that's what I call Album vol 2 | 23          | 2018-02-06T11:10:09+00:00 |
      Fatal error: Class 'PendingException' not found (Behat\Testwork\Call\Exception\FatalThrowableError)

Ok, cool. Progress.

Our First Step Towards a Happy API

We don't yet have an API in to which we can send our POST requests.

This isn't a show stopper.

It is going to require a little... ahem, interesting code endeavors.

What I don't want to do is to have to require Guzzle.

Why not? Well, imbo/behat-api-extension already not only requires Guzzle, but we've provided configuration to initialise it:

# behat.yml

default:
    suites:
        default:
            contexts:
                - FeatureContext
                - Imbo\BehatApiExtension\Context\ApiContext
    extensions:
        Imbo\BehatApiExtension:
            apiClient:
                base_uri: http://127.0.0.1:8000

We won't need Guzzle outside of Behat. And we're exclusively making use of methods already created by ImboBehatApiExtension.

Therefore why define this twice?

This is purely opinion. Explicitness is often best. However, our setup is not going to get more complex than this. So with this in mind, I'm happy with my current plan. And this plan is:

Piggy back on to the BehatApiExtension, using its public function's to do our dirty work.

Now, don't worry if you don't follow this. It's not important to understand this to complete any of the rest of this course. This is only for setup, and can be copy / pasted, then forgotten about.

Gathering Contexts

We're working in FeatureContext, but all the Guzzle / interesting HTTP communication stuff happens in vendor/imbo/behat-api-extension/src/Context/ApiContext.php.

We need a way to use the exposed public API (aka all the public function's defined in that file) from our FeatureContext.

Behat allows inter-context communication via its gatherContexts method.

Here's what we're adding:

// features/bootstrap/FeatureContext.php

...

    /** @BeforeScenario */
    public function gatherContexts(
      \Behat\Behat\Hook\Scope\BeforeScenarioScope $scope
    )
    {
        $this->apiContext = $scope
            ->getEnvironment()
            ->getContext(
                \Imbo\BehatApiExtension\Context\ApiContext::class
            )
        ;
    }

Remember, it's not essential to understand this.

From a high level all we are doing is getting access to the ApiContext class so we can use the public function's it defines inside this FeatureContext. This just means we don't need to do much setup, or define our own ways of working with HTTP requests.

It's lazy at the expense of being complex.

As the annotation @BeforeScenario implies, this gets run before each of our test scenarios, and so we always have this available when testing.

The Loop

Next, back to something a little more like standard Behat.

In the Background step, we have defined our table full of data:

    Given there are Albums with the following details: # FeatureContext::thereAreAlbumsWithTheFollowingDetails()
      | title                              | track_count | release_date              |
      | some fake album name               | 12          | 2020-01-08T00:00:00+00:00 |
      | another great album                | 9           | 2019-01-07T23:22:21+00:00 |
      | now that's what I call Album vol 2 | 23          | 2018-02-06T11:10:09+00:00 |

Behat will allow us to loop over each of these table rows and give us access to the data as a PHP array.

We get access to this data via a Behat TableNode.

Remembering back, that's exactly the argument defined on the step definition that Behat generated for us:

    /**
     * @Given there are Albums with the following details:
     */
    public function thereAreAlbumsWithTheFollowingDetails(TableNode $table)
    {
        throw new PendingException();
    }

In order to loop over this data we need to get a column hash:

    /**
     * @Given there are Albums with the following details:
     */
-    public function thereAreAlbumsWithTheFollowingDetails(TableNode $table)
+    public function thereAreAlbumsWithTheFollowingDetails(TableNode $$albums)
    {
-        throw new PendingException();
+        foreach ($albums->getColumnsHash() as $album) {
+        }
    }

Personally I like to rename the generated variable names to something more meaningful - $table becomes $albums. Personal preference.

I blogged about this in the past, so am not going into much detail here. Again, this isn't one of those things you really need to remember, as largely it's generated for you. And once you have written this once, you always have this code to refer back to.

The Request

We've already written Gherkin steps to show how we can send in a POST request:

  Scenario: Can add a new Album
    Given the request body is:
      """
      {
        "title": "Awesome new Album",
        "track_count": 7,
        "release_date": "2030-12-05T01:02:03+00:00"
      }
      """
    When I request "/album" using HTTP POST
    Then the response code is 201

We know that each of these steps really runs some PHP code behind the scenes. This is how we translate human sentences into runnable code.

With an IDE like PhpStorm, each of these step definitions should be ctrl + clickable.

If we ctrl + click the line Given the request body is then we're taken to:

// vendor/imbo/behat-api-extension/src/Context/ApiContext.php:217

    /**
     * Set the request body to a string
     *
     * @param resource|string|PyStringNode $string The content to set as the request body
     * @throws InvalidArgumentException If form_params or multipart is used in the request options
     *                                  an exception will be thrown as these can't be combined.
     * @return self
     *
     * @Given the request body is:
     */
    public function setRequestBody($string) {
        if (!empty($this->requestOptions['multipart']) || !empty($this->requestOptions['form_params'])) {
            throw new InvalidArgumentException(
                'It\'s not allowed to set a request body when using multipart/form-data or form parameters.'
            );
        }

        $this->request = $this->request->withBody(Psr7\stream_for($string));

        return $this;
    }

The comment is interesting. This function expects a string.

In Gherkin we used a PyString - the data contained within the """ block.

Here we don't want to be messing around with PyStrings. We just need to send a string.

But we're working with arrays - and no one like an array to string conversion error.

Well, not to worry. We really want JSON anyway, so all we need to do is convert our PHP array to lovely JSON. And hey, there's a function for that:

    /**
     * @Given there are Albums with the following details:
     */
    public function thereAreAlbumsWithTheFollowingDetails(TableNode $albums)
    {
        foreach ($albums->getColumnsHash() as $album) {
+
+            $this->apiContext->setRequestBody(
+               json_encode($album)
+           );
        }
    }

As soon as we have that data set as our request body, we need to send / POST it to our API.

Well, again, our API client (Guzzle) is preconfigured. We don't need to worry about this too much. Just once again, follow the method:

When I request "/album" using HTTP POST

Which ctrl + click takes us to:

// vendor/imbo/behat-api-extension/src/Context/ApiContext.php:204

    /**
     * Request a path
     *
     * @param string $path The path to request
     * @param string $method The HTTP method to use
     * @return self
     *
     * @When I request :path
     * @When I request :path using HTTP :method
     */
    public function requestPath($path, $method = null) {
        $this->setRequestPath($path);

        if (null === $method) {
            $this->setRequestMethod('GET', false);
        } else {
            $this->setRequestMethod($method);
        }

        return $this->sendRequest();
    }

And again, not a great deal for us to do here. Just provide the path (/album) and the verb (POST) and away we go:

    /**
     * @Given there are Albums with the following details:
     */
    public function thereAreAlbumsWithTheFollowingDetails(TableNode $albums)
    {
        foreach ($albums->getColumnsHash() as $album) {

            $this->apiContext->setRequestBody(
               json_encode($album)
            );

+           $this->apiContext->requestPath("/album", "POST");
        }
    }

And that's it. We're done.

The Result

The result is that our Behat background step actually goes green at this point. Nice ;)

It's sending the requests, even though the configured endpoint is unable to handle them.

That's fine. We're going to fix this shortly.

There is one other problem to address though, and it's not immediately obvious:

The database will not be getting reset between test runs.

We need to fix this immediately, as it's a big problem. We will do just this in the very next video.

Episodes

# Title Duration
1 What will our JSON API actually do? 08:46
2 What needs to be in our Database for our Tests to work? 12:32
3 Cleaning up after each Test Run 02:40
4 Docker makes for Easy Databases 09:01
5 Healthcheck [Raw Symfony 4] 07:53
6 Send in JSON data using POST [Raw Symfony 4] 05:33
7 Keep your data nice and tidy using Symfony's Form [Raw Symfony 4] 10:48
8 Validating incoming JSON [Raw Symfony 4] 08:26
9 Nicer error messages [Raw Symfony 4] 06:23
10 GET'ting data from our Symfony 4 API [Raw Symfony 4] 08:11
11 GET'ting a collection of Albums [Raw Symfony 4] 01:50
12 Update existing Albums with PUT [Raw Symfony 4] 05:00
13 Upsetting Purists with PATCH [Raw Symfony 4] 02:39
14 Hitting DELETE [Raw Symfony 4] 02:11
15 How to open your API to the outside world with CORS [Raw Symfony 4] 07:48
16 Getting Setup with Symfony 4 and FOSRESTBundle [FOSRESTBundle] 09:11
17 Healthcheck [FOSRESTBundle] 06:14
18 Handling POST requests [FOSRESTBundle] 08:31
19 Saving POST data to the database [FOSRESTBundle] 09:44
20 Work with XML, or JSON, or Both [FOSRESTBundle] 04:31
21 Going far, then Too Far with the ViewResponseListener [FOSRESTBundle] 03:19
22 GET'ting data from your Symfony 4 API [FOSRESTBundle] 05:58
23 GET'ting a Collection of data from your Symfony 4 API [FOSRESTBundle] 01:27
24 Updating with PUT [FOSRESTBundle] 02:58
25 Partially Updating with PATCH [FOSRESTBundle] 02:15
26 DELETE'ing Albums [FOSRESTBundle] 01:27
27 Handling Errors [FOSRESTBundle] 08:58
28 Introducing the API Platform [API Platform] 08:19
29 The Entry Point [API Platform] 04:30
30 The Context [API Platform] 05:52
31 Healthcheck - Custom Endpoint [API Platform] 05:17
32 Starting with POST [API Platform] 07:08
33 Creating Entities with the Schema Generator [API Platform] 07:38
34 Defining A Custom POST Route [API Platform] 07:31
35 Finishing POST [API Platform] 06:29
36 GET'ting One Resource [API Platform] 02:50
37 GET'ting Multiple Resources [API Platform] 02:59
38 PUT to Update Existing Data [API Platform] 02:19
39 DELETE to Remove Data [API Platform] 01:15
40 No One Likes Errors [API Platform] 03:28