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:

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

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:

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