Integrating Behat with Laravel

Published by 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<?php
2
3use 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;
13
14/**
15 * Defines application features from the specific context.
16 */
17class FeatureContext extends TestCase implements Context
18{
19 use DatabaseMigrations;
20
21 protected $content;
22
23 /**
24 * Initializes context.
25 *
26 * Every scenario gets its own context instance.
27 * You can also pass arbitrary arguments to the
28 * 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 }
36
37 /** @BeforeScenario */
38 public function before(BeforeScenarioScope $scope)
39 {
40 $this->artisan('migrate');
41
42 $this->app[Kernel::class]->setArtisan(null);
43 }
44
45 /** @AfterScenario */
46 public function after(AfterScenarioScope $scope)
47 {
48 $this->artisan('migrate:rollback');
49 }
50
51 /**
52 * @Given I visit the path :path
53 */
54 public function iVisitThePath($path)
55 {
56 $response = $this->get('/');
57 $this->assertEquals(200, $response->getStatusCode());
58 $this->content = $response->getContent();
59 }
60
61 /**
62 * @Then I should see the text :text
63 */
64 public function iShouldSeeTheText($text)
65 {
66 $this->assertContains($text, $this->content);
67 }
68
69 /**
70 * @Given a user called :user exists
71 */
72 public function aUserCalledExists($user)
73 {
74 $user = factory(App\User::class)->create([
75 'name' => $user,
76 ]);
77 }
78
79 /**
80 * @Given I am logged in as :user
81 */
82 public function iAmLoggedInAs($user)
83 {
84 $user = User::where('name', $user)->first();
85 $this->be($user);
86 }
87
88}

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: Login
2
3 Background:
4 Given a user called "Alan" exists
5 And a user called "Bob" exists
6 And a user called "Clare" exists
7 And a user called "Derek" exists
8 And a user called "Eric" exists
9
10 Scenario: Log in as Alan
11 Given I am logged in as "Alan"
12 And I visit the path "/"
13 Then I should see the text "Laravel"
14
15 Scenario: Log in as Bob
16 Given I am logged in as "Bob"
17 And I visit the path "/"
18 Then I should see the text "Laravel"
19
20 Scenario: Log in as Clare
21 Given I am logged in as "Clare"
22 And I visit the path "/"
23 Then I should see the text "Laravel"
24
25 Scenario: Log in as Derek
26 Given I am logged in as "Derek"
27 And I visit the path "/"
28 Then I should see the text "Laravel"
29
30 Scenario: Log in as Eric
31 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/behat
2Feature: Login
3
4 Background: # features/auth.feature:3
5 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()
10
11 Scenario: Log in as Alan # features/auth.feature:10
12 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()
15
16 Scenario: Log in as Bob # features/auth.feature:15
17 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()
20
21 Scenario: Log in as Clare # features/auth.feature:20
22 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()
25
26 Scenario: Log in as Derek # features/auth.feature:25
27 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()
30
31 Scenario: Log in as Eric # features/auth.feature:30
32 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()
35
365 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.