Behat Test Suite custom step definitions

Behat - along with the Drupal Extension - provides a powerful and useful framework for automated front-end and back-end testing of a Drupal-based web site.

The following code snippets present some additional step definitions I wrote to maximize what kinds of tests Behat can perform. These features leverage the Behat Chrome Extension and Chrome Mink Driver, by Dorian More.

Completion Date
Behat Drupal Icon
Platform(s)/Language(s)
Code Snippet
/**
 * @file
 * Custom Context.
 */
 
use Behat\Behat\Context\SnippetAcceptingContext;
use Behat\Behat\Tester\Exception\PendingException;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
use Drupal\DrupalExtension\Context\RawDrupalContext;
 
require_once 'PHPUnit/Autoload.php';
require_once 'PHPUnit/Framework/Assert/Functions.php';
 
/**
 * Defines application features from the specific context.
 */
class FeatureContext extends RawDrupalContext implements SnippetAcceptingContext {
 
  /**
   * Initializes context.
   *
   * Every scenario gets its own context instance.
   * You can also pass arbitrary arguments to the
   * context constructor through behat.yml.
   */
  public function __construct() {
  }
 
  /**
   * Wait a specified number of seconds.
   *
   * Example: When I wait 1 second
   * Example: And wait 2 seconds .
   *
   * @When /^(?:|I )wait (\d+) seconds?$/
   */
  public function waitSeconds($seconds) {
    /*    * Adapted from:
     * - https://michaelheap.com/behat-selenium2-webdriver-with-minkextension/
     */
    $this->getSession()->wait(1000 * $seconds);
  }
 
  /**
   * Prints last response to console.
   *
   * @Then /^(?:|I )debug$/
   */
  public function debug() {
    echo ($this->getSession()->getCurrentUrl() . "\n\n"
      . $this->getSession()->getPage()->getContent());
  }
 
  /**
   * Prints response headers to console.
   *
   * Example: Then debug headers
   * Example: And I debug response headers .
   *
   * @Then /^(?:|I )debug (?:|response )headers$/
   */
  public function debugResponseHeaders() {
    echo $this->getSession()->getCurrentUrl() . "\n\n";
    print_r($this->getSession()->getResponseHeaders());
  }
 
  /**
   * Enable/Disable/Uninstall a drupal module with drush.
   *
   * Example: Given I enable the module "update"
   * Example: And I disable module "update"
   *
   * @Given /^(?:|I )(enable|disable|uninstall)(?: the|) module "(?P<module>[^"]*)"$/
   */
  public function alterDrupalModule($operation, $module) {
    $this->getDriver('drush')->drush('pm-' . $operation, [$module], ['yes' => NULL]);
  }
 
  /**
   * Remove any created users.
   *
   * Overrides RawDrupalContext::cleanUsers(), because the deleteUser() method
   * called by RawDrupalContext::cleanUsers() does not appear to work with the
   * current version of Drush that we use on our Linode cloud servers.
   */
  public function cleanUsers() {
    // Remove any users that were created.
    if ($this->userManager->hasUsers()) {
      foreach ($this->userManager->getUsers() as $user) {
        /* Buggy method to bypass:
         * $this->getDriver('drush')->userDelete($user);
         */
        $arguments = array(sprintf('"%s"', $user->name));
        $options = array(
          'yes' => NULL,
          /* 'delete-content' => NULL, */
        );
        $this->getDriver('drush')->drush('user-cancel', $arguments, $options);
        /* End bypass. */
      }
      $this->getDriver('drush')->processBatch();
      $this->userManager->clearUsers();
      if ($this->loggedIn()) {
        $this->logout();
      }
    }
  }
 
}
/**
 * @file
 * Drupal Custom Context.
 *
 * Adapted from:
 * - https://www.drupal.org/project/drupalextension/issues/1846828
 */
 
use Behat\Behat\Context\SnippetAcceptingContext;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Behat\Hook\Scope\AfterScenarioScope;
use Behat\Testwork\Hook\Scope\BeforeSuiteScope;
use Behat\Testwork\Hook\Scope\AfterSuiteScope;
use Behat\Behat\Tester\Exception\PendingException;
use Drupal\DrupalExtension\Context\DrupalContext;
 
/**
 * Defines application features from the specific context.
 */
class DrupalCustomContext extends DrupalContext implements SnippetAcceptingContext {
 
  /**
   * Watchdog starting id.
   *
   * @var int
   */
  protected $startWid;
 
  /**
   * Watchdog Warnings counter.
   *
   * @var int
   */
  protected static $watchdogWarnings = array();
 
  /**
   * Watchdog Errors counter.
   *
   * @var int
   */
  protected static $watchdogErrors = array();
 
  /**
   * Log in as existing drupal user.
   *
   * Overrides DrupalContext::assertLoggedInByName() to log in as an existing
   * user instead of a randomly-generated one. No user entity should be created
   * or destroyed on the site.
   */
  public function assertLoggedInByName($name) {
    $user = (object) array(
      'name' => $name,
    );
 
    // Login.
    $this->login($user);
  }
 
  /**
   * Log-in the given user by using `drush user-login`.
   *
   * Overrides DrupalContext::login() because the default method of logging
   * in via the `/user` page with user/pass field values was failing to work as
   * intended. This method bypasses the `/user` page by utilizing `drush uli`.
   *
   * @param \stdClass $user
   *   The user to log in.
   */
  public function login(\stdClass $user) {
    // Check if logged in.
    if ($this->loggedIn()) {
      $this->logout();
    }
 
    $domain = $this->getMinkParameter('base_url');
    // Pass base url to drush command.
    $uli = $this->getDriver()->drush('uli', array(
      "'" . $user->name . "'",
      "--browser=0",
      "--uri=$domain",
    ));
    // Trim EOL characters.
    $uli = trim($uli);
    $this->getSession()->visit($uli);
 
    if (!$this->loggedIn()) {
      if (isset($user->role)) {
        throw new \Exception(sprintf("Unable to determine if logged in because 'log_out' link cannot be found for user '%s' with role '%s'", $user->name, $user->role));
      }
      else {
        throw new \Exception(sprintf("Unable to determine if logged in because 'log_out' link cannot be found for user '%s'", $user->name));
      }
    }
  }
 
  /**
   * Take note of the most recent Watchdog ID, for tracking new log events.
   *
   * @BeforeScenario @api,@javascript
   */
  public function recordStartWatchdogId(BeforeScenarioScope $scope) {
    $tags = array_merge($scope->getFeature()->getTags(), $scope->getScenario()->getTags());
 
    // Bypass the error checking if the scenario has the @noerrors tag.
    if (in_array('noerrors', $tags)) {
      return;
    }
 
    $options = ['format' => 'json', 'count' => '1'];
    $drush_output = $this->getDriver('drush')->drush('watchdog-show', [], $options);
    // Use substr() to trim any Drush notification messages that might be output
    // prior to the json string.
    $drush_output = substr($drush_output, strpos($drush_output, '{'));
    $log = json_decode($drush_output, TRUE);
 
    $this->startWid = array_keys($log)[0];
  }
 
  /**
   * Checks up to 10 latest log messages for warnings/errors.
   *
   * Checks up to 10 messages after the scenario starts.
   * - Use tag @nowarnings to ignore Watchdog warnings and notices.
   * - Use tag @noerrors to ignore all Watchdog messages.
   *
   * Adapted from:
   *   - https://git.drupalcode.org/project/lightning/blob/HEAD/tests/features/bootstrap/lightning.behat.inc
   *   - https://www.drupal.org/project/drupalextension/issues/2943574
   *
   * @AfterScenario @api,@javascript
   */
  public function checkWatchdog(AfterScenarioScope $scope) {
    $tags = array_merge($scope->getFeature()->getTags(), $scope->getScenario()->getTags());
 
    // Bypass the error checking if the scenario has the @errors tag.
    if (in_array('noerrors', $tags)) {
      return;
    }
 
    $error_levels = ['emergency', 'alert', 'critical', 'error'];
    $warning_levels = ['warning', 'notice'];
 
    $severity_levels = $error_levels;
    if (!in_array('nowarnings', $tags)) {
      $severity_levels = array_merge($severity_levels, $warning_levels);
    }
 
    // Drush command options for watchdog-show.
    $options = [
      'format' => 'json',
    ];
 
    $drush_output = $this->getDriver('drush')->drush('watchdog-show', [], $options);
    // Use substr() to trim any Drush notification messages that might be output
    // prior to the json string.
    $drush_output = substr($drush_output, strpos($drush_output, '{'));
    $log = json_decode($drush_output, TRUE);
 
    if (!empty($log)) {
      $warnings = array();
      $errors = array();
      foreach ($log as $wid => $entry) {
        if ($wid > $this->startWid && in_array($entry['severity'], $severity_levels)) {
          // Make the substitutions easier to read in the log.
          $msg = $entry['date'] . " - " . $entry['type'] . "\n";
          $msg .= ucfirst($entry['severity']) . ": " . $entry['message'];
          if (in_array($entry['severity'], $warning_levels)) {
            $warnings[$wid] = $msg;
            self::$watchdogWarnings[] = $scope->getFeature()->getFile() . ":" . $scope->getScenario()->getLine();
          }
          elseif (in_array($entry['severity'], $error_levels)) {
            $errors[$wid] = $msg;
            self::$watchdogErrors[] = $scope->getFeature()->getFile() . ":" . $scope->getScenario()->getLine();
          }
        }
      }
 
      if (!empty($warnings) && !empty($errors)) {
        $events = array_merge($warnings, $errors);
        ksort($events);
        throw new Exception(sprintf("Drupal warnings & errors logged to Watchdog in this scenario:\n\n%s\n\n",
          implode("\n\n", $events)));
      }
      elseif (!empty($warnings)) {
        ksort($warnings);
        throw new PendingException(sprintf("Drupal warnings logged to Watchdog in this scenario:\n\n%s",
          implode("\n\n", $warnings)));
      }
      elseif (!empty($errors)) {
        ksort($errors);
        throw new Exception(sprintf("Drupal errors logged to Watchdog in this scenario:\n\n%s\n\n",
          implode("\n\n", $errors)));
      }
    }
  }
 
  /**
   * Report any recorded watchdog log events.
   *
   * @param Behat\Behat\Hook\Scope\AfterSuiteScope $scope
   *   After Suite hook scope.
   *
   * @AfterSuite
   */
  public static function reportWatchdogEvents(AfterSuiteScope $scope) {
    if (!empty(self::$watchdogWarnings) && !empty(self::$watchdogErrors)) {
      throw new Exception(sprintf("Drupal warnings & errors thrown during scenarios:\n\n%s\n\n",
        implode("\n", array_unique(array_merge(self::$watchdogWarnings, self::$watchdogErrors)))));
    }
    elseif (!empty(self::$watchdogWarnings)) {
      throw new PendingException(sprintf("Drupal warnings thrown during scenarios:\n\n%s",
        implode("\n", array_unique(self::$watchdogWarnings))));
    }
    elseif (!empty(self::$watchdogErrors)) {
      throw new Exception(sprintf("Drupal errors thrown during scenarios:\n\n%s\n\n",
        implode("\n", array_unique(self::$watchdogErrors))));
    }
  }
 
  /**
   * Check site environment for production state.
   *
   * @BeforeScenario @dev
   */
  public function skipIfProdEnvironment(BeforeScenarioScope $scope) {
    if ($env = $this->assertProdEnvironment()) {
      throw new PendingException(sprintf("Skipping this dev-only tagged Scenario because we're on %s", $env));
    }
  }
 
  /**
   * Check site environment for development state.
   *
   * @BeforeScenario @prod
   */
  public function skipIfDevEnvironment(BeforeScenarioScope $scope) {
    if ($this->assertProdEnvironment() === FALSE) {
      $mink_params = $this->getMinkParameters();
      $env = $mink_params['base_url'] ?? NULL;
      throw new PendingException(sprintf("Skipping this prod-only tagged Scenario because we're on %s", $env));
    }
  }
 
  /**
   * Create a user.
   *
   * Overrides RawDrupalContext::cleanUsers() to first check current site
   * environment. If we are on a production site, then skip this action.
   */
  public function userCreate($user) {
    if ($env = $this->assertProdEnvironment()) {
      throw new PendingException(sprintf("Skipping user creation because we're on %s", $env));
    }
    else {
      parent::userCreate($user);
    }
  }
 
  /**
   * Adds a role for a user.
   *
   * Overrides DrushDriver::userAddRole() to first check current site
   * environment. If we are on a production site, then skip this action.
   */
  public function userAddRole(\stdClass $user, $role) {
    if ($env = $this->assertProdEnvironment()) {
      throw new PendingException(sprintf("Skipping user creation because we're on %s", $env));
    }
    else {
      parent::userAddRole($user, $role);
    }
  }
 
  /**
   * Clear Drupal cache.
   *
   * Overrides DrupalContext::assertCacheClear() to first check current site
   * environment. If we are on a production site, then skip this action.
   */
  public function assertCacheClear() {
    if ($env = $this->assertProdEnvironment()) {
      echo sprintf("Skipping cache clear because we're on %s", $env);
    }
    else {
      parent::assertCacheClear();
    }
  }
 
  /**
   * Run cron.
   *
   * Overrides DrupalContext::assertCron() to first check current site
   * environment. If we are on a production site, then skip this action.
   */
  public function assertCron() {
    if ($env = $this->assertProdEnvironment()) {
      echo sprintf("Skipping cron run because we're on %s", $env);
    }
    else {
      parent::assertCacheClear();
    }
  }
 
  /**
   * Create a node.
   *
   * Overrides RawDrupalContext::nodeCreate() to first check current site
   * environment. If we are on a production site, then skip this action.
   */
  public function nodeCreate($node) {
    if ($env = $this->assertProdEnvironment()) {
      throw new PendingException(sprintf("Skipping node creation because we're on %s", $env));
    }
    else {
      return parent::nodeCreate($node);
    }
  }
 
  /**
   * Create a term.
   *
   * Overrides RawDrupalContext::termCreate() to first check current site
   * environment. If we are on a production site, then skip this action.
   */
  public function termCreate($term) {
    if ($env = $this->assertProdEnvironment()) {
      throw new PendingException(sprintf("Skipping term creation because we're on %s", $env));
    }
    else {
      return parent::termCreate($term);
    }
  }
 
  /**
   * Creates a language.
   *
   * Overrides RawDrupalContext::languageCreate() to first check current site
   * environment. If we are on a production site, then skip this action.
   */
  public function languageCreate(\stdClass $language) {
    if ($env = $this->assertProdEnvironment()) {
      throw new PendingException(sprintf("Skipping language creation because we're on %s", $env));
    }
    else {
      return parent::languageCreate($language);
    }
  }
 
  /**
   * Check the current site environment to see if we are on Production.
   *
   * @return bool
   *   TRUE if production environment is detected, FALSE otherwise.
   */
  public function assertProdEnvironment() {
    $drupal_params = $this->getDrupalParameter('drush');
    $drush_alias = $drupal_params['alias'] ?? NULL;
    $mink_params = $this->getMinkParameters();
    $base_url = $mink_params['base_url'] ?? NULL;
 
    if (
      strpos($drush_alias, 'master') !== FALSE ||
      strpos($drush_alias, 'prod') !== FALSE ||
      strpos($base_url, '//www.') !== FALSE ||
      strpos($base_url, '//master.') !== FALSE
    ) {
      return $base_url ?? $drush_alias;
    }
    else {
      return FALSE;
    }
  }
 
}
/**
 * @file
 * Chrome web driver context.
 */
 
use Behat\Behat\Context\SnippetAcceptingContext;
use Behat\Testwork\Hook\Scope\BeforeSuiteScope;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Testwork\Hook\Scope\AfterSuiteScope;
use Behat\Behat\Hook\Scope\AfterStepScope;
use Behat\Mink\Exception\UnsupportedDriverActionException;
use DMore\ChromeDriver\ChromeDriver;
 
/**
 * Define a Mink Subcontext.
 */
class DmoreChromeDriverContext extends JsBrowserContext implements SnippetAcceptingContext {
 
  /**
   * Javascript Warnings counter.
   *
   * @var int
   */
  protected static $jsWarnings = array();
 
  /**
   * Javascript Errors counter.
   *
   * @var int
   */
  protected static $jsErrors = array();
 
  /**
   * Starts Chrome in Remote Debug Mode for UI tests.
   *
   * Adapted from: https://github.com/bnowack/backbone-php/tree/master/test/behat/
   *
   * @BeforeSuite
   */
  public static function startWebServer(BeforeSuiteScope $scope) {
    $suite = $scope->getSuite();
 
    if ($suite->hasSetting('chrome_path')) {
      $chrome_path = $suite->getSetting('chrome_path');
      $chrome_headless = $suite->getSetting('chrome_headless');
      $port = 9222;
      $host = 'localhost';
      $headless = $chrome_headless ? "--headless" : "";
 
      // Launch if not already up.
      if (!self::serverIsUp($host, $port)) {
        $command = "{$chrome_path} --no-first-run --no-default-browser-check --user-data-dir=/tmp/chrome-remote-profile --remote-debugging-address=0.0.0.0 --remote-debugging-port={$port} --disable-gpu --disable-extensions --window-size='1600,900' {$headless} >/dev/null 2>&1 & echo \$!";
        $output = trim(shell_exec($command));
        self::$webDriverPid = is_numeric($output) ? intval($output) : NULL;
      }
      // Check that the server is running, wait up to 2 seconds.
      $attempts = 0;
      do {
        $up = self::serverIsUp($host, $port);
        $attempts++;
        usleep(100000);
        // 0.1 sec.
      } while (!$up && $attempts < 20);
      if (!$up) {
        self::stopProcess(self::$webDriverPid);
        // Just in case it *did* start but did not respond in time.
        throw new \RuntimeException("Could not start web server at $host:$port");
      }
    }
  }
 
  /**
   * Sets the user agent and resolution for ChromeDriver by Dmore.
   *
   * Overrides JsBrowserContext::prepareBrowser()
   *
   * ChromeDriver by Dmore has no built-in mechanism to define a mobile device
   * browser, but it does provide a mechanism to alter the user agent.
   * So, this method provides a default user-agent header.
   *
   * @BeforeScenario
   */
  public function prepareBrowser(BeforeScenarioScope $scope) {
    $driver = $this->getSession()->GetDriver();
    $suite = $scope->getSuite();
 
    if ($suite->hasSetting('chrome_useragent')) {
      $useragent = $suite->getSetting('chrome_useragent');
      $driver->setRequestHeader('user-agent', $useragent);
    }
 
    parent::prepareBrowser($scope);
  }
 
  /**
   * Get the console logs.
   *
   * - Use tag @nojswarnings to ignore JS warnings.
   * - Use tag @nojserrors to ignore all JS errors & warnings.
   *
   * @AfterStep
   */
  public function getCapturedConsoleLogs(AfterStepScope $scope) {
    $tags = array_merge($scope->getFeature()->getTags(), $this->getScenario($scope)->getTags());
 
    // Bypass all JS message checking if the scenario has the @nojserrors tag.
    if (in_array('nojserrors', $tags)) {
      return;
    }
 
    try {
      if ($consoleErrors = $this->getSession()->evaluateScript("window.consoleErrors")) {
        foreach ($consoleErrors as $error) {
          print "JS Error: " . $error;
          self::$jsErrors[] = $scope->getFeature()->getFile() . ":" . $scope->getStep()->getLine();
        }
        // Reset the consoleErrors array to prepare for the next step.
        $this->getSession()->executeScript("window.consoleErrors = [];");
      }
 
      // If the scenario has the @nojswarnings tag, return now.
      if (in_array('nojswarnings', $tags)) {
        return;
      }
 
      if ($consoleWarnings = $this->getSession()->evaluateScript("window.consoleWarnings")) {
        foreach ($consoleWarnings as $warning) {
          print "JS Warning: " . $warning;
          self::$jsWarnings[] = $scope->getFeature()->getFile() . ":" . $scope->getStep()->getLine();
        }
        // Reset the consoleWarnings array to prepare for the next step.
        $this->getSession()->executeScript("window.consoleWarnings = [];");
      }
    }
    catch (UnsupportedDriverActionException $e) {
      // Simply continue on, as this driver doesn't support JS.
    }
  }
 
  /**
   * Report any recorded Javascript events.
   *
   * @param Behat\Behat\Hook\Scope\AfterSuiteScope $scope
   *   After Suite hook scope.
   *
   * @AfterSuite
   */
  public static function reportJsEvents(AfterSuiteScope $scope) {
    if (!empty(self::$jsWarnings) && !empty(self::$jsErrors)) {
      echo(sprintf("Javascript warnings & errors thrown during scenarios:\n\n%s\n\n",
        implode("\n", array_unique(array_merge(self::$jsWarnings, self::$jsErrors)))));
    }
    elseif (!empty(self::$jsWarnings)) {
      echo(sprintf("Javascript warnings thrown during scenarios:\n\n%s",
        implode("\n", array_unique(self::$jsWarnings))));
    }
    elseif (!empty(self::$jsErrors)) {
      echo(sprintf("Javascript errors thrown during scenarios:\n\n%s\n\n",
        implode("\n", array_unique(self::$jsErrors))));
    }
  }
 
  /**
   * Overrides PhpBrowserContext::assertAtPath() to add in console log tracking.
   */
  public function assertAtPath($path) {
    parent::assertAtPath($path);
    try {
      $this->initConsoleLogging();
    }
    catch (UnsupportedDriverActionException $e) {
      // Simply continue on, as this driver doesn't support JS.
    }
  }
 
  /**
   * Start console logging.
   *
   * References:
   * - https://github.com/minkphp/MinkSelenium2Driver/issues/189
   * - https://gist.github.com/amitaibu/ba6b78e24c315a7f5e3c/
   */
  public function initConsoleLogging() {
    $script = <<<JS
  window.consoleErrors = [];
  window.consoleWarnings = [];
 
  if (window.console && console.error) {
    var old = console.error;
    console.error = function() {
      window.consoleErrors.push("'" + arguments[0] + "'\\nCaller:\\n" + arguments.callee.caller + "\\nTrace:\\n" + arguments.trace);
      old.apply(this, arguments);
    }
  }
 
  if (window.console && console.warn) {
    var old = console.warn;
    console.warn = function() {
      window.consoleWarnings.push("'" + arguments[0] + "'\\nCaller:\\n" + arguments.callee.caller + "\\nTrace:\\n" + arguments.trace);
      old.apply(this, arguments);
    }
  }
JS;
 
    $this->getSession()->executeScript($script);
  }
 
  /**
   * Stops the web driver.
   *
   * Overrides JsBrowserContext::stopWebDriver() to delete temporary Chrome
   * profile directory.
   */
  public static function stopWebDriver() {
    parent::stopWebDriver();
    trim(shell_exec("rm -rfv /tmp/chrome-remote-profile 2>&1"));
  }
 
  /**
   * Step definition for setting browser window size.
   *
   * Overrides JsBrowserContext::setBrowserWindowSizeToWxH() because it only
   * supports Selenium2.
   */
  public function setBrowserWindowSizeToWxH($width, $height) {
    try {
      $this->getSession()->GetDriver()->resizeWindow((int) $width, (int) $height);
    }
    catch (UnsupportedDriverActionException $e) {
      // Skip browser resize because we are not running Chrome.
    }
  }
 
  /**
   * Checks, that http response header property equals specified value.
   *
   * Overrides PhpBrowserContext::assertResponsePropertyEqualsValue() to set
   * the property to lower case.
   */
  public function assertResponsePropertyEqualsValue($property, $value) {
    $headers = $this->getSession()->getResponseHeaders();
    assertEquals($headers[strtolower($property)], $value);
  }
 
}
Attachment Size
Automated Testing presentation.pdf 615.19 KB