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 POST
ing 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.