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
Repository Link
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); } }