Integrating Behat with Laravel
Published by Matthew Daly at 18th February 2017 9:25 pm
The Gherkin format used by tools like Cucumber is a really great way of specifying how your application will work. It's easy for even non-technical stakeholders to understand, it makes it natural to break your tests into easily reusable steps, and it encourages you to think about the application from an end-user's perspective. It's also one of the easiest ways to get started writing automated tests when you first start out - it's much more intuitive to a junior developer than lower-level unit tests, and is easier to add to a legacy project that may not have been built with testability in mind - if you can drive a browser, you can test it.
Behat is a PHP equivalent. Combined with Mink, it allows for easy automated acceptance tests of a PHP application. However, out of the box it doesn't integrate well with Laravel. There is Jeffrey Way's Behat Laravel extension, but it doesn't seem to be actively maintained and seems to be overkill for this purpose. I wanted something that I could use to run integration tests using PHPUnit's assertions and Laravel's testing utilities, and crucially, I wanted to do so as quickly as possible. That meant running a web server and using an automated web browser wasn't an option. Also, I often work on REST API's, and browser testing isn't appropriate for those - in API tests I'm more interested in setting up the fixtures, making a single request, and verifying that it does what it's meant to do, as quickly as possible.
As it turns out, integrating Behat and Laravel isn't that hard. When using Behat, your FeatureContext.php
file must implement the Behat\Behat\Context\Context
interface, but as this interface does not implement any methods, you can extend any existing class and declare that it implements that interface. That means we can just extend the existing Tests\TestCase
class in Laravel 5.4 and gain access to all the same testing utilities we have in our regular Laravel tests.
Then, in the constructor we can set environment variables using putenv()
so that we can set it up to use an in-memory SQLite database for faster tests. We also use the @BeforeScenario
hook to migrate the database before each scenario, and the @AfterScenario
hook to roll it back afterwards.
Here's the finished example:
1<?php23use Behat\Behat\Context\Context;4use Behat\Gherkin\Node\PyStringNode;5use Behat\Gherkin\Node\TableNode;6use Tests\TestCase;7use Behat\Behat\Tester\Exception\PendingException;8use Illuminate\Foundation\Testing\DatabaseMigrations;9use App\User;10use Behat\Behat\Hook\Scope\BeforeScenarioScope;11use Behat\Behat\Hook\Scope\AfterScenarioScope;12use Illuminate\Contracts\Console\Kernel;1314/**15 * Defines application features from the specific context.16 */17class FeatureContext extends TestCase implements Context18{19 use DatabaseMigrations;2021 protected $content;2223 /**24 * Initializes context.25 *26 * Every scenario gets its own context instance.27 * You can also pass arbitrary arguments to the28 * context constructor through behat.yml.29 */30 public function __construct()31 {32 putenv('DB_CONNECTION=sqlite');33 putenv('DB_DATABASE=:memory:');34 parent::setUp();35 }3637 /** @BeforeScenario */38 public function before(BeforeScenarioScope $scope)39 {40 $this->artisan('migrate');4142 $this->app[Kernel::class]->setArtisan(null);43 }4445 /** @AfterScenario */46 public function after(AfterScenarioScope $scope)47 {48 $this->artisan('migrate:rollback');49 }5051 /**52 * @Given I visit the path :path53 */54 public function iVisitThePath($path)55 {56 $response = $this->get('/');57 $this->assertEquals(200, $response->getStatusCode());58 $this->content = $response->getContent();59 }6061 /**62 * @Then I should see the text :text63 */64 public function iShouldSeeTheText($text)65 {66 $this->assertContains($text, $this->content);67 }6869 /**70 * @Given a user called :user exists71 */72 public function aUserCalledExists($user)73 {74 $user = factory(App\User::class)->create([75 'name' => $user,76 ]);77 }7879 /**80 * @Given I am logged in as :user81 */82 public function iAmLoggedInAs($user)83 {84 $user = User::where('name', $user)->first();85 $this->be($user);86 }8788}
Note that I've added a few basic example methods for our tests. As you can see, we can call the same methods we normally use in Laravel tests to make assertions and HTTP requests. If you're using Dusk, you can also call that in the same way you usually would.
We might then write the following feature file to demonstrate our application at work:
1Feature: Login23 Background:4 Given a user called "Alan" exists5 And a user called "Bob" exists6 And a user called "Clare" exists7 And a user called "Derek" exists8 And a user called "Eric" exists910 Scenario: Log in as Alan11 Given I am logged in as "Alan"12 And I visit the path "/"13 Then I should see the text "Laravel"1415 Scenario: Log in as Bob16 Given I am logged in as "Bob"17 And I visit the path "/"18 Then I should see the text "Laravel"1920 Scenario: Log in as Clare21 Given I am logged in as "Clare"22 And I visit the path "/"23 Then I should see the text "Laravel"2425 Scenario: Log in as Derek26 Given I am logged in as "Derek"27 And I visit the path "/"28 Then I should see the text "Laravel"2930 Scenario: Log in as Eric31 Given I am logged in as "Eric"32 And I visit the path "/"33 Then I should see the text "Laravel"
We can then run these tests with vendor/bin/behat
:
1$ vendor/bin/behat2Feature: Login34 Background: # features/auth.feature:35 Given a user called "Alan" exists # FeatureContext::aUserCalledExists()6 And a user called "Bob" exists # FeatureContext::aUserCalledExists()7 And a user called "Clare" exists # FeatureContext::aUserCalledExists()8 And a user called "Derek" exists # FeatureContext::aUserCalledExists()9 And a user called "Eric" exists # FeatureContext::aUserCalledExists()1011 Scenario: Log in as Alan # features/auth.feature:1012 Given I am logged in as "Alan" # FeatureContext::iAmLoggedInAs()13 And I visit the path "/" # FeatureContext::iVisitThePath()14 Then I should see the text "Laravel" # FeatureContext::iShouldSeeTheText()1516 Scenario: Log in as Bob # features/auth.feature:1517 Given I am logged in as "Bob" # FeatureContext::iAmLoggedInAs()18 And I visit the path "/" # FeatureContext::iVisitThePath()19 Then I should see the text "Laravel" # FeatureContext::iShouldSeeTheText()2021 Scenario: Log in as Clare # features/auth.feature:2022 Given I am logged in as "Clare" # FeatureContext::iAmLoggedInAs()23 And I visit the path "/" # FeatureContext::iVisitThePath()24 Then I should see the text "Laravel" # FeatureContext::iShouldSeeTheText()2526 Scenario: Log in as Derek # features/auth.feature:2527 Given I am logged in as "Derek" # FeatureContext::iAmLoggedInAs()28 And I visit the path "/" # FeatureContext::iVisitThePath()29 Then I should see the text "Laravel" # FeatureContext::iShouldSeeTheText()3031 Scenario: Log in as Eric # features/auth.feature:3032 Given I am logged in as "Eric" # FeatureContext::iAmLoggedInAs()33 And I visit the path "/" # FeatureContext::iVisitThePath()34 Then I should see the text "Laravel" # FeatureContext::iShouldSeeTheText()35365 scenarios (5 passed)3740 steps (40 passed)380m0.50s (19.87Mb)
Higher level tests can get very tedious if you're not careful - you wind up setting up the same fixtures and making the same requests many times over. By using Behat in this way, not only are you writing your tests in a way that is easy to understand, but you're also breaking it down into logical, repeatable steps, and by passing arguments in each step you limit the amount of repetition. It's also fast if you aren't running browser-based tests, making it particularly well-suited to API testing.