Automated Testing for WordPress, Part 3: Writing and Running the Tests

In part 2, I discussed installing Behat and setting up the environment. In this post, I'll discuss how the tests actually work.

Test Definitions

A typical Behat workspace looks something like this:

bin/
  behat
  behat.bat
features/
  bootstrap/
    FeatureContext.php
  test1.feature
  test2.feature
  (...more feature test files...)
vendor/
  (...lots of directories for dependencies...)
  autoload.php
behat.yml
composer.json
composer.lock

In Part 2, we discussed the configuration directives defined in behat.yml under Configuring Behat.

The tests themselves are defined in the *.feature files in the features directory using a language called "Gherkin". Here's what a Behat feature test looks like:

@javascript @administrator @reports @sample-school
Feature: Administrator Sample School Report

  Background:
    Given I am logged in as "administrator"
    And I am on the "sample-school" report

  @1
  Scenario: 2015, by district
    When I select "2015" from "School Year"
    And I select "District" from "Aggregate by"
    And I check "1" under "Grade"
    And I check "2" under "Grade"
    And I check "3" under "Grade"
    And I press "Update"
    Then the report data should match the data in the "administrator-sample-school-1.json" file.

  @2
  Scenario: 2015, by school
    When I select "2015" from "School Year"
    And I select "School" from "Aggregate by"
    And I select "Test District 1" from "District"
    And I press "Update"
    Then the report data should match the data in the "administrator-sample-school-2.json" file.

Each file describes a feature of the application to be tested and defines one or more "scenarios". Each scenario has a number of "steps" identified with certain keywords: "Given" establishes the context for the scenario, "When" describes the actions taken, and "Then" asserts the expected results. In our case, we just wanted to compare the current report output to previously captured data in a file, but you can really do anything you want, since as we'll see, you'll be writing the code to implement the tests.

Each of these steps can be extended with an arbitrary number of "And" or "But" steps. Behat doesn't actually distinguish between these keywords. They're just there to provide semantics for human readers. You can also define a "Background" to establish a shared context for all of a feature's scenarios. The background steps will be executed before each scenario.

Features and scenarios can have tags assigned prefixed with the "@" symbol. This allows you to run some subset of your tests with Behat's --tags parameter. Scenarios inherit tags from their feature. The @javascript tag is a special one telling Behat that a feature or scenario requires JavaScript.

You can read more about Gherkin here: http://behat.org/en/latest/user_guide/gherkin.html.

Test Implementation

The code that implements the tests is defined in a "feature context" class. By default, this is called FeatureContext and lives in features/bootstrap/FeatureContext.php. (You can also define custom suite-specific context classes in behat.yml.) Context classes should implement the Behat\Behat\Context\Context interface.

For each step defined in your testing scenarios, Behat looks for a corresponding context class method by matching the step text against a pattern you define in a PHP annotation for the method. PHP annotations, if you're not familiar with them, are specially formatted comment blocks, which are like "DocBlocks" except that they "[influence] the way the application behaves".

When a matching method is found, Behat calls it, passing in any arguments it parsed out of the step text. So, for example, this scenario step:

Given I am on the "sample-school" report

would match this context class method:

/**
 * @Given /^I am on the "([^"]+)" report$/
 */
public function iAmOnTheReport ($report) {
	if (!$this->onReport($report)) {
		$this->visitPath(sprintf($this->getParameter("reportsPath"), urlencode($report)));
	}
}

with the captured sub-pattern match passed as the $report argument. Behat also has its own pattern matching scheme, but regular expressions work too, which I prefer for familiarity and clarity.

The "Given" keyword doesn't have to match, so this method would also match When I am on the "sample-school" report, or "And…" or "But.." and even "Then…". although semantically "Then" is usually used for assertions.

If no match is found, Behat throws an exception and will prompt you to add the missing method. It can even stub out the method and write it into your context class source file for you, if you want.

! Gotcha: Although "And" and "But" are valid *.feature file step keywords, they are not recognized for matching context class source file method annotations. Only @Given, @When and @Then are recognized as tags for annotation patterns. So for example, @And /^I am on the "([^"]+)" report$/ in a context class file method annotation won't match the text And I am on the "sample-school" report in a feature file. However, @Given /^I am on the "([^"]+)" report$/ (or @When... or @Then...) will match that text. Also note that the tag isn't part of the pattern. It's only there to identify the rest of the line on which it appears as a pattern to be matched against step text.

Tip: By default, arguments are passed in whatever order they're encountered in the step text. However, you can use "named capturing groups" in your regex patterns to match arguments by name.

Here's an example from the Behat Mink Extension's MinkContext class:

/**
 * Checks, that element with specified CSS contains specified text
 * Example: Then I should see "Batman" in the "heroes_list" element
 * Example: And I should see "Batman" in the "heroes_list" element
 *
 * @Then /^(?:|I )should see "(?P<text>(?:[^"]|\\")*)" in the "(?P<element>[^"]*)" element$/
 */
public function assertElementContainsText($element, $text)
{
    $this->assertSession()->elementTextContains('css', $element, $this->fixStepArgument($text));
}

The arguments $element and $text are passed in the reverse order they appear in the pattern. (Note also the use of the non-capturing parens and pipe to effectively make "I " optional.) If the step definition is an assertion, throwing an exception fails the test.

Tip: Subclass Behat\MinkExtension\Context\MinkContext in your feature context class, and you'll inherit the above method plus lots of other useful assertions.

You can read more about writing step definitions here: http://behat.org/en/latest/user_guide/context/definitions.html

Running Tests

Tests are run from the root directory with the command bin/behat (or on Windows, bin\behat).

Note: In Part 2, we suggested specifying bin/ for Composer's bin-dir parameter. If you skipped that part, your bin-dir will be vendor/bin making your Behat command vendor/bin/behat.

If you're using the Selenium driver, you'll have to start the Selenium server process first by running java -jar selenium-server-standalone-<version>.jar from wherever you downloaded Selenium. (I find it easiest to do this in two separate shell windows, but if you're savvy enough you can also send the first process to the background.)

By default, all the tests in features are run, but you can also specify some subset of tests to run with command line parameters. For example, the --tags parameter runs only features or scenarios with the specified tags: bin/behat --tags reports.

The default output looks something like this:

>bin/behat --tags sample-school

@javascript @administrator @reports @sample-school
Feature: Administrator Sample School Report

  Background:                               # features/test1.feature:4
    Given I am logged in as "administrator" # FeatureContext::iAmLoggedInAs()
      Logging in as administrator
    And I am on the "sample-school" report  # FeatureContext::iAmOnTheReport()

  @1
  Scenario: 2015, by District # features\test1.feature:9
    When I select "2015" from "School Year"
          # FeatureContext::selectOption()
    And I select "District" from "Aggregate by"
          # FeatureContext::selectOption()
    And I check "1" under "Grade"
          # FeatureContext::iCheckOptionUnder()
    And I check "2" under "Grade"
          # FeatureContext::iCheckOptionUnder()
    And I check "3" under "Grade"
          # FeatureContext::iCheckOptionUnder()
    And I press "Update"
          # FeatureContext::pressButton()
    Then the report data should match the data in the "administrator-sample-school-1.json" file. 
          # FeatureContext::theReportTableDataShouldMatch()

  @2
  Scenario: 2015, by School  # features/test1.feature:19
    Given I am logged in as "administrator"
          # FeatureContext::iAmLoggedInAs()
      Reusing authentication tokens for administrator
    When I select "2015" from "School Year"
          # FeatureContext::selectOption()
    And I select "School" from "Aggregate by"
          # FeatureContext::selectOption()
    And I select "Test District 1" from "District"
          # FeatureContext::selectOption()   
    And I press "Update"
          # FeatureContext::pressButton()
    Then the report data should match the data in the "administrator-sample-school-2.json" file.
          # FeatureContext::theReportTableDataShouldMatch()

2 scenarios (2 passed)
16 steps (16 passed)
1m8.16s (9.22Mb)

>

It echoes out the contents of each feature file it's executing, showing the file and line number where each scenario is defined and the matching method for each step, and then prints a summary of the results. If your shell supports it, there's color coding for passing and failing tests. Also, any output from your code is printed on the command line.

There are also a number of command line parameters to control the output. You can read documentation of the command line interface here: http://docs.behat.org/en/v2.5/guides/6.cli.html. Note however, that this is not for latest version of Behat. (The documentation for the latest version doesn't seem to be as descriptive.) Run bin/behat -h to see the command line options for whatever version you've installed.

Next: Practical Considerations