Skip to main content

Configuration

Everything this package reads arrives through 1 of 4 channels, apart from the Mink settings it shares: base_url, files_path and each browserkit_http session's http_client_parameters. Each channel answers a different question, and they aren't interchangeable.

ChannelAnswersScope
Suites and context argumentsWhich scenarios run against which surface, with which vocabularyPer suite
The behat_steps extension keyHow the package talks to the sitePer profile
TagsWhat a single scenario opts intoPer scenario or feature
Environment variablesWhat differs between one machine and anotherPer run

The tables in sections 2 and 3 are generated from the source by docs.php, and CI fails when they fall behind it. Documentation covers regenerating them.

1. Suites and context arguments​

Suite per surface​

A suite binds a set of feature files to a set of contexts. The default layout is 1 suite per surface under test, because each surface needs a different vocabulary and a different backend.

<?php

declare(strict_types=1);

use Behat\Config\Config;
use Behat\Config\Extension;
use Behat\Config\Filter\TagFilter;
use Behat\Config\Profile;
use Behat\Config\Suite;
use DrevOps\BehatSteps\Extension\ServiceContainer\BehatStepsExtension;

// The browser surface: what an editor or a visitor can do in a page.
$ui = (new Suite('ui'))
->withPaths('%paths.base%/tests/behat/features/ui')
->addContext(UiContext::class);

// The API surface: responses, headers and payloads, with no browser.
$api = (new Suite('api'))
->withPaths('%paths.base%/tests/behat/features/api')
->addContext(ApiContext::class);

// The specification surface: the few domain-language scenarios a
// stakeholder reads.
$spec = (new Suite('spec'))
->withPaths('%paths.base%/tests/behat/features/spec')
->addContext(SpecContext::class);

$profile = (new Profile('default'))
->withSuite($ui)
->withSuite($api)
->withSuite($spec)
->withExtension(new Extension(BehatStepsExtension::class, [
'drupal' => ['drupal_root' => 'web'],
]));

return (new Config())->withProfile($profile);

Run 1 surface with vendor/bin/behat --suite=api, or all of them with a bare vendor/bin/behat.

3 properties follow from the layout:

  • Each suite composes only the vocabulary its surface needs. ApiContext mixes in RestTrait, JsonTrait and ResponseTrait; it never loads a trait that expects a rendered page. A step that can't work on a surface isn't defined on it, so a mistake is a "step not found" at parse time instead of a confusing failure mid-run.
  • The spec suite and the regression suites do different jobs. The spec suite holds domain-language scenarios written over your own step definitions; the regression suites hold broad scenarios written in the shipped vocabulary. See Scenario styles for why both exist.
  • You can exclude a slow surface without touching the others. Accessibility, visual and @javascript scenarios sit behind their own suite or a tag filter, so the fast suites stay fast.

Context constructor arguments​

The extension key in section 2 is read once per profile. Pass a value that has to differ between suites as a context constructor argument instead:

$ui = (new Suite('ui'))
->withPaths('%paths.base%/tests/behat/features/ui')
->addContext(UiContext::class, [
'fixtures_path' => '%paths.base%/tests/behat/fixtures',
]);
class UiContext extends WebRawContext {

public function __construct(
protected string $fixtures_path,
array $config = [],
) {
parent::__construct($config);
}

}

Behat matches the arguments to the constructor by name, so the array keys are the parameter names.

WebRawContext declares 1 argument of its own, config, and every shipped and consuming context inherits it without redeclaring a constructor. It carries the trait options that differ between 2 contexts of the same profile:

$ui = (new Suite('ui'))
->withPaths('%paths.base%/tests/behat/features/ui')
->addContext(WebContext::class, [
'config' => ['wait' => ['ajax_timeout' => 10]],
]);

A group names the trait that declares it, so a context accepts only the groups its own traits bring. WaitTrait and JavascriptTrait are web traits, so WebContext takes wait and javascript, among others; DrupalContext extends it and adds the groups of the Drupal traits, such as watchdog, big_pipe, cache, queue and email. Setting a group that no trait in the chain declares is an error at construction, naming what that context does accept.

A context that adds arguments of its own forwards config to the parent, and the suite passes both:

class UiContext extends WebRawContext {

use JavascriptTrait;

public function __construct(
protected string $fixtures_path,
array $config = [],
) {
parent::__construct($config);
}

}
$ui = (new Suite('ui'))
->withPaths('%paths.base%/tests/behat/features/ui')
->addContext(UiContext::class, [
'fixtures_path' => '%paths.base%/tests/behat/fixtures',
'config' => ['javascript' => ['fail_on_errors' => FALSE]],
]);

Behat 4 reads PHP configuration only, from behat.php or, when there's no behat.php, from behat.dist.php. Behat 3 reads the same settings from behat.yml. Prefer behat.php: both majors accept it, and it's the only format Behat 4 accepts.

While a project is still on Behat 3, the YAML form of the suite above is:

default:
suites:
ui:
paths: ['%paths.base%/tests/behat/features/ui']
contexts:
- UiContext:
fixtures_path: '%paths.base%/tests/behat/fixtures'
api:
paths: ['%paths.base%/tests/behat/features/api']
contexts: [ApiContext]
extensions:
DrevOps\BehatSteps\Extension\ServiceContainer\BehatStepsExtension:
drupal:
drupal_root: web

behat.dist.php in this repository sets every extension option, plus the trait options a project almost always sets, as a reference.

2. The behat_steps extension key​

Settings under this key configure how the package reaches the site: which backends a scenario may resolve and in what order, how each of them connects, what the login form looks like and which CSS selector each named region resolves to. They're read once per profile.

HTTP connection settings are the exception. Certificate checks, a proxy or DNS pinning live on Mink's browserkit_http session as http_client_parameters, and requests that steps send from PHP, such as a file download, share them with the page. HTTP clients covers how.

$profile->withExtension(new Extension(BehatStepsExtension::class, [
'backends' => ['drupal', 'blackbox'],
'drupal' => ['drupal_root' => 'web'],
'regions' => ['content' => '#content'],
]));

A nested option is a section in the configuration and a dotted path in the table below.

OptionTypeDefaultDescription
backendsmap[]Ordered list of the backends a scenario may resolve, most preferred first. It is both the allow-list and the precedence order: a step names the capability it needs and the first backend here providing it answers. A bare entry names a registered backend; a tag: backend entry gives it a name of its own, so the same feature file runs against a different backend in another profile. Omit it to get every registered backend, in registration order.
- drupal
- api: acme-jsonapi
- blackbox
login_fieldstring'name'User entity property submitted as the login value. Defaults to name. Set to mail for sites that authenticate by email, or any other user property.
regionsmap[]Map of named regions to CSS selectors. Region steps such as 'I press the button :button in the region :region' resolve against this map.
My region: "#css-selector"
Content: "#main .region-content"
Right sidebar: "#sidebar-second"
textsection-Text strings, such as Log out or the Username field can be altered in the Behat configuration if they vary from the default values.
login_url: "/user"
logout_url: "/user/logout"
logout_confirm_url: "/user/logout/confirm"
logout: "Sign out"
login: "Sign in"
password_field: "Enter your password"
username_field: "Nickname"
text.login_urlstring'/user'Path the login steps submit the login form on.
text.logout_urlstring'/user/logout'Path the logout steps request.
text.logout_confirm_urlstring'/user/logout/confirm'Path of the logout confirmation form, submitted when the site asks to confirm.
text.loginstring'Log in'Text of the login submit button.
text.logoutstring'Log out'Text of the logout link.
text.password_fieldstring'Password'Label of the password field on the login form.
text.username_fieldstring'Username'Label of the username field on the login form.
login_waitinteger0Maximum seconds to wait for post-login DOM signals (URL change, body render, logged-in selector, logout link). Set to 0 to disable waiting. A JavaScript session still waits up to 10 seconds, or this value when it is larger, for the browser to leave the login form or show the logged-in selector, and for the logged-in selector or the logout link on the homepage. Each wait ends as soon as its signal appears.
stepsmap[]Default values of the options the step traits declare, keyed by trait group and then by option name. Each group is named after the trait that declares it, so JavascriptTrait reads javascript and BigPipeTrait reads big_pipe. A group naming a trait none of the registered contexts composes is ignored, so one profile can carry the defaults of every suite.
javascript:
enabled: true
fail_on_errors: false
wait:
ajax_timeout: 10
selectorssection-CSS selectors the steps resolve page structures against.
selectors.login_form_selectorstring'form#user-login,form#user-login-form'Selector of the login form, used to tell a login page from a page that merely holds a login block.
selectors.logged_in_selectorstring'body.logged-in,body.user-logged-in'Selector present only while a user is authenticated, used to confirm a login took effect.
blackboxsection-Settings of the backend that drives the site through the browser only. It has no options, and it performs no backend operation, so it provides no capability a step can resolve.
drupalsection-Settings of the backend that bootstraps Drupal in-process.
drupal.drupal_rootstringrequiredPath to the Drupal root the in-process backend bootstraps.
drupal.corestring-Class of the Core the in-process backend delegates to, in place of the one the package ships for the Drupal major the backend detects. A class implements CoreInterface, and one built on a shipped Core extends the Core for that major.
drupal.field_handlersmap[]Map of field types to the handler classes that turn a value a step gives a field into the value Drupal stores. An entry adds a handler for a field type the package ships none for, or replaces the shipped one. A class implements FieldHandlerInterface.
rating: Acme\Behat\Field\RatingHandler
datetime: Acme\Behat\Field\FiscalDatetimeHandler
drupal.creation_aliasesmap[]List of creation alias classes the backend registers after the ones it ships. An alias resolves a value a creation step gives an entity that is not a field of it, such as author on a node. An entry adds an alias, or replaces the shipped one with the same name on the same entity type. A class implements PreCreateAliasInterface or PostCreateAliasInterface and takes no constructor arguments.
- Acme\Behat\Alias\OrganizationAlias
drushsection-Settings of the backend that reaches the site by running Drush 13 or newer.
drush.aliasstring-Drush site alias to run every command against.
drush.binarystring'vendor/bin/drush'Path to the Drush binary.
drush.rootstring-Drupal root passed to Drush, for a site Drush cannot locate on its own.
drush.global_optionsstring-Options appended to every Drush command, such as --uri=http://example.com.
drush.creation_aliasesmap[]List of creation alias classes the backend registers after the ones it ships. The Drush backend creates only users, so it runs only the aliases for the user entity type. A class implements PreCreateAliasInterface or PostCreateAliasInterface and takes no constructor arguments.
- Acme\Behat\Alias\DepartmentAlias

Backend resolution​

5 authorities decide which backend runs a step, and each decides exactly 1 thing.

AuthorityDecidesWhere
ExtensionWhich backends exist at all, and in what orderbehat_steps: { backends: [...] }
ProfileWhich backends exist per environmentbehat -p remote
Scenario tagPreference among the backends that exist@backend:NAME
StepWhich capability it needsbackendFor(X::class)
Capability interfaceWhich backends are even eligibleinstanceof during the walk

The backends list is both the allow-list and the precedence order. Each entry names a backend the extension registers; a keyed entry gives the backend a name of its own, so the same feature file can run against a different backend in another profile.

$profile->withExtension(new Extension(BehatStepsExtension::class, [
'blackbox' => NULL,
'drupal' => ['drupal_root' => 'web'],
'backends' => [
'drupal',
'api' => 'acme-jsonapi',
'blackbox',
],
]));

That aliasing lets a profile swap the implementation behind a name without touching any Gherkin:

// Default profile: "api" is the in-process backend.
'backends' => ['api' => 'drupal', 'blackbox'],

// "behat -p staging": "api" is the real HTTP API, and Drupal is unreachable.
'backends' => ['api' => 'acme-jsonapi', 'blackbox'],

A name is unique, and it holds only letters, digits, _ and - so that @backend:NAME is a valid tag. A name the extension doesn't register fails the container build. A configuration that declares no list gets every registered backend, in registration order.

acme-jsonapi here stands for a backend of your own, which A backend of your own shows how to write and register.

Scoping a run to a restricted backend set is a profile's job, not a suite's: the package reads nothing from a Behat suite's settings.

At step time the resolution is:

  1. Start from the configured order.
  2. Move every name a @backend: tag promotes to the front: the names on the scenario line first, then those on the feature line. Each group keeps the configured order, so the order the tags are written in never changes the result.
  3. Walk that order and return the first backend implementing the capability the step asked for, bootstrapping only that one.
@backend:api
Scenario: Content created over the public API is immediately visible
Given the following "article" content exists:
| title |
| Lab report |
When I visit "/articles"
Then I should see "Lab report"

The order becomes acme-jsonapi, drupal, blackbox, and the content step resolves to acme-jsonapi. A cache step in the same scenario still resolves to drupal, because acme-jsonapi implements no cache capability. Promotion only affects the capabilities the promoted backend provides.

Repeated tags promote their names in the configured order: @backend:blackbox @backend:api and @backend:api @backend:blackbox both give acme-jsonapi, blackbox, drupal. A Scenario Outline row counts the outline's tags and its Examples: table's tags as 1 scenario line.

@backend:NAME reorders the configured list; it never adds to it. A name outside the list is an error at scenario start, so a typo can't silently run the wrong backend, and no tag can hand a Drupal backend to a smoke profile that lists only blackbox. When no backend in the order provides the capability a step asked for, the step fails with an UnsupportedBackendActionException naming the capability and the resolved order.

The backends also take classes of your own through the drupal.field_handlers, drupal.creation_aliases, drush.creation_aliases and drupal.core options, which Extending the backend layer covers.

Trait options​

Every configurable trait declares its own options, and the steps section holds their profile-wide defaults. A group is named after the trait that declares it, in snake case: JavascriptTrait reads javascript, BigPipeTrait reads big_pipe and FileDownloadTrait reads file_download. A trait's page under Web steps or Drupal steps lists its options beside its steps.

$profile->withExtension(new Extension(BehatStepsExtension::class, [
'drupal' => ['drupal_root' => 'web'],

'steps' => [
'javascript' => ['enabled' => TRUE, 'fail_on_errors' => TRUE],
'watchdog' => ['enabled' => TRUE, 'fail_on_errors' => TRUE],
'big_pipe' => ['wait_timeout' => 5000],
'wait' => ['ajax_timeout' => 10],
'modal' => ['selectors' => ['.ui-dialog', '[role="dialog"]']],
],
]));

An option resolves through 5 layers, each overriding the one above it:

declaration default
-> behat_steps.steps (profile-wide)
-> context config (per context)
-> feature tag
-> scenario tag

The 2 levels, steps and a context's config, differ in how strictly they're read, because they know different things:

  • steps is permissive. A group there may name a trait that only 1 of the registered contexts composes, so a group or an option a context can't serve is ignored, and 1 profile can carry the defaults of every suite.
  • A context's config is strict. The context knows which traits it composes, so an unknown group, an unknown option or a value of the wrong type is an error naming what the context accepts.

2 options recur, and they're deliberately separate switches:

OptionEffect
enabledThe trait's hooks do nothing at all. Equivalent to @behat-steps-skip:<TraitName> on every scenario.
fail_on_errorsThe trait still collects, and does not fail the scenario on what it found.

fail_on_errors only covers what a trait collected. A trait whose prerequisites don't hold, like WatchdogTrait on a site without the core dblog module, fails the scenario at its start whatever fail_on_errors says, so enabled is the switch there. Switch a trait off has the details.

Where a trait offers a <trait>Get<Noun>() method for an option, overriding it in the composing context sits outside the chain and replaces the resolution entirely. That override is the escape hatch for anything the configuration can't express.

Extending the step vocabulary covers declaring options for a trait of your own, and Extending the extension layer covers replacing the resolution itself.

3. Tags​

A tag configures 1 scenario or 1 feature. A parametrized tag takes its value after a colon, never a hyphen: @module:redirect, not @module-redirect. A tag with nothing after the colon is ignored, except @behat-steps-skip: and @backend:, which fail at scenario start like any other value they can't resolve. A flag tag takes no value. @module: also switches its value off with a !: @module:!redirect.

@module:redirect @behat-steps-skip:WatchdogTrait
Scenario: Editor publishes a page

A tag on the Feature: line applies to every scenario in that feature. Where a scenario and its feature carry the same kind of tag, a flag takes effect from either line. @behat-steps-skip:VALUE, @email:VALUE, @watchdog:VALUE, @disable-config-override:VALUE and @behat-steps-entity-cleanup-skip:VALUE add up across both lines. @module:VALUE for the same module, @breakpoint:VALUE and an @accessibility:VALUE threshold take the scenario's value over the feature's, while @accessibility:strict applies from either line. @backend:VALUE promotes from both lines, the scenario's names ahead of the feature's, as backend resolution describes.

TagDescription
@behat-steps-skip:VALUESwitch off every hook of the named trait, such as EmailTrait. On a trait that declares an enabled option, the tag sets it to FALSE. A value not shaped like a trait name, or naming a trait no context of the suite composes, fails the run at scenario start.
@behat-steps-entity-cleanup-skip:VALUEKeep entities of the named entity type after the scenario. Repeat the tag to keep several types.
@backend:VALUEMove the named backend to the front of the configured backend list for the scenario. Repeat the tag to promote several: they keep the configured order among themselves, so the order the tags are written in does not matter. The tag reorders the list; it never adds to it.
@module:VALUEEnable the named module for the scenario, or disable it when the name is prefixed with !. The original state is restored afterwards.
@breakpoint:VALUEResize the viewport to the named breakpoint before the first step. A scenario and its feature take 1 tag each, and the scenario's replaces the feature's. The scenario or its feature has to be @javascript.
@email:VALUECollect email for the scenario with the named handler type. A bare @email uses the default handler.
@watchdog:VALUETrack the named Watchdog message type in addition to php, which is always tracked.
@disable-config-override:VALUEDisable settings.php overrides for the named configuration object for the duration of the scenario.
@accessibility:VALUEAssess every page the scenario visits. The value sets the impact threshold that fails the scenario: critical, serious, moderate, minor, any, warning or strict.
@bigpipeRender BigPipe placeholders server-side, for a browser driver without JavaScript.
@disable-form-validationStrip HTML5 validation from every form on the page so a scenario can submit values the browser would block.
@js-errorsAllow JavaScript errors, which otherwise fail the scenario. They are still collected. Sets the javascript.fail_on_errors option to FALSE.
@downloadPrepare the download directory for the scenario and clean it up afterwards.
@testmodeEnable the Testmode module for the scenario.
@debugPrint detailed diagnostics while the scenario runs.
@errorExpect the scenario to log an error. The errors are still read and cleared, and the scenario is not failed. Sets the watchdog.fail_on_errors option to FALSE.

@javascript is Mink's own tag: it selects the browser session. Backend resolution and the @backend:NAME tag above decide which backend a step runs against.

4. Environment variables​

These vary a run without changing any committed configuration. Nothing else in the package reads the environment.

VariableRead byEffect
BEHAT_STEPS_DISABLE_CLEANUPEntityLifecycleTrait, AuthTraitSet to 1, true, yes or on to keep the entities, users and roles a scenario created, instead of deleting them in the teardown. The logged-in user also stays logged in for the scenarios that follow. For inspecting the state a failing scenario left behind, not for CI.
BEHAT_ACCESSIBILITY_PRINTAccessibilityTraitSet to any non-empty value other than 0 to print a one-line accessibility summary per page to the console.
COMPOSER_BIN_DIRDrushBackendWhen drush.binary is the bare drush, names the directory the Drush binary is resolved from, before the backend falls back to vendor/bin/drush under the working directory and then to drush on the PATH. Composer sets it inside its own scripts.

BEHAT_PARAMS is Behat's own override channel and applies here as it does to any extension: it carries a JSON object merged over the loaded configuration, which is the usual way to point base_url or a browser driver's api_url somewhere else for 1 run.