Setup - Behat
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:
(edit: updated 4th July 2017 - see comments for why)
<?php
namespace AppBundle\Features\Context;
use Behat\Behat\Context\Context;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7;
use PHPUnit_Framework_Assert as Assertions;
use Behatch\Json\JsonInspector;
use 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();
/**
* @var
*/
private $dummyDataPath;
/**
* RestApiContext constructor.
* @param ClientInterface $client
*/
public function __construct(ClientInterface $client, $dummyDataPath = null)
{
$this->client = $client;
$this->dummyDataPath = $dummyDataPath;
// strangeness with guzzle?
$this->addHeader('accept', '*/*');
}
/**
* 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: "([^"]*)"$/
* @throws \GuzzleHttp\Exception\GuzzleException
*/
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
* @param array $data
*
* @When /^(?:I )?send a "([A-Z]+)" request to "([^"]+)"$/
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function iSendARequest($method, $url, array $data = [])
{
$url = $this->prepareUrl($url);
$data = $this->prepareData($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:$/
* @throws \GuzzleHttp\Exception\GuzzleException
*/
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()->request($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:$/
* @throws \GuzzleHttp\Exception\GuzzleException
*/
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:$/
* @throws \GuzzleHttp\Exception\GuzzleException
*/
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()->request($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:$/
* @param $method
* @param $url
* @param TableNode $post
*
* @throws \GuzzleHttp\Exception\GuzzleException
* @throws \DomainException
*/
public function iSendAMultipartRequestToWithFormData($method, $url, TableNode $post)
{
$url = $this->prepareUrl($url);
$fileData = $post->getColumnsHash()[0];
if ( ! array_key_exists('filePath', $fileData)) {
throw new \DomainException('Multipart requests require a `filePath` Behat table node');
}
$filePath = $this->dummyDataPath . $fileData['filePath'];
unset($fileData['filePath']);
$data['multipart'] = [
[
'name' => 'name', // symfony form field name
'contents' => $fileData['name'],
],
[
'name' => 'uploadedFile', // symfony form field name
'contents' => fopen($filePath, 'rb'),
]
];
// remove the Content-Type header here as it will have been set to `application/json` during the successful
// login, that preceeds this step in the Behat Background setup
$this->removeHeader('Content-Type');
$data = $this->prepareData($data);
try {
$this->response = $this->getClient()->request($method, $url, $data);
} catch (RequestException $e) {
if ($e->hasResponse()) {
$this->response = $e->getResponse();
}
}
}
/**
* Checks that response has specific status code.
*
* @param string $code status code
*
* @Then the response code should be :arg1
*/
public function theResponseCodeShouldBe($code)
{
$expected = (int)$code;
$actual = (int)$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];
if ( ! $this->hasHeader('Authorization')) {
$responseBody = json_decode($this->response->getBody(), true);
$this->addHeader('Authorization', 'Bearer ' . $responseBody['token']);
}
$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 \Behatch\Json\Json($this->response->getBody());
$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
* @throws \Exception
*/
public function theJsonNodeShouldBeEqualTo($node, $text)
{
$json = new \Behatch\Json\Json($this->response->getBody());
$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 ( ! $this->hasHeader($name)) {
$this->headers[$name] = $value;
}
if (!is_array($this->headers[$name])) {
$this->headers[$name] = [$this->headers[$name]];
}
$this->headers[$name] = $value;
}
protected function hasHeader($name)
{
return isset($this->headers[$name]);
}
/**
* 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 :)