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
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
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
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
After installing above extension, below file structure will be created:
|config/
| - services_test.yaml
|features/
| - demo.feature
|tests/
| - Behat/
| - DemoContext.php
|behat.yml.dist
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
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"
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
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"
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
{
}
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);
}
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
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());
}
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');
}
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
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
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());
}
}
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([]);
}
}
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);
}
Then we can use it in test scenario:
And the fixture "Fixtures\PostsData" is appended
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
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
In the terminal, we should see the results of our tests. It should look something like this:
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)