DEV Community

Jarosław Szutkowski
Jarosław Szutkowski

Posted on

Behat: The Easy and Effective Way to Write Acceptance Tests

Behavior-Driven Development (BDD) is a software development methodology that focuses on defining the desired behavior of an application in terms of concrete examples. It is based on the idea that the behavior of a software system can be described in a language that is accessible to both technical and non-technical stakeholders. This approach helps to ensure that the software system is aligned with the business requirements and expectations.

If you are interested in adopting Behavior-Driven Development in your PHP project, Behat and Gherkin provide a powerful set of tools for implementing it. They allow developers to focus on the behavior of their application rather than the implementation details. In this blog post, I will provide a step-by-step guide on how to install and configure Behat for writing acceptance tests in Symfony application using the Gherkin language. By following the instructions outlined in this post, you will be able to set up Behat and start writing effective and efficient acceptance tests for your Symfony application.

Behat

Behat is an open-source BDD framework for PHP that allows developers to write and execute tests in a human-readable language called Gherkin. Behat tests can be written in a simple, non-technical language that is easy to understand by stakeholders, such as product owners or business analysts. Behat allows developers to focus on the behavior of the application rather than the implementation details.

Gherkin

Tests written in Gherkin are structured in a scenario outline format, consisting of a series of steps that describe the behavior of an application. The steps are written in a plain natural language and are organized into Given, When, and Then clauses, that describe the preconditions, actions, and expected outcomes of the test. The scenarios can be further organized into feature files, that group related scenarios together and provide a clear and concise overview of the behavior being tested.

Installing dependencies

To set up our test environment we have to install a few external dependencies.

Let's start with Behat:

composer require behat/behat --dev
Enter fullscreen mode Exit fullscreen mode

Another dependency is MinkExtension - a Behat extension that provides integration between Behat and Mink (a web-testing framework for PHP). It allows developers to write Behat scenarios that interact with web pages and test the functionalities of web applications. MinkExtension provides a set of pre-defined steps that can be used to perform common web interactions, such as clicking links, filling in forms, and validating page content.

composer require friends-of-behat/mink-extension --dev
Enter fullscreen mode Exit fullscreen mode

BehatChromeExtension is a Behat extension that provides integration between Behat and Google Chrome browser. It allows developers to execute Behat tests using the Chrome browser as the testing environment. With BehatChromeExtension, developers can simulate user interactions with the web application and test its behavior in a real-world scenario. This extension provides a more realistic testing environment and can help to uncover issues that may not be visible in a headless browser environment.

composer require dmore/behat-chrome-extension --dev
Enter fullscreen mode Exit fullscreen mode

FriendsOfBehat/SymfonyExtension is the extension that provides integration between Behat and Symfony framework. It allows developers to write Behat scenarios that interact with Symfony applications and test their functionality. The extension provides a set of pre-defined steps that can be used to perform common Symfony-specific interactions, such as interacting with Doctrine, using the service container, and testing routes. The extension also provides support for Behat's dependency injection and configuration management features, making it easier to manage and maintain Behat tests in Symfony projects.

composer require friends-of-behat/symfony-extension --dev
Enter fullscreen mode Exit fullscreen mode

After installing above extension, below file structure will be created:

|config/
| - services_test.yaml
|features/
| - demo.feature
|tests/
| - Behat/
|   - DemoContext.php
|behat.yml.dist
Enter fullscreen mode Exit fullscreen mode

By default, SymfonyExtension attempts to load the bootstrap.php file, which does not exist in a Symfony application yet. However, we can use the file that we use for PHPUnit.

To run tests that will click through our application, a browser will be required. In this case, Google Chrome will be used. Below you can see a code snippet from the Dockerfile that installs the browser.

# Install chromedriver
RUN apt-get -y install gnupg
RUN wget -qO - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/googlechrome-linux-keyring.gpg
RUN echo "deb [arch=amd64 signed-by=/usr/share/keyrings/googlechrome-linux-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list
RUN apt-get -y update && apt-get -y install google-chrome-stable

Enter fullscreen mode Exit fullscreen mode

Behat configuration

Behat is configured in the behat.yml file. In my case, the file looks like this:

default:
  suites:
    ui_posts:
      contexts:
          - App\Behat\Context\PostsContext  
          - App\Behat\Context\FixturesContext
      filters:
        tags: "@posts"
#      paths: ['%paths.base%/features/posts']
  extensions:
    FriendsOfBehat\SymfonyExtension:
      bootstrap: tests/bootstrap.php
      kernel:
        class: 'App\Kernel'
        environment: test
        debug: false      

    DMore\ChromeExtension\Behat\ServiceContainer\ChromeExtension: ~

    Behat\MinkExtension:
      browser_name: chrome
      base_url: http://localhost
      sessions:
        default:
          chrome:
            api_url: "http://localhost:9222"
Enter fullscreen mode Exit fullscreen mode

I have configured a test suite to test a simple CRUD blog application. If we have multiple test suites, we can tag them (in this case, @posts) to ensure that only tests with that specific tag are run in the context of that suite. Alternatively, we can set paths to the feature directories. Otherwise, all test suites will be run.

In the extensions section, we can configure additional add-ons, such as the SymfonyExtension or MinkExtension. The ChromeExtension has default configuration.

Writing the first test

As I mentioned earlier, we write our test scenarios in Gherkin, a language that's understandable for the business. In case of our simple CRUD, we want to test the functionality of displaying, adding, editing, and deleting posts. To do this, we need to create a posts.feature file in the features directory.

In BDD, there are no formal requirements on how to write user stories, but there is a widely-used standard. First, we add a Feature section where we name our functionality. Then, we describe the functionality and the business value it provides. We can do this in three lines: "In order to...", "As a...", "I want to...".

@posts
Feature: Managing posts
  In order to manage posts
  As a writer
  I want to add, edit, delete and display blog posts
Enter fullscreen mode Exit fullscreen mode

After this section, we can add test scenarios. The first one for me will be the ability to navigate to the add post page from the listing page.

  Scenario: Writer goes to create post page
    Given I am on "/post/"
    When I follow "Create new"
    Then I should be on "/post/new"
Enter fullscreen mode Exit fullscreen mode

As we can see, our scenario is written in a natural language that is understandable to humans. But how can we make Behat understand what we want to test? This is where contexts come in. In the configuration file shown earlier, you may notice a reference to the App\Behat\Context\PostsContext class. Contexts are used to translate natural language into a language that Behat can understand.

In this test, PostsContext extends MinkContext, which has implementations of each step in the above scenario, as well as many others, that allows us to reuse basic steps without having to write them from scratch.

use Behat\MinkExtension\Context\MinkContext;

class PostsContext extends MinkContext
{
}
Enter fullscreen mode Exit fullscreen mode

In the MinkContext class, we can find a method:

/**
 * @Given /^(?:|I )am on "(?P<page>[^"]+)"$/
 * @When /^(?:|I )go to "(?P<page>[^"]+)"$/
 */
public function visit($page)
{
    $this->visitPath($page);
}
Enter fullscreen mode Exit fullscreen mode

When we write Given I am on "/post/" in the posts.feature file in our scenario, Behat will find and call this method based on the annotations contained in the PostsContext class.

Let's now write another scenario that adds a post and verifies if it is displayed on the listing.

  Scenario: Writer wants to add a new post and see it on the posts page
    Given I am on "/post/new"
    When I fill in "Title" with "An example post"
    And I fill in "Content" with "Lorem ipsum"
    And I press "Save"
    Then I should be on "/post/"
    And On the posts list I can see post with title "An example post" 1 time
Enter fullscreen mode Exit fullscreen mode

In this scenario, we add a new post by filling out the form, save it and check if it appears on the listing page. The last step, which checks how many times the title is found on the page, cannot be found in MinkExtension, and we have to write it ourselves. It may look like this:

/**
 * @Then /^On the posts list I can see post with title "([^"]*)" (\d+) times$/
 * @Then /^On the posts list I can see post with title "([^"]*)" (\d+) time$/
 * @throws ExpectationException
 */
public function onThePostsListICanSeeTitleNTimes(string $title, int $count): void
{
    $element = $this->getSession()->getPage();

    $result = $element->findAll('xpath', "//*[contains(text(), '$title')]");

    $resultCount = \count($result);

    if ($resultCount == $count && str_contains(reset($result)->getText(), $title)) {
        return;
    }

    throw new ExpectationException(sprintf('"%s" was expected to appear %d times, got %d',
        $title,
        $count,
        $resultCount
    ), $this->getSession());
}
Enter fullscreen mode Exit fullscreen mode

Sometimes it may be necessary to group several steps into one, for example when accessing our application requires authentication, which requires going to the login page, entering a username and password, and submitting the form. We can do this in the following way:

/**
 * @Given I am logged in as admin
 */
public function iAmLoggedInAsAdmin(): void
{
    $this->visit('/login');
    $this->fillField('email', 'admin@admin');
    $this->fillField('password', 'admin1');
    $this->pressButton('Sign in');
}
Enter fullscreen mode Exit fullscreen mode

We can add the above step before executing each test scenario. To do this, add a Background section in the posts.feature file before the first scenario.

  Background:
    Given I am logged in as admin
Enter fullscreen mode Exit fullscreen mode

The entire posts.feature file now looks like this:

@posts
Feature: Managing posts
  In order to manage posts
  As a writer
  I want to add, edit, delete and display blog posts

  Background:
    Given I am logged in as admin

  Scenario: I want to go to create post page
    Given I am on "/post/"
    When I follow "Create new"
    Then I should be on "/post/new"

  Scenario: Writer wants to add a new post and see it on the posts page
    Given I am on "/post/new"
    When I fill in "Title" with "An example post"
    And I fill in "Content" with "Lorem ipsum"
    And I press "Save"
    Then I should be on "/post/"
    And On the posts list I can see post with title "An example post" 1 time
Enter fullscreen mode Exit fullscreen mode

The PostsContext file looks like this:

use Behat\Mink\Exception\ExpectationException;
use Behat\MinkExtension\Context\MinkContext;

class PostsContext extends MinkContext
{
    /**
     * @Given I am logged in as admin
     */
    public function iAmLoggedInAsAdmin(): void
    {
        $this->visit('/login');
        $this->fillField('email', 'admin@admin');
        $this->fillField('password', 'admin1');
        $this->pressButton('Sign in');
    }

    /**
     * @Then /^On the posts list I can see post with title "([^"]*)" (\d+) times$/
     * @Then /^On the posts list I can see post with title "([^"]*)" (\d+) time$/
     * @throws ExpectationException
     */
    public function onThePostsListICanSeeTitleNTimes(string $title, int $count): void
    {
        $element = $this->getSession()->getPage();

        $result = $element->findAll('xpath', "//*[contains(text(), '$title')]");

        $resultCount = \count($result);

        if ($resultCount == $count && \str_contains(\reset($result)->getText(), $title)) {
            return;
        }

        throw new ExpectationException(\sprintf('"%s" was expected to appear %d times, got %d',
            $title,
            $count,
            $resultCount
        ), $this->getSession());
    }
}
Enter fullscreen mode Exit fullscreen mode

Adding fixtures

Before executing each scenario, we want to log in. However, it may turn out that there are no users in the database.

Furthermore, after each test run, another post with the same title and content would be added to the database. This would cause the test to fail on the second run.

It's good to clean the database and add test data (Fixtures) before running each scenario. I wrote about using fixtures in Symfony in this post: Using Fixtures In Testing Symfony Application

To load test data, let's create a FixturesContext, which will be responsible for this task. In my case, it looks like this:

namespace App\Behat\Context;

use Behat\Behat\Context\Context;
use Liip\TestFixturesBundle\Services\DatabaseToolCollection;

class FixturesContext implements Context
{
    public function __construct(
        private readonly DatabaseToolCollection $databaseToolCollection,
    ) {}

    /**
     * @BeforeScenario @fixtures
     */
    public function loadFixtures(): void
    {
        $this->databaseToolCollection->get()->loadFixtures([]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The above class contains one method which is responsible for loading fixtures. It has the @BeforeScenario annotation, which means that it will be executed before each scenario. We can limit loading fixtures only to the scenarios that need them by adding a specific tag (in this case, @fixtures). Now, before each scenario with this tag, sample data will be loaded.

The above example is not very flexible and would load the same fixtures every time, even if we don't need all of them. Instead, we can write another step in the FixturesContext, to load the fixtures we need in the particular scenario, without tagging it with @fixtures annotation:

    /**
     * @Given the fixture :className is appended
     */
    public function theFixtureIsAppended(string $fixtureClass): void
    {
        $this->databaseToolCollection->get()->loadFixtures([
            $fixtureClass
        ], true);
    }
Enter fullscreen mode Exit fullscreen mode

Then we can use it in test scenario:

And the fixture "Fixtures\PostsData" is appended
Enter fullscreen mode Exit fullscreen mode

Running tests

To run the tests, we need to perform two steps. The first one is to start the browser. We can do it by running the following command:

google-chrome-stable --disable-gpu --headless --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 --no-sandbox
Enter fullscreen mode Exit fullscreen mode

When the browser is running, we can run the tests. We do this by executing the following command (please remember to set APP_ENV=test in the .env file)

vendor/bin/behat
Enter fullscreen mode Exit fullscreen mode

In the terminal, we should see the results of our tests. It should look something like this:

Image description

Summary

Behat is a tool that enables writing tests in a way that is understandable both for business and developers.

To start writing simple tests, you need to install a few packages and create test scenarios. Many steps are handled by MinkExtension, which certainly makes it easier for the first approach to BDD.

Top comments (0)