Donate to protect them directly or help international organizations.
Structuring the Test Harness
In this guide, I will walk you through my general structure of the step definitions, and the classes supporting them. The examples will be as simple as possible to keep things easy to understand. See a working example on GitHub and adapt that code to your needs.
I will use Behat and PHP for the examples, but the concepts can be adapted to all BDD frameworks.
Resetting the Application State
Before we begin, make sure that the system under test connects to a non-production database. I usually have a local database called acceptance
that is used only for acceptance tests.
I execute each scenario starting from a predictable state. For most apps, this means cleaning the database from the previous run. I usually have an SQL file that truncates the tables that I expect to change during scenario execution. I can optionally seed tables with minimal data.
Example in MySQL:
-- reset.sql
TRUNCATE TABLE `receipt`;
TRUNCATE TABLE `currency`;
INSERT INTO `currency` (`code`) VALUES ("EUR"), ("USD");
BDD frameworks provide a means to execute code before each scenario. I like to do it before instead of after, in case the previous run crashed and failed to clean after itself. Here is what this could look like in a Behat context (other BDD frameworks may call it step definitions):
// class ResetDatabaseContext
/**
* @BeforeScenario
*/
public function beforeScenario(): void
{
$this->database->reset();
}
$this->database
refers to a simple class that interacts with the database. Here is the interface:
interface Database
{
public function reset(): void;
}
Here is a minimalist implementation using PDO. See towards the end of this guide to learn how to inject various classes into the step definitions.
class PdoDatabase implements Database
{
public function __construct(
private PDO $pdo
) {}
public function reset(): void
{
$sql = file_get_contents('reset.sql');
$this->pdo->exec($sql);
}
}
The constructor expects a connection to the database that the application will be using during tests. We then load the file and call $this->pdo->exec
to execute all the statements.
The PDO instance may look like this:
// services.php
$pdo = new PDO(
getenv('APP_DB_DSN'),
getenv('APP_DB_USER'),
getenv('APP_DB_PASS'),
[
// Make PDO always throw exceptions instead of having to check ->errorCode().
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]
);
When resetting the application state, we may also need to clear the upload directory and anything else that the application affects during its execution. The pattern remains the same.
Creating the New Application State
Each test scenario depends on an application being in a specific state. Examples:
Given my password has expired
Given I am logged in as an administrator
Given I am registered for course "A"
Given I have an unpaid speeding ticket
The application will behave differently depending on that state. If we have an unpaid speeding ticket, perhaps the system will not allow us to purchase any products until we pay the fine.
Here's an example of a step definition:
// class FeatureContext
/**
* @Given my password has expired
*/
public function passwordExpired()
{
$this->database->update(
'user',
['password_updated_at' => '2005-01-01 12:00:00'],
['username' => 'admin'],
);
}
The update method is just a convenient way to generate and execute SQL. We add the update
method to the interface:
interface Database
{
public function update(string $table, array $data, array $criteria): void;
}
The implementation can be as simple as a concatenation, but feel free to use libraries that construct SQL for you:
class PdoDatabase implements Database
{
public function update(string $table, array $data, array $criteria): void
{
$statement = $this->pdo->prepare(/* create SQL from above arguments */);
$statement->execute();
}
}
You should be able to use a similar approach for inserting records as well.
Executing the Action
Say we have a web application where the user can visit pages and submit forms:
Given I am logged in as "admin"
When I visit "/home"
I usually write code in this order:
- Create a step definition where I interact with the application.
- From that interaction, an Application interface will emerge.
- Implement the interface.
The step definitions may look like this:
// class FeatureContext
/**
* @Given I am logged in as :string
*/
public function iAmLoggedInAs(string $username)
{
$this->application->submitForm('/login', [
'username' => $username,
'password' => self::PASSWORD,
]);
}
/**
* @When I visit :string
*/
public function iVisit(string $page)
{
$this->application->visit($page);
}
The purpose here is to determine how I want to interact with a $this->application
service, which doesn't exist yet. I'll later show you how to inject application
and other services into this class. From the usages above, I determined the following methods:
interface WebApplication
{
public function submitForm(string $url, array $data): void;
public function visit(string $url): void;
}
Here is what an implementation using a library called Guzzle might look like this:
final class GuzzleWebApplication implements WebApplication
{
/** @var \Psr\Http\Message\ResponseInterface[] */
private array $responses = [];
public function __construct(
private \GuzzleHttp\ClientInterface $guzzle,
) {}
public function submitForm(string $url, array $data): void
{
$this->responses[] =
$this->guzzle->request('POST', $url, [
'form_params' => $data,
]);
}
public function visit(string $url): void
{
$this->responses[] =
$this->guzzle->request('GET', $url);
}
}
The class stores all responses in a private array, so that in Then
steps, we could verify the result. I'll show the response verification in detail later.
The two public methods send POST and GET requests.
The constructor expects a Guzzle client, which is how we'll send HTTP requests to the application. Here is an example of an instance that we could provide to the constructor:
// services.php
new \GuzzleHttp\Client([
'base_uri' => 'http://application.test',
'allow_redirects' => true,
'cookies' => true,
'http_errors' => false,
]);
base_uri
is the location of your system under test. Ensure that this application runs in test mode and using the acceptance database, since we'll be truncating tables there.allow_redirect
are especially useful when we submit forms, which typically redirect after saving.cookies
set to true will reuse the same cookies between two requests, which is an easy way for testing stateful applications. You can login in one step and access a protected page in the next, and it will just work.http_errors
will allow us to capture errors such as Not Found and Not Authorized. We don't necessarily want our tests to crash if it's not a 2xx response. We may need to test something likeGiven I attempt to view a receipt that belongs to another user
.
Verifying the Result
A simple verification could check for the presence of some text in the HTML:
Then I should see "Welcome, admin"
This requires us to get the body of the HTTP response, crawl its HTML and make assertions about what we find. Should the assertion fail, we throw an exception.
I chose Symfony's DomCrawler to extract text from the HTML, and Benjamin Eberlei's Assert to throw exceptions when the HTML does not satisfy certain criteria.
// class FeatureContext
/**
* @Then I should see :string
*/
public function iShouldSee(string $expectedText): void
{
$html = (string) $this->application->getLastResponse()->getBody();
$crawler = new \Symfony\Component\DomCrawler\Crawler($html);
$pageText = $crawler->text();
\Assert\Assert::that($pageText)->contains($expectedText);
}
Should the $pageText
not contain the supplied $expectedText
, an exception will be thrown and Behat will consider that scenario as failed.
In the Application interface that we previously created, we'll need to add a new method to get the last response:
interface WebApplication
{
public function getLastResponse(): ResponseInterface;
}
Here is the implementation of that method:
final class GuzzleWebApplication implements WebApplication
{
public function getLastResponse(): ResponseInterface
{
return $this->responses[array_key_last($this->responses)];
}
}
Inject Services
Every time we call an interface like $this->database
, it means that we injected a service into the constructor:
class ResetDatabaseContext implements Context
{
public function __construct(
private Database $database,
) {}
}
This requires us to use a Dependency Injection Container (DIC). In Behat, this means two steps:
- Instruct Behat to use an extension for allowing the use of a DIC.
- Use a DIC of your choice to configure the services.
Install Roave's PSR-11 Container extension, then tell Behat to refer to services.php
whenever it encounters constructor arguments:
# behat.yml
default:
suites:
#...
extensions:
Roave\BehatPsrContainer\PsrContainerExtension:
container: 'services.php'
For our container, we'll use PHP-DI. The extension doesn't care which container we use, as long as it's compliant with PSR-11, which PHP-DI happens to be.
// services.php
$container[Database::class] =
DI\factory(static function (ContainerInterface $container): Database {
return new PdoDatabase(
new PDO(/**/)
);
});
return (new DI\ContainerBuilder())
->addDefinitions($container)
->build();
First, we tell it that whenever we encounter a Database
interface in a constructor to please call this closure (factory) to instantiate a class implementing that interface. We go ahead and instantiate a PdoDatabase
, which in turn needs a PDO
instance with the right connection arguments.
That's it. Whenever Behat instantiates a class requiring Database
, it will get the fully configured PdoDatabase
instance. The pattern is the same for other services.
You can see how this all fits together on GitHub.
Learn about more topics