Our Next Passing Step


This video is largely a follow on from the previous video.

There are some issues in the code at this point. We are placing our entity retrieval logic right there in the controller action. I would strong advise against this unless your API / application is trivial.

In our case, our current implementation is trivial. We are only interested in two tests:

  Scenario: User cannot GET a Collection of User objects
    When I send a "GET" request to "/users"
    Then the response code should 405

  Scenario: User can GET their personal data by their unique ID
    When I send a "GET" request to "/users/u1"
    Then the response code should 200
     And the response header "Content-Type" should be equal to "application/json; charset=utf-8"
     And the response should contain json:
      """
      {
        "id": "u1",
        "email": "peter@test.com",
        "username": "peter"
      }
      """

And the good news is, the first scenario is already passing.

The second scenario re-uses two of the step definitions we've already covered, but the third step is new so let's dig in a little deeper.

Really this test is somewhat superflous. JSON by design is encoded in UTF-8, so the mime type (application/json) already implies that the content will be in UTF-8.

But, being the sort of person that gets annoyed when a computer won't behave exactly as I expect it to, fixing the response to contain the exact Content-type that the test requires is not that difficult.

So, let's fix it.

NGINX Configuration Change

The Content-type header is being set by our NGINX web server.

More specifically, the config is being set inside the web site we used Ansible to configure. The expected configuration properties have now been added to the Ansible playbook for NGINX, so if you are to use this moving forwards (after this video has been published) then this problem should be resolved.

However, if you have used the Ansible playbook to follow along so far, or you have manually configured NGINX and want to resolve this problem, then here is how:

sudo vim /etc/nginx/sites-available/symfony-rest-example.dev.conf

Then, inside the server block, add in:

server {

    listen 80;
    # listen 443; # or whatever

    charset utf-8;
    charset_types application/json;

    server_name localhost symfony-rest-example.dev

    # remaining config

}

Save and exit (esc, :wq in vim), and then:

sudo service nginx reload

And you should now get the full application/json; charset=utf-8 set in your Content-type header.

As I say, superflous, but it's good to show the computer who is the boss.

Behat Code For This Step

The Behat code that checks the Content-type is generic enough to check any response header value:

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

The Behat feature step sets the two variables ($header, and $value) by extracting the values from the step definition pattern:

And the response header "Content-Type" should be equal to "application/json; charset=utf-8"

We could swap out "Content-Type" for any other header value - good or bad - and the same method would simply check the headers array for that key, and assertContains / check it contains the value we are passing in using the second set of speech marks.

If you are unsure how we get the response the be sure to check the previous video and show notes.

FOSUserBundle and JMSSerializer

Before I go further with this, I want to point out that I did not write the vast majority of the underlying Behat code that really tests our scenario steps. I covered this in an earlier video, so be sure to watch that if wanting to know why.

Now we only have one remaining failing step in our scenario:

     And the response should contain json:
      """
      {
        "id": "u1",
        "email": "peter@test.com",
        "username": "peter"
      }
      """

It is worth pointing out here that the underlying function that tests our JSON actually won't care if there are more fields here than what we have specified. This is important, so let's cover it in more depth.

Let's assume for a second that our /users end point is returning exactly the response we expect:

{
  "id": "u1",
  "email": "peter@test.com",
  "username": "peter"
}

We could remove some fields here and the test would still pass.

{
  "username": "peter"
}

This would still pass.

Why?

To find out, let's look at the code that tests our JSON response:

    /**
     * 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 = $this->response->json();

        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]);
        }
    }

Ok, let's break this down.

Behat uses PyStringNodes to represent JSON. PyStrings look like a multiline comment in Python, using the three """ double quotes.

Our PyString here is:

  """
  {
    "id": "u1",
    "email": "peter@test.com",
    "username": "peter"
  }
  """

You can read more about PyStrings in the official Behat docs. However, you really don't need to know much more, at this point, than that they are a way for us to write multiline text in our Behat scenario steps.

Behat Etalon Confusion

Probably the most confusing line in this function comes first:

$etalon = json_decode($this->replacePlaceHolder($jsonString->getRaw()), true);

What the heck is an $etalon?

A type of interferometer in which incoming light is repeatedly refracted and reflected between two surfaces into multiple beams that are then focused together, causing self-interference of the light.

But of course! As masters of physics, this should be obvious to us all ;)

Okay, and for the rest of us? Well, as best I can understand it, an etalon here means the expected outcome. Most of the time you will see PHPUnit-like tests written with $expected and $actual. That is, the expected result, and the result the function really / actually produced.

The $etalon in this instance is the result of replacing placeholders (as mentioned in the previous write up, we won't be covering placeholders but think of them as variables inside our scenario steps) in the given PyString, and then using the PHP function json_decode to populate the $etalon variable with data that PHP can understand natively.

If you were to dump out the $etalon at this stage, it would look like this:

array(size=3) 
  'id' => string 'u1' (length=2)
  'email' => string 'peter@test.com' (length=14)
  'username' => string 'peter' (length=5)

In summary, this line is converting the JSON we wrote in our Behat scenario step into a format PHP can understand.

Errors At Runtime

If the json_decode outcome fails (think, you left a comma in the wrong place or similar), then Behat will error out with the "Can not convert etalon to json" error.

Incidentally, a \RuntimeException is thrown because this is an error that occurs because of the way the end user (us, in this case) interacted with the given function ;) Aka... we dun' goofed.

Check Yourself Before You Wreck Yourself :: Assertions

Lastly we get to the assertion lines. Here is where the potentially unexpected outcome could arise.

Remember, we are able to remove some of the JSON lines from our PyString and still get a passing test. Why is this?

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

Probably easiest to explain with a quick example.

Let's assume we have our three line PyString, and that our actual real response contains:

{
  "id": "u1",
  "email": "peter@test.com",
  "username": "peter",
  "more": "data",
  "goes": "here"
}

We are using the PHPUnit assertion library, and the method signature for our assertion is:

assertGreaterThanOrEqual(mixed $expected, mixed $actual[, string $message = ''])

Disregard the $message, it is optional and we aren't using it:

assertGreaterThanOrEqual(mixed $expected, mixed $actual)

In our example, $etalon contains a count of 3 fields: id, email, and username.

A count of $actual gives 5 fields - as above, plus more and goes.

Although the function reads a little backwards (to me), the test is:

is 5 greater than or equal to 3? Yes.

We haven't checked the underlying data here, just that the 'amount' of data seems right.

Checking Each Value

As we know our $etalon is really just an array, we can do a standard foreach loop over the key / value pairs and assert they match what we expect.

This is really where the more intricate checks take place.

Now we aren't just comparing counts, but rather that the field / property names match.

Our expected JSON should contain a key: id, and a key: email, etc:

Assertions::assertArrayHasKey($key, $actual);

If we don't find that key, the test would fail. We can assume we did find the key if we get further, so let's check if the associated value for that key matches the key/value pair in the real result:

Assertions::assertEquals($etalon[$key], $actual[$key]);

In summary, for what is a one line step in Behat (excluding the multiline PyString element), is actually quite an interesting bit of code behind the scenes.

I guess that sums up Behat entirely :)

Back On Track

With that detour through the internals of our RestApiContext, we were discussing why the tests will still pass if there are more fields than we expect in our actual JSON response.

This all comes down to Behaviour.

Essentially we care that the three fields are returned. If more fields are returned, do we care? As long as the fields we want are there, and they match our expectations, the application / API is behaving as expected. From a certain point of view.

This can be really useful in other tests where we might get back a new resource, and we can't assert the ID field.

Let's say we make a POST request to /users, and the result is something like:

{
  "id": "cd171f7c-560d-4a62-8d65-16b87419a58c",
  "email": "peter@test.com",
  "username": "peter"
}

Now, obviously this is junk - but pretend that ID makes sense in the context of our API.

We can't write a test for this - not directly - as the next time we run our test suite, the API will return a different response:

{
  "id": "11cdd494-de5b-460e-9ddf-4e84bad6f596",
  "email": "peter@test.com",
  "username": "peter"
}

What should we do?

Please don't say: "Fake IDs for our test environment" (I have seen this before, eurghhh).

The simple solution here is to simply ignore the "id" entirely in our PyString for the expected response:

{
  "email": "peter@test.com",
  "username": "peter"
}

It's a bit of a hack, and doesn't really solve the problem. But it does work.

I will show you a better way to do this in a future video - using Sanpi Behatch JSON Schema checks :)

Code For This Course

Get the code for this course.

Episodes