Below are some guidelines for developing and maintaining the Behat steps.
New here? docs/content/architecture/ shows how the pieces fit together - the layers, the step traits, and the tooling that documents and tests them - in a logical architecture, a class inheritance and a data flow diagram.
A step is 1 plain English sentence. Every step follows every rule below, and a step that doesn't fit a rule is reworded rather than exempted. ahoy lint-docs checks the rules marked (lint).
Who acts
-
Every step method's docblock declares
@actor:personor@actor:system, on its own line after the last@endcodewith a blank line before it. It'spersonwhen a person does or sees the step through the browser, a terminal or an HTTP client, andsystemotherwise. (lint)/** * Process all items from a queue. * * @code * When the queue "default" is processed * @endcode * * @actor:system */
-
A
personstep starts withI, and asystemstep starts with its subject:Given I am logged in as the user "jane",When cron runs. (lint)
Given, When, Then
Givenputs the system under test into the state the scenario needs, and its wording names that state:Given the module "node" is enabledenables the module. NotGiven I enable the module "node".Whenis the action or the event under test:When I press the button "Save",When cron runs.Thenchecks withshouldorshould not, and no other step uses them:Then the queue "default" should be empty. (lint)- Something already done takes the perfect tense:
Given the cache has been cleared,Then an email should have been sent to the address "jane@example.com".
Values
- A value that says which one goes after its noun:
the user "jane",the config "system.site",in the region "header". Notthe "header" region. (lint) - A value that says what kind or how many goes before its noun:
the "article" content,3 items,in ascending order,a REST "POST" request. Notthe content "article".kind_placeholders()indocs.phplists the placeholders that do this. (lint) - Any other property that picks a thing out reads
with the <property> <value>, right after the thing. A thing with a kind before it gets its name this way too:the link with the title "Home page",the "article" content with the title "Hello". Notthe "article" content "Hello". (lint) - A place or a destination takes its preposition, right after the thing:
the element "#logo" in the region "header",sent to the address "a@example.com". - The value a step checks comes straight after its verb:
should be "/about",should contain "site". A thing that holds a value takeshave the value:the state "x" should have the value "1". (lint) thenames the one you mean, anda,anornonames any that match:the cookie "SESS",a cookie with a name containing "SE",no emails should have been sent.
Words
- Each action has 1 verb: follow a link, click on an element, press a button or a key, fill in a field, check a checkbox, select an option, visit a page. (lint)
- A state is a word, never a value:
is enabledin aGiven,should be enabledin aThen. - A count of what a thing holds reads
should have 3 <things>. A count of events readsthe number of <events> should be 3. (lint) - A step that takes a table introduces it with
the following <noun>:, as inGiven the following users exist:. (lint) - A step never starts with a value, writes acronyms in capitals, and never uses a phrase from
rejected_step_phrases()indocs.php, such asthe current, or Mink's ownI should see :text, which is why a step of ours readsI should see the text "Welcome" in the modal.step_acronyms()lists the acronyms. (lint)
Names
- A step is words and placeholders, never a regular expression. A placeholder is
snake_case, matches its method parameter and has 1 name per idea::name,:value,:count,:index,:address, and:partial_<name>for a partial match.:textis only for text on the page.non_descriptive_placeholders()andplaceholder_synonyms()indocs.phplist the names it rejects. (lint) - A step method's name starts with its trait's name, uses
Assertin aThenandVisitwhen it opens a page, and carries 1 step. (lint)
Behat binds every placeholder as a string, and a Turnip pattern can't make a placeholder optional. A step method declares exactly that: a required string for each placeholder, named as the placeholder is, plus a trailing TableNode or PyStringNode for a step that ends with a colon. Nothing is nullable, nothing has a default, and nothing else is accepted. tests/phpunit/src/StepArgumentTest.php enforces it.
A step that needs a number parses its placeholder with Steps\Helper\Web\StringTrait before it uses the value:
#[Then('the table :selector should have :count row(s)')]
public function tableAssertRowCount(string $selector, string $count): void {
$count = $this->stringParseInteger($count, 'count', 0);
// ...
}stringParseInteger() accepts what FILTER_VALIDATE_INT accepts, and stringParseNumber() accepts what FILTER_VALIDATE_FLOAT accepts. Both throw \RuntimeException naming the argument, which is the exception an invalid step argument throws everywhere in this package: The count must be an integer, but "abc" was given. Pass the smallest value the step accepts as $min, and a value below it fails the same way: The count must be 0 or greater, but "-1" was given. Leave $min out when the helper the value goes to checks the range itself, as elementGetNth() does for an index, so each range rule lives in 1 place.
A native int parameter would hand the check to PHP. Behat calls a step without strict types, so PHP reads 1e3 as 1000 and 3.5 as 3 with a deprecation notice, and abc fails with a TypeError that Behat reports as Type error: ... rather than the \RuntimeException a consumer catches.
A step has none. A variation of a step is a step of its own. When 2 steps differ only by a qualifier one of them leaves out, both call a shared helper rather than one calling the other with NULL: linkAssertExistsWithHref() and linkAssertExistsWithHrefWithinElement() both call linkAssertHrefMatches(). A flag that only PHP code passes belongs on a helper too.
When leaving out a helper's string argument means "not given", the parameter defaults to NULL, with a nullable type, and NULL is the one value that means it: contentVisitActionPageWithTitle() opens the content page for a NULL subpath, and configGet() returns the whole configuration object for a NULL key. A parameter never defaults to an empty string, so '' stays a value a caller passes on purpose: cookieAssertExists() skips the value check for NULL and asserts an empty value for ''. A default that is a value in its own right stays, as DrushBackend's $binary = 'drush' does. tests/phpunit/src/OptionalParameterTest.php rejects an empty-string default on every class, interface and trait under src/.
A project's own tests assert on failure messages, so each one reads 1 way:
- It names what it reports about with the noun its step uses and the article:
the element :selectorfails withThe element "..." is not displayed within the viewport., andthe config :name with the key :keywithThe config "..." with the key "..." .... A message reporting that nothing in a set matched keeps its quantifier, asNo hreflang alternate links were found on the page.does. - It quotes every value it names in double quotes, the page URL included, and puts the noun before the value:
the attribute "...", notthe "..." attribute. - It ends with a period, and closes a broken expectation with
, but it should notor, but it should be.
Which exception carries the message depends on what failed, as Exception types lists.
Which exception a step throws is part of the public contract: consumers catch on it, and the @test-trait: harness asserts on it. Pick the type by what failed, never by what the surrounding code happens to use:
| Failure | Throw |
|---|---|
| An assertion failed and the trait has a Mink session | Behat\Mink\Exception\ExpectationException, with $this->getSession()->getDriver() as the second argument |
| An element the step locates is missing - on the page or in an XML response: a field, link, button, select, table or row | Behat\Mink\Exception\ElementNotFoundException (a subclass of ExpectationException) |
| An attribute, a JSON path or a table column is missing - none of them is an element | Behat\Mink\Exception\ExpectationException |
| An assertion failed and the trait has no Mink session | DrevOps\BehatSteps\Steps\Exception\AssertionException |
| Not an assertion: an invalid step argument, an unmet prerequisite, an infrastructure error | \RuntimeException |
| The current browser driver lacks a required capability | Behat\Mink\Exception\UnsupportedDriverActionException |
| No backend the scenario lists provides a required capability | DrevOps\BehatSteps\Backend\Exception\UnsupportedBackendActionException |
Never throw a plain \Exception or an \InvalidArgumentException from src/.
A trait without a Mink session is one that never calls $this->getSession(). The ones that assert are Steps\Web\CommandTrait, Steps\Drupal\ConfigTrait, Steps\Drupal\DrushTrait, Steps\Drupal\ModuleTrait, Steps\Drupal\QueueTrait, Steps\Drupal\RedirectTrait, Steps\Drupal\StateTrait and Steps\Drupal\WatchdogTrait. Don't add a session to a trait just to reach ExpectationException.
In a @test-trait: scenario, Then it should fail with an error: asserts an assertion exception and Then it should fail with an exception: asserts a \RuntimeException. Use Then it should fail with a "<class>" exception: only when the specific class matters.
Every method a trait contributes begins with the trait's own name, so that traits mixed into one context cannot collide. tests/phpunit/src/TraitMethodNamingTest.php enforces this, along with the assertion, negation, action, helper verb, hook, lookup, word order, batch and spelling conventions below.
The prefix is the trait name minus its Trait suffix with the first letter lowercased, and the character after it is uppercase: menuFindByLabel(), not findMenuByLabel(). The prefix is not also the verb: waitSeconds(), not waitWaitForSeconds(). It applies to every member a trait mixes into the context, since any of them can collide with another trait's: tests/phpunit/src/PublicSurfaceTest.php holds constants to it in upper snake case, and review holds properties to it.
TraitMethodNamingTest, PublicSurfaceTest and MemberOrderTest pick their subjects the same way: every trait under src/Steps, which are the traits this package names itself and flattens into a context. A helper trait is held to its own full name, so Steps\Helper\Drupal\EntityLifecycleTrait carries entityLifecycleCreateNode() and leaves the entity prefix to Steps\Drupal\EntityTrait. The traits under src/Extension are out of scope - their names are the ones Behat's and Mink's interfaces dictate - and src/Backend is composed into no context: its 1 trait, CreationAliasRegistryTrait, is composed by DrushBackend and Core.
An assertion method reads <trait>Assert<Subject><Predicate>, with Assert directly after the prefix and nowhere else. Every Then step is an assertion, and so is a helper that only checks something and fails with an assertion exception, so it's cookieAssertExists(), not cookieExists(), and xmlAssertResponseMatchesXsd(), not xmlValidateXsd(). A helper that returns what it found or produced is named for that instead, even when a miss fails the step: elementGetNth() returns an element, jsonGetValue() the value at a JSON path and jsonDecodeLoose() the decoded value. Assert always says what it asserts: messageAssertExistsOfType(), not a bare messageAssert().
- Existence: never
Present,AbsentorMissing.- Singular subjects →
ExistsorNotExists(e.g.,fieldAssertExists(),taxonomyAssertVocabularyNotExists()) - Plural subjects →
ExistorNotExist(e.g.,redirectAssertExist())
- Singular subjects →
- Containment: always
ContainsorNotContains, neverIncludes(e.g.,xmlAssertElementContains(),responseAssertHeaderNotContains()) - Subject first: the thing being asserted about precedes what is asserted of it, as in
responseAssertHeaderExists()rather thanresponseAssertContainsHeader(). The subject is what the step asserts about, soI should see the text :text in the row containing :partial_textistableAssertRowContains(), nottableAssertTextInRow(). - Qualifiers last: a qualifier that narrows the subject, opened by a word such as
With,By,In,Within,OforContaining, follows the predicate, as inmediaAssertExistsWithName()andblockAssertNotExistsInRegion(). That keeps the slot after the subject free forNot:cookieAssertNotExistsWithName(), notcookieAssertWithNameNotExists(). Hasnames something the subject holds, as inuserAssertHasRoles()andelementAssertHasKeyboardFocus(). It never stands in for another predicate: a value compared against readsEqualsorContains(stateAssertValueEquals(), notstateAssertHasValue()), and entries that must be absent readNotExist(watchdogAssertErrorsNotExist()).- No copula:
Assertalready states that the subject is something, soIsis dropped -elementAssertVisible(), notelementAssertIsVisible().
A check that throws \RuntimeException on a bad step argument or a missing precondition isn't an assertion, so it isn't named Assert. It takes the verb for what it does instead: stringParseInteger() turns a step argument into an integer, and commandRequireRun() fails when no command has run yet.
TraitMethodNamingTest reads every name for this shape: Assert right after the prefix with something after it, no qualifier ahead of Not or Exists, no Includes or Present, no Has before a compared value, and an assertion exception from every Assert method that throws one directly. A Has standing in for existence looks just like one naming something held, so review holds that half of the Has rule.
Not is the only negation particle, and it sits immediately after Assert<Subject>, directly before the predicate it negates. A negative name is its positive counterpart with Not inserted and nothing else changed.
| Instead of | Write |
|---|---|
userAssertHasNoRoles() |
userAssertNotHasRoles() |
emailAssertNoMessagesSent() |
emailAssertMessageNotSent() |
userAssertIsNotBlocked() |
userAssertNotBlocked() |
elementAssertIsVisuallyHidden() |
elementAssertNotVisuallyVisible() |
metatagAssertWithAttributesNotExists() |
metatagAssertNotExistsWithAttributes() |
The determiner No, the copula Is, an antonym standing in for a negation, and DoesNot or DoNot are all out. A method follows its step, so a step reading should not takes Not, while a step that names a state of its own takes that state's word: the module :module should be disabled is moduleAssertDisabled() and the command should fail is commandAssertFailure(), because the step asserts that state rather than negating another. TraitMethodNamingTest fails a should not step whose method carries no Not, and pairs every should not step with its should twin in the same trait, failing a pair whose method names differ by anything but Not.
A step that opens a page only navigates, so its method opens with Visit and names the page the way the step does: I visit the :media_type media edit page with the name :name is mediaVisitEditPageWithName(), and I visit the profile delete page of the user :name is userVisitProfileDeletePage(). A noun the prefix already carries isn't repeated, so a node's page is contentVisitPageWithTitle() while a term's is taxonomyVisitTermPageWithName().
A qualifier that the step opens with with reads With in the name, and a second one repeats it, in the order the step gives them: I follow the link with the index :index in the email with a subject containing :partial_subject is emailFollowLinkWithIndexWithSubjectContaining().
TraitMethodNamingTest fails an I visit step whose method doesn't open with Visit, or doesn't carry the Page or Link its step names ahead of the first qualifier.
A documented override point is public, so HELPERS.md lists it and semantic versioning covers it. A project's override relies on the package calling the method at the same point with the same signature, and a protected method promises neither, so a docblock never invites an override of one.
src/Backend publishes no override point at all, because semantic versioning covers none of its internals, so no docblock there invites an override, public or protected. A project adds or replaces a field handler through the drupal.field_handlers option or FieldHandlerCapabilityInterface rather than a subclass of Core, and a creation alias through the drupal.creation_aliases and drush.creation_aliases options or CreationAliasCapabilityInterface rather than a subclass of Core or a backend.
PublicSurfaceTest fails a docblock that says Override to, Override when, or override this followed by method, to or when, on a trait's protected method and on any method under src/Backend.
A documented override point that supplies a value is <trait>Get<Noun>(), booleans included - modalGetWaitTimeout(), commandGetTimeout(), accessibilityGetFailOnIncomplete(), diagnosticsGetShowUrl(), accessibilityGetBlankUrls(). A method that computes rather than supplies keeps a verb describing what it does, as in accessibilityFormatUrl() or restResolveUrl().
A trait reads each of its options inside a public <trait>Get...() method and nowhere else, so a project can override any option a hook or a step acts on: watchdogAfterStep() asks watchdogGetFailOnErrors() rather than reading watchdog.fail_on_errors itself, and mappingGetFlattened() builds on mappingGetGroups(). The enabled option is the exception, because skipTag() reads it, as Deciding whether a trait acts describes. OptionDeclarationsTest fails an option read anywhere else.
Every published helper names what it does with a verb: messageGetSelector(), not messageSelector(). A yes-or-no question takes Is or Has, as in authIsLoggedIn() and metatagResponseHasNoindexHeader(). A verb in the trait prefix counts, as query does in queryEntityIds().
TraitMethodNamingTest reads the words of every public helper against its VERBS list, so a helper built on a verb no helper has used yet adds that verb to the list in the same change. Steps take their verb from the step text and hooks are named for their event, so the check skips both.
A hook is named <trait><Event>, for the event it runs on rather than what it does: timeAfterScenario(), watchdogBeforeScenario(), contentBeforeNodeCreate(). 2 methods can't share a name, so a trait registers 1 hook per event. When a trait has 2 jobs on 1 event, its hook calls a protected helper for each, in the order they need to run: authAfterScenario() runs authCleanUsers() and then authCleanRoles().
Behat runs every hook on an event even when an earlier one fails, and reports each failure, but 2 calls inside 1 hook get neither for free. A hook running 2 teardowns still runs the second when the first throws, then rethrows the first failure. When both throw, it throws 1 \RuntimeException that names both and keeps the first as its previous exception, as authAfterScenario() does.
TraitMethodNamingTest fails a hook named anything but <trait><Event>.
A lookup's verb says what it does when nothing matches, so a caller knows whether to check for NULL or catch an exception without opening the docblock.
FindreturnsNULLwhen nothing matches, and its return type is nullable:tableFindRowByText(),blockFindByLabel(),cookieFindByName().Getthrows when nothing matches and never returnsNULL, so its return type excludesNULL:tableGetRowByText(),blockGetByLabel(),regionGet(). A consumer override point is aGetfor the same reason - it always supplies a value.Loadloads a set and returns an empty array when nothing matches, asuserLoadMultiple()does, or loads a document into the trait's own state, asxmlLoadDocument()does. A lookup for 1 item is aFindor aGet, never aLoad.
A trait that needs both contracts for one lookup declares the pair, and the Get calls the Find: tableGetRowByText() throws where tableFindRowByText() returns NULL. Resolve isn't a lookup verb. It derives a value from its input, as restResolveUrl() turns a relative URL into an absolute one. Read isn't one either: it reads a file's contents, as fixtureDirectoryReadFile() does, and names nothing else.
When a stored NULL and a miss look the same, a yes-or-no question tells them apart: stateFindValue() returns NULL for both, and stateExists() answers which it was. A helper that removes what it returns names the removal instead of a lookup verb, so watchdogClearErrors() deletes the errors it hands back and a later call returns only new ones.
An entity comes back as a stub, never as a Drupal object: a Get or a Find for 1 entity returns an EntityStubInterface carrying its ID under the entity type's id key, and every <trait>LoadMultiple() returns such a stub per entity, keyed by entity ID, so reading 1 of them tells you what the rest return. queryEntityStubs() builds the set from the IDs queryEntityIds() returns, so call queryEntityIds() when you only need the IDs. webformLoadMultiple() takes a partial title instead of conditions. A helper that acts on an entity takes its stub, and queryGetEntityId() reads the ID from it. queryEntityIds() resolves EntityLookupCapabilityInterface, so it runs on any backend providing it, and never \Drupal::entityQuery(). A condition is a value, matched with =, or an [operator, value] pair over =, IN, CONTAINS and LIKE: userExistsByMail() matches with LIKE and webformLoadMultiple() with CONTAINS.
When several entities share the title, label, name or description a lookup matches on, a lookup for 1 entity returns the newest, and nothing picks with reset(), current(), end() or a sort of its own:
- A content entity goes through
queryFindNewestEntityId(). Its serial ID only grows when an entity is created, so the highest ID is the newest even after an older entity is saved again. - A config entity, such as a block or a menu, goes through
entityLifecycleFindNewest(). It prefers the entity the scenario created last, read from the cleanup registry, and otherwise takes the last machine name in natural order, which follows the suffix a colliding machine name gains. - A
... does not existstep deletes every match, and a step that only checks whether an entity exists checks every match. A step that reads or changes 1 entity,the block :label in the region :region should existincluded, resolves the newest.
TraitMethodNamingTest reads each declared return type: a Find must allow NULL, a Get must exclude it, and a Load must return array or void. A Get declaring mixed, such as restGetClient(), leaves the test no type to read, so review holds it to the same rule. The native type can't say what an array holds, so each LoadMultiple() has a kernel test under tests/phpunit/src/Kernel/Steps/Drupal/ that pins its stubs and keys instead.
A method that creates, deletes or changes an entity names the verb before the entity. In a trait the verb follows the prefix: entityLifecycleCreateNode(), authCreateUser() and menuCreateLinkMultiple(), never entityLifecycleNodeCreate(). A capability interface carries no prefix, so the verb opens the name: createNode(), deleteTerm(), placeBlock() and addUserRole(). A backend of your own implements those names as they stand.
An entity-create hook is named for its event, so BeforeNodeCreate and contentBeforeNodeCreate() keep the event's word order.
TraitMethodNamingTest holds Create, Delete and Load directly after a trait's prefix. It skips hooks and the Visit methods, whose object is a page, as in contentVisitDeletePageWithTitle(). CapabilityMethodNamingTest fails a capability method that carries Create anywhere but at the start, and a create<Noun>() with no delete<Noun>() on the same interface.
A method that creates, deletes or loads several entities at once ends in Multiple, the way Drupal's own loadMultiple() does: contentCreateMultiple() for the following :content_type content exists:, taxonomyDeleteMultiple() for the following :vocabulary terms do not exist:, and userLoadMultiple() for a set. A With qualifier still comes last, so the vertical-table step is contentCreateMultipleWithFields().
The method for 1 entity is the same name without the suffix, so nothing carries Single: mediaCreate() saves 1 media item, and mediaCreateMultiple() saves the rows of a table. The noun stays singular, so it's userCreateRoleMultiple(), not userCreateRoles().
TraitMethodNamingTest fails a Create, Delete or Load method that carries Single, a create or delete step over a the following table without Multiple, and a Load returning an array without it. A plural noun looks like any other word to the test, so review holds that half.
Normalize, not Normalise, in method names and in prose.
Login and Logout, not LogIn and LogOut, in method, interface and configuration key names alike: authLogin(), FastLogoutInterface, login_url. Step text and prose keep the verb, so the steps read Given I am logged in as the user :name and When I log out. TraitMethodNamingTest checks the trait methods, and review holds interfaces and configuration keys to the same spelling.
A class name states the role the class plays, so a reader can tell a lookup table, a service that acts and a value apart without opening the file. 3 shapes cover the classes under src/Extension that name their own role:
<Noun>Registryholds things and looks them up.BackendRegistryregisters backends and resolves one by capability;UserRegistrystores the users a scenario created and tracks the current one.- An agent noun performs an action.
Authenticatorlogs a user in and out,BasicAuthenticatorapplies HTTP Basic credentials to a session, andTagOverrideResolversettles the value an option takes under a scenario's tags. A factory is named the same way, asHttpClientFactoryis. - A plain noun names a value.
Optiondeclares 1 option,Prerequisite1 prerequisite, andHttpIdentitycarries the identity a detached browser sends.TagandGroupNamehold no state: each names the value its static methods read or derive.
Every other class takes the name of the framework type it extends or implements. The contexts end in Context and the hook scopes in Scope, while BehatStepsExtension, BackendPass, ClassGenerator and DocumentElement follow Behat, Symfony and Mink. An interface ends in Interface and a trait in Trait.
A class the container builds is a registry or an agent noun, because a value is never a service. ClassNamingTest fails one in services.yml whose name doesn't end in a suffix from its ROLE_SUFFIXES, so a service named for a new role adds that suffix there in the same change.
Manager is not a role, so it names nothing. Do not reach for it, or for Handler, Helper or Service as a class suffix under src/Extension - each would describe every class there equally well.
A namespace follows the same rule. It's named for the role its classes share, as Registry and Listener are, or for the concern they serve, as Auth and Config are, so the registries live in Extension\Registry and the authenticators in Extension\Auth. ClassNamingTest fails a class or a namespace under src/Extension whose name ends in Manager, Handler, Helper or Service.
An accessor is named for what it returns, after its trait prefix where one applies: getBackendRegistry(), authGetUserRegistry(). A name and its return type cannot disagree, so renaming a class renames its accessors with it.
A type goes in the sub-namespace of its role or concern, never at the root of src/Extension, even when several sub-namespaces build on it:
- The contracts the context initializer injects through sit with the initializer.
BackendAwareInterface,ParametersAwareInterfaceandUserAwareInterfaceare all inExtension\ContextbesideInitializer\BackendAwareInitializer, which checks for them, andBackendAwareInterfaceextendsParametersAwareInterface. The classes implementing them are the 3 contexts at the package root. - A trait sits with the concern it serves, not with the contract its methods fill.
ParametersTraitreads the extension's configuration, so it's inExtension\Config, and the authenticator composes it from there as a context does. That keeps every container service clear ofExtension\Context.MinkAwareTraitgives a container service the Mink session a context has, so it's inExtension\Browser. Tagis the 1 type at the root. The step traits, the contexts and the listeners all read tags through it, so no sub-namespace owns it.
ClassNamingTest fails any other type directly under src/Extension. Adding 1 there means adding it to ROOT_TYPES, with the reason no sub-namespace owns it. The package root holds the 3 contexts and nothing else, and ClassNamingTest holds that too.
Do not write a class whose only job is to forward to a capability interface. The capability interface already is the abstraction, and a trait reaches it through backendFor(SomeCapabilityInterface::class) on WebRawContext. A wrapper adds a second name for the same contract, a second place to keep in step, and nothing else - which is why the one that existed was never wired into the container.
A class earns its place when it holds state across calls, composes more than one collaborator, or decides something the capability cannot. Forwarding 4 methods and renaming them on the way through is none of those.
A concrete class under src/ is final unless a project is meant to extend it. Sealing a class that has shipped breaks every project extending it, while opening one breaks nothing, so a new class starts final and is opened once a project has a reason to extend it.
These are the extension points. Each one stays open, along with every class that extends or implements it:
- The contexts:
WebRawContext,WebContextandDrupalContext, one of which a project's own context extends. - The backends: a project backend may extend
DrupalBackend,DrushBackendorBlackboxBackendrather than implementBackendInterfacefrom scratch. Core: a version core extends it to change what its Drupal major changed, and the class thedrupal.coreoption names can extend the Core for the site's major.- The field handlers: a handler for another field type extends a shipped one, the way
OgStandardReferenceHandlerextendsEntityReferenceHandler. - The browser adapters: an adapter for a browser driver derived from a shipped one extends that driver's adapter.
HttpClientFactory: a package replaces thebehat_steps.http_client_factoryservice with a subclass, as docs/content/extending/extension.md describes.
DocumentElement stays open as well. It's installed in place of Mink's own DocumentElement, which other code extends. A class that another class under src/ extends, such as ParseException, is open because PHP requires it.
An open class promises a project nothing about its protected members. A subclass of a backend, Core or a field handler relies on methods a minor release can rename or stop calling, as Consumer override points says. A project's own field handler implements FieldHandlerInterface and is registered through the drupal.field_handlers option or FieldHandlerCapabilityInterface, and its own creation alias implements PreCreateAliasInterface or PostCreateAliasInterface and is registered through the creation_aliases options or CreationAliasCapabilityInterface.
Every other class is replaced rather than extended. A suite that swaps in its own registry or authenticator through a *.class container parameter implements that class's interface, and option resolution is replaced through TraitOptionResolverFactoryInterface. A final class keeps its members protected, like every other class here, so rector.php skips the Rector rules that would make them private.
tests/phpunit/src/ExtensionPointTest.php fails a class that's neither final nor an extension point, and an extension point that's final. Opening a class adds it, or the type it extends, to EXTENSION_POINTS there, with the reason a project extends it.
3 things sit close together in this codebase, and each has 1 name. Use it in identifiers, docblocks and prose alike.
| Term | What it is | Where it shows up |
|---|---|---|
| Backend | What a step resolves a capability from: Drupal in-process, Drush or Blackbox | src/Backend, BackendInterface, the backends list and the @backend: tag, backendFor() and getBackend() on WebRawContext, BackendRegistry |
| Browser driver | Mink's driver behind the session: BrowserKit, Selenium2 or Chrome | browserDriverFor() and browserDriverHas() on WebRawContext, the adapters and capabilities under src/Extension/Browser |
| HTTP client | What a step sends its own request through: the page, detached or bare client | httpPageClient(), httpDetachedClient() and httpBareClient() on WebRawContext, HttpClientFactory |
Mink owns the word "driver" across the Behat ecosystem, so no name this package owns uses it for anything else. Backend in one of our identifiers means a backend, and Driver or BrowserDriver means Mink's. In prose, Mink's is a "browser driver", never a "Mink driver" or "the session's driver", and that holds inside src/Extension/Browser too. An HTTP client is neither. It's a BrowserKit AbstractBrowser, so a docblock may call the object a browser.
Each side has a counterpart on the other, and the names keep the 2 apart:
| Backend | Browser driver |
|---|---|
DrevOps\BehatSteps\Backend\BackendInterface |
Behat\Mink\Driver\DriverInterface |
DrevOps\BehatSteps\Backend\Exception\UnsupportedBackendActionException, thrown by backendFor() |
Behat\Mink\Exception\UnsupportedDriverActionException, thrown by browserDriverFor() |
$this->getBackend($name), a backend by the name its suite gave it |
$this->getSession()->getDriver(), the browser driver |
A file that needs both of a pair imports each under its own name, with no alias.
The package is 2 products in 1: the steps, and the helpers the steps are built on. A project that outgrows the shipped steps stops calling the helpers from Gherkin and starts calling them from PHP, so the helpers are public API in the same sense the step text is. docs/content/guides/scenario-styles.md argues why.
Visibility is the marker. A public method is a helper; a protected one is an implementation detail that promises nothing and may change in any release. Nothing else distinguishes the 2, which is why a helper worth calling from a project's own step definitions is declared public and one that only serves the machinery stays protected.
A member is published in HELPERS.md when all of the following hold. Everything published is covered by semantic versioning.
- It is declared by a trait under
src/Stepsorsrc/Steps/Helper, or by a class indocs.php'sHELPER_CLASSES. A helper is published once, under the trait whose file declares it, never under a trait that composes that one. - It is
public. Aprivatemember cannot be reached from a project's context that extends a shipped one, and has no place in a trait. - A trait member begins with its trait's name, which is the collision rule every trait member follows anyway. A member of a
HELPER_CLASSESclass, such asWebRawContext::backendFor(), carries none. - It carries no
#[Given],#[When],#[Then],#[Transform]or hook attribute. Those are registered with Behat and belong to the vocabulary. - Its docblock carries no
@internal.
3 obligations follow from publishing a member:
- It needs a summary.
ahoy lint-docsfails on a published helper with no docblock description, because the reference would carry a blank entry, andPublicSurfaceTestfails on the same thing from the other side. A{@inheritdoc}docblock is resolved to the interface or parent that declares the method, so implementing an interface is enough. - It is named as carefully as a step. The naming conventions above apply to a helper exactly as they do to a step method.
- It shows up in review. HELPERS.md is committed and gated by
--fail-on-change, so promoting a method topubliclands in the diff with its signature and summary. That diff is what keeps the surface deliberate: a method cannot be narrowed again before the next major.
Withdraw a member that exists only to serve the machinery with @internal, naming who calls it:
/**
* Sets the backend registry.
*
* @internal
* Injection point called by the context initializer.
*/What a project can call shouldn't depend on which trait a helper happens to sit in, so a job published in 1 trait is published in every trait that does it. xmlGetFirstNode() is public because jsonGetValue() is: both are the lookup a trait's element or path assertions run, and both throw when nothing matches. xmlGetContent() and jsonGetContent() return the content the steps read, a fixture step's content included, so they're public next to the xmlParse() and jsonDecodeLoose() that take it.
A helper that only resembles a published one is decided on its own. metatagResolveUrl() resolves a URL against the origin of a base URL, the Mink base URL unless the caller passes another, rather than against the whole base URL restResolveUrl() uses, and metatagFetchUrl() serves only the hreflang return-link check, where httpDetachedClient() already covers fetching a page in general, so both stay protected.
A class follows the same rule among its own members. Authenticator is final and replaced through its interfaces, so its 3 page-element getters are all protected.
A step body is a thin wrapper over named helpers, so every behavior a scenario can reach is also reachable from a project's own step definitions. A step parses its arguments, calls helpers and guards on what they return. Everything else belongs in a helper.
tests/phpunit/src/StepBodyTest.php reads every step under src/Steps and holds it to that shape:
- No step calls another step of its trait. When 2 steps share logic, it moves into a helper both call, typed to what each caller has, so nothing builds a
TableNodeor passes'1'just to fit another step's signature.blockCreateInstance()hands the block it just created toblockApplyConfiguration()instead of makingblockConfigure()look it up again by label. - No
switch,match,try, closure or arrow function. Each one is logic of its own. - A loop only dispatches. Its body holds guards and then at most 1 call, as in
foreach ($table->getHash() as $row) { $this->taxonomyCreate($vocabulary, $row); }. - At most 4 statements, guards aside. A guard is an
ifwith noelsewhose body only throws. A step that needs a fifth statement is usually doing a helper's job.
Traits lay their members out in this order:
- Trait composition (
use), then constants, then properties. - Hooks (
#[BeforeScenario],#[AfterStep]and the like). Givensteps, thenWhensteps, thenThensteps.- Helpers, public and protected together. Visibility marks what is published, not where a member sits, so a helper stays next to the ones it reads with.
Within each of those groups, keep the members in whatever order reads best - the rule settles the groups, not what happens inside one. tests/phpunit/src/MemberOrderTest.php enforces it.
Reordering an existing trait into this layout leaves STEPS.md untouched. docs.php already groups steps by Given, When and Then and keeps source order inside each group, so this layout only applies a sort the generated documentation applies anyway.
These 5 style questions have no dominant form in this codebase. Every form listed is correct and behavior-identical where it appears, and converging any of them would churn 25 to 400 sites for no functional gain. Match the surrounding file and do not convert existing code from one form to the other as a drive-by change.
- Nullable-object absence:
if (!$element)(~40 sites) and=== NULL(~35 sites) are both accepted.is_null()is not - it has been converged away. - Array emptiness:
empty($array)(~30 sites) and$array === [](~25 sites) are both accepted. Newer code leans strict, which is a weak preference rather than a rule. - Docblock tag order and
@codeindentation:@parambefore@codeand the reverse both appear, as do flush and indented example bodies.docs.phprenders@codebodies into STEPS.md, so changing indentation reflows the generated documentation. - Test method names:
test<Scenario>(~410 methods, as intestAnUnsetParameterIsNull()),test<Method><Scenario>(~290, as intestApplyAfterCreateIgnoresNonArrayValues()) andtest<Method>(~85, as intestNormalize()) are all accepted. A third of the test classes mix shapes, so name a new test like the existing tests of the same method, or like the rest of its class when there are none. - Data provider form: a generator declared as
\Iterator(~150 providers) and a plain array declared asarray(~165) are both accepted.iterableis not - it has been converged away, so the return type always tells the 2 apart.
Each of these has 1 answer. Write new code this way, and where a tool holds the form, its name is given.
- Coding standard: code follows the Drupal coding standards. A local variable or a method argument is
snake_case, and a method name or a class property iscamelCase. - Line breaks:
"\n"reads andPHP_EOLwrites. Code that splits, matches, trims or replaces line breaks in text it received - a docblock, a file, command output, page text or a PyString - uses"\n", because splitting on it handles both\nand\r\ntext, whilePHP_EOLon Windows misses every\n-only line. Console output, exception and assertion messages, and generated documentation and reports end their lines withPHP_EOL, and a string built withPHP_EOLis split withPHP_EOLtoo. A line break spliced into a nowdoc or heredoc template, or into text built only to be searched or parsed in place, stays"\n"so the result never mixes line endings. - Paths: a path is built with
/, which PHP accepts on every platform:$directory . '/' . $file,__DIR__ . '/fixtures'.DIRECTORY_SEPARATORonly compares against, splits or trims a path the platform returned - fromrealpath(),getcwd(),getFileName()or a directory iterator - because on Windows that path holds backslashes. - Closures: a closure or arrow function that never touches
$thisisstatic.SlevomatCodingStandard.Functions.StaticClosurefails the rest inahoy lint, andahoy lint-fixadds the keyword. The sniff can't see a closure that calls an instance method throughself::orstatic::, which still needs$this, so check those by hand. - Empty bodies: an empty class, interface, trait, enum, function or closure body is
{}on its declaration line, a promoted constructor's included:) {}.tests/phpunit/src/EmptyBodyTest.phpenforces it. - Control structures: an
if,foreach,for,while,do,switchortrystatement has a blank line above it and below it, except where it's the first or last statement in its block or itscase. The coding standard allows no blank line after an inline comment, so a comment between 2 statements, a@codeCoverageIgnoreEndmarker included, sits against the statement below it, and the blank line goes above the comment.tests/phpunit/src/ControlStructureSpacingTest.phpenforces the blank lines andahoy lintthe comment placement. - Constant types: every constant under
src/declares its native type, in a class, an interface or a trait alike:public const string CONFIG_KEY = 'behat_steps';. A subclass that redeclares one declares the same type, or PHP refuses to load it.tests/phpunit/src/TypedConstantTest.phpenforces it. - Booleans: a single-word boolean local or parameter reads as a question with
is_:$is_found,$is_exact,$is_strict.has_names something held, as in$has_title, andshould_an expectation, as in$should_match. A test's expected value stays$expectedwhatever its type. - Local names: a caught exception is
$exception. A directory path is$directory, and a qualified one keeps the short suffix, as in$features_dir, matching thereport_dirandtemp_diroptions. The stub a create call returns is$created, a value saved to be restored is$originalor$original_<what>, a\ReflectionPropertyis$propertyor$<name>_property, never_prop, and a test's expected exception message is$expected_message. - Dynamic calls: a method name held in a variable is braced, as in
$this->{$method}(). - Declaring methods: a reader names the method it reads with
methodFor()and aMETHOD_SUFFIXconstant, soConfigSchemaReader::methodFor()gives<prefix>ConfigSchema()andPrerequisiteReader::methodFor()gives<prefix>Prerequisites(). - No
Helpermethod: a method is named for what it does, asfieldFillDatetimeInput()fills 1 input, soHelpernames nothing on a method, just as it names nothing on a class. - Calls into a trait: an instance method is called through
$this->. A static method a trait declares is called throughstatic::, becauseself::binds at compile time to the class the trait was flattened into: a shipped context composes the trait and a project subclasses that context, soself::would reach past the project's override.DateTrait::dateGetNow()is the documented example. - Data providers: a provider is named
dataProviderfollowed by its test's name without thetestprefix, and it's declared after that test, so each provider serves exactly 1 test and renaming a test renames its provider.tests/phpunit/src/DataProviderConventionTest.phpenforces both, along with the return types above.
The PHPUnit suite under tests/phpunit/src/ holds each of these in 1 form. tests/phpunit/src/TestConventionTest.php enforces all 8.
- Base class: a unit test extends
UnitTestCase, directly or through a base that does, and a kernel test extends Drupal'sKernelTestBase.UnitTestCasegives each test a workspace thattearDown()removes, along with the hook scope builders and the helpers below, so callparent::setUp()andparent::tearDown()when you override them. - Placement: a test with no counterpart in
src/sits at the root oftests/phpunit/src/, as Unit and kernel tests describes. A test that declares#[CoversNothing]has none, so it never sits underUnit/orKernel/:BehatDistConfigTestreadsbehat.dist.php, and the convention tests read the whole source tree. - Fixtures: a unit test writes a fixture through
writeFixture(), which creates the parent directories inside that workspace, and reads the workspace path fromstatic::$tmp. Nothing callsfile_put_contents(),touch()orcopy()itself, so no fixture outlives its test or lands in the repository. An empty directory is the one thingwriteFixture()can't make, andmkdir()stays for it. - Reflection: a class held in a variable, whether a name a test discovered or an object, is reflected through
static::reflect(), which narrows a string to a class string. A::classconstant goes tonew \ReflectionClass(), which keeps the class type PHPStan reads. - Static members: a test reaches a static member through
static::, so a subclass that redeclares it is honored, the same way a trait does.self::stays where late static binding has nothing to resolve: in a constant expression such as a property default or a constant value, where PHP rejectsstatic::; on a final class, a final member or a private member, where Rector'sConvertStaticToSelfRectorrequires it; and in an anonymous class, which nothing can extend.self::fail()is the common case, because PHPUnit declares its assertions final. - Helper names: a test helper names what it does with a verb, like a published helper:
createUserStub(),collectRegisteredSteps(),readSignificantTokens(). A yes-or-no question opens withisorhas, as inhasCallToAny().TestConventionTest::HELPER_VERBSlists the verbs in use, so a helper built on a new verb adds it there in the same change. - Test doubles: a double reaches into the code under test through 2 prefixes that production code never declares, so a test-only method can't override a production one by accident.
call<Method>()runs a protected method and is named after it without its trait prefix, ascallSlug()runsstringSlug().test<Accessor>()reads or writes protected state, astestGetExitCode()andtestSetRoles()do. Every other public method on a double overrides or implements a production method and keeps its name, ashttpDetachedClient()does. - Test double names: a context composing the trait under test is named
<Trait>TestImplementation, with any qualifier for a variant before the suffix, as inAccessibilityTraitRetryTestImplementation. Any other double is<Qualifier><Role>, the qualifier saying what sets it apart:RecordingDrushBackend,ParserExposingDrushBackend,InjectedPermissionsCore.TestandTestabledon't say what sets a double apart, so neither qualifies a name.
The Behat harness in tests/behat/bootstrap/ - FeatureContext, FeatureContextTrait and BehatCliTrait - holds these conventions from src/:
- Tags: a tag is read through
Tag::has()orTag::values(), passing the scope and a constant that names the tag, and no hook attribute filters on a tag. A feature hook has no scenario, so it passes the feature. - Numbers: a numeric step argument is parsed with
stringParseInteger(), sosleep for five secondsfails instead of sleeping for 0 seconds. - Hooks: a hook is named
<prefix><Event>and declared before the steps.FeatureContextTrait,FeatureContextand the contextBehatCliTraitgenerates share thetestprefix, andBehatCliTraititself usesbehatCli. A prefix holds 1 hook per event, so a second job on an event runs from a helper that hook calls, astestBeforeScenario()runstestStopSessions()and thentestShortenBigPipeWait(). - Constants: every constant declares a native type.
A tag the harness reads to configure a scenario starts with test-, as @test-bigpipe-timeout does, so it can't be mistaken for a library tag registered in tag_registry() in docs.php. A scenario that runs on 1 Drupal fixture only is tagged @test-drupal:<major>: behat.php reads DRUPAL_VERSION and filters out the tag of every other fixture under tests/behat/fixtures_drupal/, the same way it filters out @test-skipped.
The harness steps keep their own wording, placeholders, patterns and signatures rather than follow Steps format and Step arguments. They aren't published, and renaming one means editing every .feature line that uses it: every @test-trait scenario runs some behat configuration and scenario steps: or scenario steps tagged with "...":. BehatCliContext.php holds none of these conventions, because it's kept in step with an upstream copy.
TraitMethodNamingTest, MemberOrderTest, PublicSurfaceTest and TagReadTest read only src/, since each of them also holds a vocabulary rule the harness doesn't follow. The list above is held in review rather than by a test.
The package ships 3 layers, and the dependency only runs one way: Steps on Extension on Backend. The 3 contexts at the package root - WebRawContext, WebContext and DrupalContext - sit above all 3: they are the 1 place that reaches every layer, so a project extends 1 of them and gets the whole stack.
src/Backendis the part that talks to the site: it bootstraps a Drupal site in-process or shells out to Drush, creates entities, and expands field values into their storage shape. It knows nothing about Behat or Mink, which is what keeps it usable outside a Behat run. Its root holds what any backend may provide -BackendInterface,BlackboxBackend,EntityStub, the creation alias contracts, the exceptions, the random generator and the capabilities any backend may implement - andBackend\Drupalholds the Drupal and Drush backends,Corewith its field handlers and Drupal creation aliases, and the capabilities only a Drupal backend provides:Batch,Block,Config,ConfigEntity,Core,Draggableviews,Drush,EntityDefinition,File,Log,MenuLink,Module,Node,PathAlias,Queue,QueueItem,Redirect,SearchApi,SearchIndex,State,Term,UserActivityandWorkflow.src/Extensionis the Behat extension:ServiceContainer/BehatStepsExtensionreads thebehat_stepsconfiguration and builds the container, andServiceContainer/BackendProviderInterfaceis what each backend provider contributes to it,Registry/holds the backend, user and scenario tag registries,Auth/holds the authenticator and the basic authenticator,Context/holds the contracts the context initializer injects through, the initializer itself and the hook attribute reader,Config/holds option resolution andParametersTrait, which reads the extension parameters,Browser/holds the browser capabilities, their adapters and resolver, theDocumentElementinstalled in place of Mink's own, andMinkAwareTrait,ServiceContainer/Driver/holds thebrowserkit_httpbrowser driver factory,Http/holds the factory behind the detached and bare HTTP clients,Prerequisite/holds the prerequisite declarations and their reader, andHook/,Listener/,Selector/andGenerator/carry the entity and user creation hooks, the per-scenario backend selection and skip-tag check, theregionMink selector and the starter-class generator.Extension\Drupalholds what the extension needs for a Drupal site:ServiceContainer/DrupalBackendProvider, which adds thedrupalanddrushsettings, the Drupal login text and selector defaults and the 2 backends' service definitions, andHook/, the node, term and language creation hooks.src/Stepsis the step vocabulary - traits a context mixes in. Placement follows the subject:Web/holds the steps that mean something on any site, andDrupal/the ones that only mean something on a Drupal site, whether they reach a backend or only drive the page. The directory a trait sits in is the context STEPS.md groups it under, so a project testing a site that isn't Drupal extendsWebContextand gets no step it can't use.Helper/holds the step-free traits a step trait and a context both compose, split the same way intoWeb/(fixture directories, heading lookup, JavaScript error collection, last-step tracking, the request header bag, string shaping, table transposition, token replacement) andDrupal/(the entity lifecycle, authentication, static caches, fixture files, entity lookups). They register no Gherkin, so composing one twice shares its state instead of registering a step twice, and every member carries its trait's prefix so a name cannot collide once flattened.Exception/AssertionExceptionis what a trait without a Mink session throws.
The contexts are 1 chain. WebRawContext carries the plumbing, composes 3 of the web helper traits (LastStepTrait, RequestHeadersTrait and StringTrait) and registers no steps; WebContext extends it and composes every trait under Steps/Web; DrupalContext extends that and composes every trait under Steps/Drupal. ContextCompositionTest holds that directory-to-context coverage in both directions, and holds the chain to 1 composition of each trait, because a subclass re-composing a parent's trait registers its steps twice. ClassNamingTest holds the package root to the 3 contexts.
A trait names the host it needs with @phpstan-require-extends, and composes the helper traits its own body calls. ContextCompositionTest walks every step trait and fails one that reaches a helper member it does not compose, so the plumbing a trait needs travels with it rather than being assumed of the host.
The host is the base class that provides every method the trait calls without declaring it: Behat\MinkExtension\Context\RawMinkContext when a Mink session is all the trait touches, and DrevOps\BehatSteps\WebRawContext when it reaches a backend or the extension configuration. A trait that calls nothing outside itself names no host. A context that composes AuthTrait declares UserAwareInterface, so the initializer injects the user registry and the authenticator.
That is what keeps src/Steps/Helper a library rather than a catch-all: teardown lives with the concern that creates the thing being torn down. A trait that creates entities composes EntityLifecycleTrait and so brings the AfterScenario pass that removes them; a context composing no such trait runs no entity teardown at all. Trait flattening is idempotent, so the 14 traits that compose it still yield 1 registry, 1 hook and 1 deletion pass in reverse creation order.
A trait's directory is its classification, so nothing has to be declared twice: src/Steps/Web and src/Steps/Drupal register Gherkin and src/Steps/Helper registers none. scripts/lint-traits.php fails a step trait composing another step trait, and a helper trait carrying a step or transform attribute. A helper may carry a hook: the trait that owns a teardown carries the hook that runs it. Shared logic goes in a helper trait under src/Steps/Helper named for its concern, composed by whoever needs it.
The neutral root is written for any backend and shaped by Drupal's. The root of src/Backend and the root of src/Extension reference no Drupal code, which scripts/lint-layers.php holds, and name no CMS in their prose: a contract there speaks of an entity type id, a storage value and the site's mail system, never of a Drupal one. The shapes those contracts carry are the ones the Drupal step vocabulary needs - the message array mailGet() returns, the item lists getEntityValues() returns, the link relationships getEntityUrl() takes, the =, IN, CONTAINS and LIKE operators of a lookup, the bins and tags of the cache, and the uid and langcode keys a user and a language stub carry - and a backend for another system maps its own shapes onto them. A sentence that explains a contract by naming Drupal belongs in Backend\Drupal or Extension\Drupal, next to the code that is Drupal's.
The web half falls back to a framework and never depends on one. A trait under src/Steps/Web finds a page element through generic markup first - a label, a name, a standard attribute - and may fall back to a framework's markup when the generic lookup finds nothing, or take a framework's branch behind a runtime check, as WaitTrait waits on Drupal.ajax only when typeof Drupal says the object exists. A fallback names the framework in a comment and never in step text, and markup a project may need to change is an option whose default is Drupal's, as add_more_button_selectors is. fieldFillMultiValueItems() and fieldFillDatetimeInput() target Drupal's widgets this way, because a multi-value widget and a date widget have no neutral markup to fall back to. The lint holds the code to it: the web half references no Drupal class and calls no function PHP doesn't define, so a fallback is markup and JavaScript, never PHP.
The in-process backend keeps the working directory on the Drupal root. Core::bootstrap() changes into the root and stays there, because Drupal resolves file_public_path and its other relative settings against the working directory, and a file written through public:// from the Behat process would otherwise land outside the site. A web-half trait that resolves a relative path against the directory the run started from captures that directory at @BeforeSuite, as AccessibilityTrait does for its report directory. DiagnosticsTrait reads the live directory and returns a path outside it unchanged, so its re-run command stays correct after a bootstrap.
A step is only as portable as the backend behind it, so each trait falls into 1 of 3 bands. Which band a trait is in decides which capability its steps resolve, and therefore which suites can run them.
- Nothing. Every trait under
src/Steps/WebexceptMessageTrait,RegionTrait,MappingTraitandBasicAuthTraitreads and drives the page through Mink alone. They run on any backend, against any site, with no Drupal at all.BatchTrait,BigPipeTraitandConfigOverrideTraitneed nothing from a backend either, and sit undersrc/Steps/Drupalbecause only a Drupal site renders the batch progress bar and the BigPipe placeholders, or reads the config override headerConfigOverrideTraitsends. That header also goes to every backend of the scenario providingRequestEnvironmentCapabilityInterface, without bootstrapping any, so the site code a backend runs reads it as the browser's requests do. - Extension configuration, but no backend.
MessageTraitandMappingTraitread themessage.selectorsandmapping.groupsoptions,RegionTraitreads theregionsmap through theregionselectorBehatStepsExtensionregisters, andBasicAuthTraitreads the basic authenticator. They need configuration, not a bootstrapped site. - Named capabilities. Every other trait resolves the capabilities it works through.
CacheTrait,ConfigTrait,StateTrait,TimeTrait,ModuleTrait,DrushTrait,QueueTrait,WatchdogTrait,TestmodeTrait, and the steps that create, change or delete users, roles and content resolve named capabilities (CacheCapabilityInterface,CronCapabilityInterface,ConfigCapabilityInterface,ConfigEntityCapabilityInterface,StateCapabilityInterface,ModuleCapabilityInterface,DrushCapabilityInterface,QueueCapabilityInterface,LogCapabilityInterface,UserCapabilityInterface,RoleCapabilityInterface,ContentCapabilityInterface,NodeCapabilityInterface,TermCapabilityInterface). They work on any backend implementing them, which for most is the Drush backend as well as the in-process one: a user's roles and password, for one, go throughUserCapabilityInterface, which Drush serves. A backend declares a capability only when it serves every method of it, so what only the in-process backend can do sits on a capability of its own:the following item is in the queue :queue:goes throughQueueItemCapabilityInterface, indexing 1 entity or running a search module's cron hook throughSearchApiCapabilityInterface, and the times a user last accessed the site and logged in throughUserActivityCapabilityInterface, so those steps reach the in-process backend whatever the order lists first. A lookup goes throughQueryTrait, which resolvesEntityLookupCapabilityInterface, and a delete hands the id it found to the capability that deletes that entity type, so a find-then-delete step calls nothing of Drupal's itself. An update hands a stub carrying the id toupdateEntity(), and a read takes a field's values fromgetEntityValues()in their storage shape, a bundle fromgetEntityBundle(), or puts the field into the lookup's conditions, so no step saves or deletes a Drupal object itself. A step visiting an entity's page asksgetEntityUrl()for it by link relationship, such asedit-form, so the site's own route sets the path. A config entity is created, changed and copied throughConfigEntityCapabilityInterface, which saves through the entity storage so the save hooks run. The create pipeline parses a table's cells against the field definitions throughEntityDefinitionCapabilityInterface, which also answers whether a bundle exists and what it is labelled, forContentTrait,MediaTrait,ContentBlockTraitandTaxonomyTrait, and tellsParagraphsTraitan entity type's id key and a bundle's fields.EmailTraitcollects throughMailCapabilityInterface,BlockTraitplaces, configures, finds and reads blocks andContentBlockTraitcreates them throughBlockCapabilityInterface,ContentTraitsets a path alias throughPathAliasCapabilityInterfaceand moves a node through its workflow withWorkflowCapabilityInterface,RedirectTraitcreates, finds and deletes redirects throughRedirectCapabilityInterface,MenuTraitnames a link's parent throughMenuLinkCapabilityInterface,FileTraitwrites throughFileCapabilityInterface,SearchApiTraitindexes throughSearchIndexCapabilityInterfaceandDraggableviewsTraitorders throughDraggableviewsCapabilityInterface, which only the in-process backend provides.
CoreCapabilityInterface has no band. It hands out the in-process core through getCore(), which stays the Drupal backend's own contract, so no step resolves it and scripts/lint-layers.php rejects it under src/Steps. A step that has to run in-process resolves a capability only the in-process backend provides, which also names what a backend of your own would implement.
A capability method leaves the site consistent behind it, so a step compensates for nothing a backend does. moduleInstall() and moduleUninstall() leave the container and the router rebuilt, deleteUser() drains the batch that cancelling an account queues, and draggableviewsSetOrder() invalidates the caches the order it saved feeds, so ModuleTrait, AuthTrait and DraggableviewsTrait call the method and nothing else. A method that changes the site owns the cache tags, batches and rebuilds that follow from the change, and a step that clears a cache after calling a capability is the sign of a method that doesn't.
A step names a capability and never a backend. WebRawContext::backendFor() walks the scenario's backend order, returns the first backend implementing that capability and bootstraps only that one; when none does, it throws an UnsupportedBackendActionException naming the capability and the order. The order itself comes from the backends list under behat_steps and the @backend:NAME tag, documented in docs/content/guides/configuration.md.
Every create and delete on a capability interface follows 1 contract, so teardown code can delete whatever a scenario created without checking first. A create returns the stub, flagged as saved once the backend holds the created entity: createLanguage() leaves a language that already exists alone and returns the stub unsaved, and createRole(), which takes no stub, builds a user_role one. A backend running outside the test process holds no entity, so the Drush backend's createUser() and createRole() return their stubs unsaved, carrying the new uid or id value. An update and a copy return a stub the same way: updateEntity() and updateConfigEntity() return the stub they were given, flagged as saved, and duplicateConfigEntity() returns a stub of the copy. A delete returns void and does nothing when its target doesn't exist. A stub with no identifier at all still throws, since that's a malformed argument rather than a miss. tests/phpunit/src/CapabilityContractTest.php holds every create, update, copy and delete to its return type, and the backend tests pin the miss.
A capability sits at the root of src/Backend when any backend may provide it, and under Backend\Drupal when only a Drupal backend can. Whether the Drush backend declares one is decided per capability by 1 rule: the Drush backend declares a capability when a Drush command serves every method of it in full, and never runs php:eval to fill a gap. A capability no second backend serves in full stays with the in-process backend, so the capabilities below marked in-process are exactly what a second backend has to implement itself.
| Capability | Drush | Why |
|---|---|---|
Authentication |
in-process | Logging in switches the account of the process that runs the site |
Batch |
yes | Every Drush command runs the batch it starts, so processBatch() has nothing left to do |
Block |
in-process | No command places or configures a block |
Cache |
yes | cache:rebuild, cache:clear and cache:tags; a bin's entries can't be listed, so cacheDeletePagePath() empties the bin |
Config |
yes | config:get, config:set and config:delete |
ConfigEntity |
in-process | No command creates, changes or copies a config entity through its storage |
Content |
in-process | Creating or updating an entity expands its values through the field handlers of the process that saves it |
Core |
in-process | Hands out the in-process core |
CreationAlias |
yes | Each backend class keeps its own aliases; the Drush backend creates only users, so it runs only theirs |
Cron |
yes | core:cron |
Draggableviews |
in-process | No command writes a display's order |
Drush |
yes | The Drush backend's own capability |
EntityDefinition |
in-process | The field definitions a value is parsed against live in the process that saves it |
EntityLookup |
in-process | No command queries entities by field |
File |
in-process | No command writes a file through a stream wrapper |
Language |
in-process | language:add and language:info exist, and no command deletes a language |
Log |
yes | watchdog:show and watchdog:delete; no command writes an entry, so the capability has no write |
Mail |
in-process | No command sends a message |
MenuLink |
in-process | No command reads a menu link's plugin id |
Module |
yes | pm:install, pm:uninstall and pm:list |
Node |
in-process | No command creates a node |
PathAlias |
in-process | No command sets an alias |
Queue |
yes | queue:list, queue:run and queue:delete |
QueueItem |
in-process | No command adds an item |
Redirect |
in-process | No command creates a redirect |
RequestEnvironment |
yes | The environment of each command |
Role |
yes | role:create, role:perm:add and role:delete |
SearchApi |
in-process | Indexing 1 entity reaches the Search API of the process |
SearchIndex |
yes | search-api:index |
State |
yes | state:get, state:set and state:delete; no command tells a stored NULL from an absent key, which the Drush backend documents on stateExists() |
Term |
in-process | No command creates a term |
User |
yes | user:create, user:cancel, user:role:add, user:information, user:login and user:password |
UserActivity |
in-process | No user: command sets the access or login time |
Workflow |
in-process | No command reads a workflow or moves an entity through it |
Not every Drupal name in a Drupal step trait is a leak. A trait under src/Steps/Drupal names the entity types and fields it works with, so these stay as literals in the traits and move to no capability:
- The id key of the entity type a trait names:
nid,uid,mid,tid,fid,ridandid, asqueryGetEntityId(),queryEntityStubs()andentityLifecycleRegister()take it.EntityDefinitionCapabilityInterface::getEntityIdKey()is for a trait whose entity type is a step argument, asParagraphsTraithas. - The base fields a lookup conditions on, such as
statusanddefault_langcodeinuserIsActive()andcontentIsPublished(), andmenu_nameon a menu link. - The
public://,private://andtemporary://schemesFileTraitprepares andFixtureFileTraitsearches: the step text names them too. - The state key and the cache tag
TestmodeTraitwrites, like thetesting.timekeyTimeTraitwrites and therenderbinCacheTraitnames. Each is the documented contract of the module or the consumer the trait serves, and the writes go throughStateCapabilityInterfaceandCacheCapabilityInterface, which both backends serve, so a capability of their own would copy 2 names into 2 backends for no portability.
The library's own harness in tests/behat/bootstrap is not a step trait. Its steps that log a watchdog entry or send mail through the fixture module's hook_mail call Drupal's API, and resolve CoreCapabilityInterface, whose contract is that API. No consumer step follows them, and LogCapabilityInterface gains no logWrite() for them, because no Drush command writes an entry.
A step that sends its own HTTP request picks 1 of 3 clients on WebRawContext by what the response is for, and the method it calls says which:
| The response | Call | Used by |
|---|---|---|
| Becomes the page the next steps read | httpPageClient() |
RestTrait |
| Belongs to the scenario's visitor, but isn't the page | httpDetachedClient() |
FileDownloadTrait, the hreflang return-link check in MetatagTrait |
| Carries nothing of the scenario | httpBareClient() |
The engine fetch in AccessibilityTrait |
Never build a client of your own, whether that's cURL, file_get_contents() over HTTP or a new HttpBrowser(). It wouldn't get the connection settings a project declares on its browserkit_http session, so a suite on a staging site with a self-signed certificate would pass on the page and fail on the download. A trait calling any of the 3 carries @phpstan-require-extends \DrevOps\BehatSteps\WebRawContext.
The page client exists only under BrowserKit, and throws UnsupportedDriverActionException in a JavaScript session, so a step built on it fails there by design. The detached and bare clients work under every browser driver. In a @javascript scenario they're a sidecar: the browser holds the page while the step sends its request from PHP, so a download keeps working there. Keep a detached or bare response in the trait's own state, as FileDownloadTrait does, or inside the step, as the hreflang check does. Never write it to the session, or the next page assertion reads the wrong content.
In a unit test, the test implementation overrides httpDetachedClient() or httpBareClient() to return an HttpBrowser over Symfony's MockHttpClient, as FileDownloadTraitTest and MetatagTraitTest do. docs/content/guides/http-clients.md covers the settings and the identity the detached client carries, and docs/content/extending/ covers the extension points.
A step never reaches into Drupal itself: no \Drupal::, no Drupal\ class and no Drupal function. What it needs from the site goes on a capability, adding a method or a capability when none covers it. A step resolves the capability it works through as its first statement, so a suite without that capability fails naming it before the step changes anything. A step whose trait declares prerequisites calls $this->assertPrerequisites(__TRAIT__) next, as Deciding whether a trait acts describes.
scripts/lint-layers.php holds the boundaries. It reads every file of each declared layer and fails on any code reference into the namespaces that layer excludes: imports, type declarations, and class names reached through a string. src/Backend excludes Behat, Mink, Extension, Steps and the 3 contexts. src/Extension excludes Steps and the 3 contexts, apart from Generator/ClassGenerator, which writes extends WebRawContext into the starter class it generates. The root of each of those 2 layers, outside its Drupal sub-namespace, excludes Drupal and both Drupal sub-namespaces, apart from ServiceContainer/BehatStepsExtension, the composition root that lists the shipped backend providers. src/Steps/Web, src/Steps/Exception, WebRawContext, WebContext and every web helper trait under src/Steps/Helper/Web exclude Drupal. src/Steps/Drupal, src/Steps/Helper/Drupal and DrupalContext exclude Drupal too, and every class under DrevOps\BehatSteps\Backend\Drupal but the capabilities, so a Drupal step trait reaches the site through a capability and nothing else. The longest prefix that matches a name decides, so CoreCapabilityInterface, which declares getCore(), stays excluded although it sits among the capabilities. LintLayersTest fails a file under src/ that no layer reads. A class isn't the only way in, so the check reads calls as well: a Drupal step trait never calls getCore(), which hands out the in-process core, and a step trait on either side calls no function PHP doesn't define, which keeps out Drupal's procedural API. That rule also rejects a call whose target only runtime knows, such as $function() or $object->$method(), unless the variable holds a closure the file assigns it, and a call through call_user_func() and its relatives, which name their target in a value the check can't read. A prose mention in a comment is fine - it's the code references that matter. ahoy lint runs it.
composer.json declares behat/behat: ^3.33.0 || ^4.0 and friends-of-behat/mink-extension: ^2.7.5 || ^3.0@alpha, so the library installs on either Behat major. Behat 4 needs Mink extension 3, which has only an alpha release so far, and Composer applies an inline stability flag only to the root package's own requirements. So the @alpha lets this repository resolve Behat 4 and does nothing for a consumer:
- A project on the default
stableminimum stability resolves Behat 3 with Mink extension 2.7. It gets Behat 4 only by requiringbehat/behat:^4andfriends-of-behat/mink-extension:^3.0@alphaitself. - A project whose minimum stability admits alpha releases can resolve Behat 4 with the Mink extension alpha, as a project requiring only this library does under
prefer-stable. Nothing pins the major, though, so the project's other requirements can still settle it on Behat 3. Requiringbehat/behat:^4is what guarantees Behat 4.
Working on this repository makes it the root package, so its own flag applies: a plain composer install here resolves Behat 4.0 with Mink extension 3.0.0-ALPHA.1, and provisioning narrows the fixture to the major each leg runs.
src/Extension plugs into 5 Behat extension points, and each one is written to satisfy Behat 3.33 and Behat 4 at the same time. Keep it that way when touching them.
- Signatures are typed for Behat 4, widened for Behat 3. Behat 4 types its interfaces where 3.33 leaves them untyped, so implementations declare the Behat 4 return type (
ClassGenerator::supportsSuiteAndClass(): bool,HookScope::getName(): string,Extension::getConfigKey(): string) and keep the parameter untyped ormixedso the 3.33 interface is not narrowed. - The
browserkit_httpfactory is registered fromBehatStepsExtension::initialize(). The factory extends Mink's own, records each session'shttp_client_parametersthroughbuildDriver()for the transport the library's own requests share, and builds the session exactly as Mink does. Mink declares its ownMinkExtensionfinalfrom version 3, the release that carries Behat 4 support, so it can't be subclassed, and a wrapper would take theminkkey away from any other Mink extension a project registers.initialize()runs once every extension is activated and before any configuration tree is built, so it hands the factory toregisterDriverFactory()on Mink's ownMinkExtensionwhen it holds the key - the hook every browser driver extension uses - and leaves any other extension under that key alone. That works on both majors. BackendListenerreads the event, not the removed interface. Behat 4 dropsScenarioLikeTested. BothScenarioTested::BEFOREandExampleTested::BEFOREcarry aBeforeScenarioTested, which declaresgetFeature()andgetScenario()itself in both versions, so the listener type-hints that class.EntityHookresolves the hook callable for the installed major. Behat 4 types the callee constructor ascallable, and[class-string, method]isn't callable for an instance method.ContextMethodCallableFactorywraps such methods on Behat 4 and is absent on Behat 3, soEntityHook::resolveCallable()uses it only when the class exists and passes the pairHookAttributeReaderbuilds through unchanged otherwise. It returnsmixed, because the 2 majors type that parameter differently and PHPStan only analyzes against the installed one. Behat's own parameter type checks the value at runtime.- The
context.class_generator.simpleoverride survives by service id. Behat collects generators by tag before an activated extension'sprocess()runs and injects them as references, so replacing the definition behind that id swaps the class in both versions.
The test suite follows the same rule. Behat 4 reads only PHP configuration and ignores docblock annotations, so the suite runs from behat.php, BehatCliTrait writes a behat.php for every nested run, and every step and hook - in src/ and in tests/behat/bootstrap/ - is declared with a PHP attribute. Behat 3.33 reads both the same way. Both configurations list every Mink session under sessions instead of using the browser driver name shorthand, because Mink 3.0.0-ALPHA.1 reads the shorthand with an Undefined array key "sessions" warning.
behat.dist.php is the reference a consumer copies from, so it sets every option BehatStepsExtension accepts. BehatDistConfigTest names any option missing from it, which is what keeps it complete as the extension grows. The same test holds the reference suite to the project's own FeatureContext and Mink's MinkContext, so a project copying the file never registers a shipped context beside the context that extends it. Behat never loads it here, because behat.php takes precedence.
Behat 3 strips the @ from a tag by default and Behat 4 keeps it, while TaggedNodeInterface::hasTag() compares strictly, so a bare-name comparison that matches on one major silently fails on the other.
Read tags through Tag, never through hasTag() or getTags() directly. A hook passes its scope to one of 3 readers, which read the scenario's tags together with its feature's, so a tag on the Feature: line applies to every scenario below it:
// A flag: '@testmode' on the scenario or on its feature.
if (Tag::has($scope, self::TESTMODE_TAG)) {
// ...
}
// The values of a parametrized tag: '@watchdog:php @watchdog:cron' gives
// 'php' and 'cron', feature tags first.
$types = Tag::values($scope, self::WATCHDOG_TAG);
// The on/off state of each value: '@module:help @module:!contextual' gives
// ['help' => TRUE, 'contextual' => FALSE]. A later tag for a value replaces
// an earlier one, so a scenario tag overrides a feature tag.
$modules = Tag::valueStates($scope, self::MODULE_TAG);Every tag has the same syntax: a flag stands alone, a parametrized tag takes its value after Tag::SEPARATOR (:), and Tag::NEGATION (!) before a value switches it off. A tag with nothing after the separator names no value.
A trait names each tag it reads in a constant carrying its prefix, such as TestmodeTrait::TESTMODE_TAG, and documents the tag in tag_registry() in docs.php. Tag::JAVASCRIPT names the tag Mink reads to run a scenario in its JavaScript session. TagReadTest fails on a trait that passes a reader a string literal or a single node, or calls Tag::all(), Tag::on() or Tag::normalize(), and DocsTest fails on a tag constant the registry does not list.
A filter in a hook attribute, as in #[AfterScenario('@acme')], reads its tag outside the readers, so neither test sees it. A scenario or feature hook filters on @javascript alone, the tag Mink reads itself, and reads any other tag in its body through a reader. TagReadTest fails on a hook attribute that filters on anything else.
A reader also takes a single node, for a hook that ranks the 2 lines itself: ResponsiveTrait validates the @breakpoint: tag of the scenario and of its feature separately. Tag::all(), Tag::on() and Tag::normalize() return raw lists for code outside the traits, such as a listener. Nothing outside Tag calls getTags() or hasTag(), so grep finds any new one.
A consumer switches a trait's hooks off with @behat-steps-skip:<TraitName> on a scenario or a feature, or for a whole profile or context with the trait's enabled option. The tag names a trait and never a hook, and it switches off every hook that trait registers. SkipTagListener fails the run at scenario start on a skip tag whose value is not shaped like a trait name, such as a hook method or a name without the Trait suffix, and on one naming a trait that no context of the suite composes, directly, through a parent class or through another trait. A misspelled trait name would otherwise switch nothing off. The name has to match the trait's own, case included.
A scenario hook asks the guard in 1 of 2 shapes, naming its own trait. A hook that acts returns when its trait is skipped:
#[AfterScenario]
public function acmeAfterScenario(AfterScenarioScope $scope): void {
if ($this->skipTag(__TRAIT__, $scope)) {
return;
}
// ...
}Resetting the trait's own properties may come before the guard, because a reset changes nothing a skipped scenario keeps. bigPipeBeforeScenario() clears its flags and then guards.
A step hook's scope carries no scenario tags, and neither does a transform's, so a trait whose step hooks or transforms need the answer records it in a property instead:
#[BeforeScenario]
public function acmeBeforeScenario(BeforeScenarioScope $scope): void {
$this->acmeEnabled = !$this->skipTag(__TRAIT__, $scope);
}The hook assigns the answer rather than returning, so a property that defaults to on is switched off for a skipped scenario. DateTrait and MappingTrait read theirs in a transform and WaitTrait in a step hook, and an AfterScenario hook may read the same property instead of a guard of its own.
__TRAIT__ resolves to the trait the code is written in, so the guard cannot name the wrong trait or fall out of step with a rename.
2 other kinds of scenario hook carry no guard, because they have nothing to switch off:
- A hook that only resets in-memory state - its trait's own properties, or a static cache in this process - holds nothing a scenario would want to keep.
- A hook that acts only on its trait's own activation tag, such as
@breakpoint:, is switched off by removing the tag.
tests/phpunit/src/SkipGuardTest.php holds all of this. It fails a scenario hook that asks the guard in neither shape and isn't listed in its UNGUARDED_HOOKS with a reason, a skipTag() call naming anything but __TRAIT__, and a trait that reads a skip tag directly.
A trait's hooks and steps answer 3 separate questions, and each has 1 mechanism. Keeping them apart is what lets a trait stay quiet where it should and fail where it should.
| Question | Mechanism | When the answer is no |
|---|---|---|
| Opt-in: is the trait switched on? | skipTag(__TRAIT__, $scope), which reads the enabled option and the skip tag |
The hook returns quietly |
| Activation: does this scenario ask for it? | The trait's own tag check, such as Tag::has($scope, self::TESTMODE_TAG) |
The hook returns quietly |
| Prerequisites: does the site provide what it needs? | The trait's <prefix>Prerequisites(), checked by assertPrerequisites(__TRAIT__) |
The scenario fails, naming what's missing |
Don't merge them into 1 boolean. Opted out means the trait does nothing, while opted in without its prerequisites means the scenario can't be trusted, so the first returns and the second throws.
A setup hook applies them in that order:
#[BeforeScenario]
public function acmeBeforeScenario(BeforeScenarioScope $scope): void {
if ($this->skipTag(__TRAIT__, $scope) || !Tag::has($scope, self::ACME_TAG)) {
return;
}
$this->assertPrerequisites(__TRAIT__, $scope);
// ...
}A trait declares what it needs from the site in a <prefix>Prerequisites() method, named by its prefix like <prefix>ConfigSchema(), and states each prerequisite through a backend capability:
protected function acmePrerequisites(): array {
return [
Prerequisite::capability(ContentCapabilityInterface::class),
Prerequisite::check(
static fn(ModuleCapabilityInterface $backend): bool
=> $backend->moduleIsEnabled('acme'),
'the "acme" module from the "drupal/acme" package is enabled',
),
];
}Prerequisite::capability()holds when a backend in the scenario's list provides the capability.Prerequisite::check()takes a static closure whose only parameter is typed to a capability interface. The checker hands it a backend providing that capability, and the closure returns whether the prerequisite holds. The closure gets nothing else: a condition that depends on an option, a tag or a step argument is opt-in, activation or input validation, not a prerequisite.- A description is a clause completing "requires that", in lower case with no closing period. It appears both in the failure message and in the Prerequisites table
docs.phprenders into STEPS.md.
A module the trait needs is declared. A core module counts the same as a contrib one, since a site can uninstall it: FileTrait declares file, MediaTrait media and TaxonomyTrait taxonomy, as WebformTrait declares webform. A module it only adapts to, such as search_api_solr, is asked with $this->anyBackendFor(ModuleCapabilityInterface::class)->moduleIsEnabled(), which reuses a backend the scenario already reached, so the question never starts a second backend the way backendFor() would under @backend:drush. Either way, module state goes through ModuleCapabilityInterface, never \Drupal::moduleHandler()->moduleExists().
- A step calls
$this->assertPrerequisites(__TRAIT__)right after resolving its backend. It checks only when a scenario uses it, so a suite that never runs a webform step never needswebform. A public helper a project calls from its own steps checks the same way, aswebformLoadMultiple()andmediaLoadMultiple()do. - Only the steps that need every declaration check, because
assertPrerequisites()checks the trait's whole list. A module only some steps need is checked in those steps alone:ContentTraitcheckspathin its path alias step, andFileTraitchecksfilein its managed file steps. A step that creates through a narrow capability rather than Drupal's API, such asTaxonomyTrait's term creation, checks nothing, so it keeps running on any backend providing that capability. - A setup hook checks at scenario start, straight after its guard, and passes its scope:
$this->assertPrerequisites(__TRAIT__, $scope). Theenabledoption and the skip tag stop a trait's hooks but never its steps, so only a hook's failure names them as a way out. - A check at step scope that reads what a prerequisite provides checks again first, in case the scenario removed it.
- A teardown never throws for an unmet prerequisite. It asks
$this->prerequisitesMet(__TRAIT__), or reads a flag its setup set, and undoes only what the setup did, so it can't replace a failure the scenario already recorded.
The checker reads each trait's declarations once per context class and run, since a context can redeclare the declaring method, and evaluates them in order. For each capability it goes through anyBackendFor(), which reuses a backend the scenario already reached when that backend provides the capability, before trying the first one listed, so checking starts a backend only when none the scenario reached provides the capability. A capability with no check still reaches its backend, so declaring a capability only the in-process backend provides first is what keeps the rest in-process: once it has resolved drupal, a module check runs there even under @backend:drush. That capability is the one the trait's checking steps work through, such as BlockCapabilityInterface for BlockTrait or ContentCapabilityInterface for MediaTrait, so the Prerequisites table in STEPS.md names what a backend has to implement. Answers aren't cached, because a tag, a step or an out-of-process command can install or uninstall a module at any time.
tests/phpunit/src/PrerequisiteDeclarationsTest.php holds all of this. It fails a malformed declaration, a trait that declares prerequisites but never checks them or checks prerequisites it never declares, a check naming anything but __TRAIT__, and a moduleExists() call anywhere under src/Steps or src/Steps/Helper.
Keep the require section of composer.json minimal - it should contain only what every consumer needs regardless of which traits they use.
require: the framework and browser abstraction that virtually all steps build on -php,behat/behat,behat/mink- plus what the backend and Behat layers need at runtime. Both ship insrc/, so every consumer loads them:symfony/processfor the Drush backend, andfriends-of-behat/mink-extension,symfony/config,symfony/dependency-injection,symfony/event-dispatcherfor the extension, its config schema andWebRawContext's Mink ancestor;behat/mink-browserkit-driver,symfony/browser-kit,symfony/http-clientandsymfony/mimefor the HTTP clients; andsymfony/yamlfor the extension's service definitions.require-dev+suggest: any package used by only a subset of traits. List it inrequire-devso this library's own test suite still exercises it, and insuggestwith a message naming the exact trait(s) or step(s) that need it (asjustinrainbow/json-schemadoes forJsonTrait).
A package that code under src/ calls is declared in its own right, even when a require dependency already installs it. That arrival is the other package's implementation detail, which it can drop in any release. symfony/filesystem is the case in point: symfony/config requires it, yet it sits in require itself, because FileDownloadTrait, which WebContext composes, builds a Filesystem for its download directory.
When a new trait needs a package, decide up front: trait-specific packages go in require-dev + suggest, never in require. Demoting a package from require to suggest later is a breaking change for consumers relying on transitive installation, so batch such demotions into the next major release and document them in MIGRATION.md.
A patch against this repository's own vendor directory lives at patches/<vendor>/<package>/<name>.patch, so the package it applies to is its own directory and the file name becomes its description. composer.json declares none. These are separate from the fixture site's patches, which the Drupal 12 fixture declares itself - see Patched contrib.
scripts/provision.php writes the extra.patches map over this package's own composer.json, applies it with composer patches-relock and composer patches-repatch, and restores the file afterwards. The declaration is not committed because Composer Patches registers a Dependencies resolver that reads extra.patches from every installed dependency and resolves relative paths against the consuming project's root - a project requiring this package would look for the patch inside its own tree and fail.
Install Docker, Pygmy, Ahoy and shut down local web services (Apache/Nginx, MAMP etc.).
- Check out the project repository in one of the supported Docker directories.
pygmy upahoy build- Access the built site at
http://<directory>.docker.amazee.io/, where<directory>is the name of your checkout directory.ahoy infoprints the exact URL. Each checkout gets its own hostname, so several clones can run side by side.
Use ahoy --help to see the list of available commands.
There are 3 types of tests in this repository: unit tests, kernel tests and Behat tests. The documentation site in docs/ has its own unit and accessibility tests, which docs/README.md covers.
Both suites are declared in phpunit.xml and run against the fixture site, because the backend layer and the tests around it resolve Drupal classes from there. Run ahoy build first.
Tests live under tests/phpunit/src/ in a directory named after their suite: Unit/ and Kernel/. Anything outside Kernel/ belongs to the unit suite. Inside a suite directory the path mirrors src/, so src/Steps/Helper/Web/StringTrait.php is tested by tests/phpunit/src/Unit/Steps/Helper/Web/StringTraitTest.php. Tests with no counterpart in src/ - the docs generator, the layer and trait linters, the provisioning script and the convention tests - sit at the root of tests/phpunit/src/.
ahoy test-unit # Run the unit suite
ahoy test-kernel # Run the kernel suite
ahoy test-coverage # Run both suites and write one coverage reportCoverage is collected across both suites in a single run, so the report covers everything the tests reach. Its output paths are declared in phpunit.xml.
phpunit.xml also requires coverage metadata, so a test counts only the lines of the units its #[CoversClass] or #[CoversTrait] names. A class that only other classes' tests reach, such as an exception they expect, reads as uncovered until a test of its own names it, as BootstrapExceptionTest does.
Behat tests are used as functional/integration tests to validate the functionality of the traits. These Behat tests run in the same way they would be run in your project: one context, FeatureContext.php, extends DrupalContext and carries the test-only overrides, and is then run on the pre-configured fixture Drupal site using test features.
Run ahoy build to setup a fixture Drupal site in the build directory.
ahoy test-bdd # Run all Behat tests
ahoy test-bdd path/to/file # Run all Behat scenarios in specific feature file
ahoy test-bdd -- --tags=wip # Run all Behat scenarios tagged with `@wip` tagEvery step the library registers needs at least 1 scenario that runs it. Behat only resolves a step definition when a scenario uses it, so a pattern no scenario reaches can ship unusable without the suite noticing. tests/phpunit/src/StepScenarioCoverageTest.php enforces this in the unit suite: it matches every registered pattern against the steps the feature files run and fails on any step nothing reaches. Steps a @test-trait scenario hands to its nested run count, since they do run. Steps in a scenario the suite filters out, such as one tagged @test-skipped, don't.
Put a new scenario in the feature file that already tests its trait rather than in a new file, and give it a descriptive tag, such as @datetime, so it can run on its own. A negative @test-trait: scenario, such as one for FieldTrait, reaches its page through a plain navigation step like I go to "node/add/page", and avoids custom steps its nested run may not have.
The nested runs find the repository through BehatCliTrait::behatCliGetRootPath(), which derives the root from the trait's own location, and rector.php derives its paths from __DIR__ the same way. Neither writes /app, so both follow the checkout wherever it lives. The service hosts in the configuration, such as nginx and chrome, stay as they are: they name the Docker services the suite runs against, not paths.
Static fixture files - HTML pages, XML, JSON, images, archives - live in tests/behat/fixtures.
Traits with no Drupal dependency are tested against those files served by a PHP built-in server instead of the fixture Drupal site. Tag the scenario @phpserver and address the file directly:
@phpserver
Scenario: Assert that a heading exists
When I visit "http://cli:8888/elements.html"
Then I should see the heading "Element Testing Page"The server runs for the duration of a tagged scenario and serves tests/behat/fixtures at its root, so an edited fixture applies on the next run.
Drupal traits are tested through the fixture site, which receives a copy of the same directory in build/web/sites/default/files during provisioning. Run ahoy copy-files after editing a fixture that such a scenario reaches through a Drupal path.
@codeCoverageIgnoreStart and @codeCoverageIgnoreEnd suppress genuinely unreachable defensive code. They are not a way to hide an untested branch.
The distinction matters because PHPUnit drops ignored lines from the report entirely. Ignoring a line that a test would have covered lowers the reported rate, which is merely wasteful. Ignoring a line that no test covers raises it, which reports progress that does not exist. codecov/patch and codecov/project are required checks, so that second case shifts the baseline every later pull request is measured against.
Mark a block only when a test cannot reach it in this environment:
- An I/O failure that cannot be provoked -
file_get_contents()returningFALSEon a file just written,tempnam()failing. - A type guard that the preceding call makes impossible -
!$node instanceof NodeInterfacedirectly after a successful load. - A capability branch for a backend the suite does not run.
Do not mark a branch that a scenario could reach. In particular:
- Skip-tag guards (
@behat-steps-skip:<TraitName>) are reachable by definition - add a scenario carrying the tag. - Argument validation driven by a step parameter is reachable by passing an invalid value.
If a reachable branch has no test, the fix is the test, not the marker.
ahoy debug- Set breakpoint
- Run tests with
ahoy test-bdd- your IDE will pick up an incoming debug connection
.github/workflows/test.yml runs 3 jobs. The test matrix runs the unit, kernel and Behat suites, Lint checks the code and the generated pages, and Docs builds and tests the documentation site.
1 job, on PHP 8.4. It checks that composer.json is normalized, then ahoy lint runs composer validate, parallel-lint, phpcs, phpstan, rector --dry-run, gherkinlint, scripts/lint-layers.php and scripts/lint-traits.php, and ahoy lint-docs checks every region docs.php generates for drift. Both are the commands you run locally, and the job is green only when both are.
The job provisions before it lints, and PHPStan needs it to. ahoy lint points the analyzer at the fixture with DRUPAL_ROOT and DRUPAL_VENDOR_ROOT, which mglaman/phpstan-drupal reads only with the patch scripts/provision.php applies to it. Run ahoy build before ahoy lint on a fresh checkout, or PHPStan aborts before it analyzes anything.
| Legs | What they prove |
|---|---|
PHP 8.3 / 8.4 / 8.5 x Drupal 11 x normal / lowest x Behat 3 |
The library works across the supported PHP range against both the newest and the oldest resolvable dependencies. The lowest legs are what hold the Behat 3.33 floor. |
chrome_headless x Drupal 11 x normal: PHP 8.3 and 8.5 on Behat 3, PHP 8.4 on Behat 4 |
The steps drive a browser without Selenium, over the Chrome DevTools Protocol, on both Behat majors. That browser driver is Drupal-version independent, so the 3 legs take their breadth from the PHP axis, 1 version each. All 3 stay on normal deps: dmore/behat-chrome-extension hands the browser driver domWaitTimeout and socketTimeout, which the oldest dmore/chrome-mink-driver it accepts does not define, so a lowest resolution cannot boot Chrome at all. |
PHP 8.3 / 8.4 / 8.5 x Drupal 11 x normal / lowest x Behat 4 |
The same unit, kernel and Behat suites pass on Behat 4. See Behat 4 legs. |
PHP 8.5 x Drupal 12 x normal / lowest x Behat 4 |
The next core major, on a patched contrib set. See Drupal versions. |
The unit and kernel suites run on every leg that is not driven by a Behat profile, since a profile changes how the Behat suite runs and not what PHPUnit covers.
Coverage is produced on 1 Selenium leg and 1 chrome_headless leg, both on Behat 3, merged by Codecov into a single report, and its upload fails the leg rather than passing quietly. Test artifacts (.logs) are uploaded from every leg.
Each Behat 4 leg provisions the fixture with BEHAT=4. scripts/provision.php narrows the composer.json constraint with composer update --with="behat/behat:^4". dmore/behat-chrome-extension accepts Behat 4 from 1.5.0, the floor composer.json sets, so it stays in the build and the PHP 8.4 leg runs the suite under chrome_headless.
On Drupal 11 it removes dvdoug/behat-code-coverage, which accepts Behat 4 only from 5.5. That release, and 5.4 before it, needs phpunit/php-code-coverage 12, while Drupal 11's drupal/core-dev holds the fixture on PHPUnit 11.5, which requires ^11.0.12. The fixture therefore resolves 5.3.7, the newest release that still takes PHPUnit 11, and that one caps behat/behat at ^3. The composer.json constraint is open at ^5.3.7, so the repository root, running PHPUnit 12, does install 5.5 - only the Drupal 11 fixture is held back. behat.php registers the coverage extension only when it is installed.
Every leg names the major it runs, as in Test PHP 8.3, Drupal 11, Behat 3, Deps normal, so a check name says what it covered without a lookup. The main branch ruleset requires checks by name, so renaming a leg means updating its required checks to match.
BEHAT reaches the container through ahoy, so provisioning the fixture for Behat 4 locally is a matter of setting it. Run ahoy provision to switch back to Behat 3:
BEHAT=4 ahoy provision
ahoy test-bddEach major has its own fixture directory under tests/behat/fixtures_drupal, addressed as d${DRUPAL_VERSION}, and DRUPAL_VERSION defaults to 11 everywhere it is read. Renovate leaves Composer major updates alone, so moving to a new core major is a deliberate change rather than an automatic one.
Code that differs between Drupal majors goes in a version core, DrevOps\BehatSteps\Backend\Drupal\Core{N}\Core. It extends the newest version core below N, or the base Core when there is none, and overrides only what major N changed. DrupalBackend builds the newest version core at or below the major it detects, and rejects a Core whose nearest Core of the package is the one for another major. DrupalBackendCoreSelectionTest covers both with the Core99\Core fixture, which stands in for a version core no Drupal release has.
Drupal 12 is pinned to ~12.0.0-beta1 and runs 2 legs of its own - PHP 8.5, Behat 4, normal and lowest - so the normal / lowest pair covers both majors. Getting there takes a patched contrib set, because Drupal 12 and Symfony 8 broke most of what the fixture installs. See Patched contrib.
Drupal 12 constrains its own grid hard:
- Drupal 12 requires PHP 8.5, so PHP 8.3 and PHP 8.4 are out.
- Drupal 12 requires Symfony 8, and the newest Behat 3 release still requires
symfony/yaml ^5.4 || ^6.4 || ^7.0, so Behat 3 is out.behat/behat4.0 accepts Symfony 8. - Drupal 12 raises the database floor to MariaDB 10.11, which is why docker-compose.yml runs
uselagoon/mariadb-10.11-drupal. Drupal 11 asks for 10.6 or newer, so one image serves both majors.
The grid has no chrome_headless leg. The Chrome browser driver doesn't depend on the core major, so the Behat 4 Chrome leg on Drupal 11 covers it, and the Drupal 12 legs still run every @javascript scenario through Selenium.
Building the Drupal 12 fixture takes 3 requirements the Drupal 11 fixture does not share:
mglaman/composer-drupal-lenient, with anextra.drupal-lenient.allowed-listnaming every contrib module whosedrupal/corerequirement excludes Drupal 12. Most of the modules the fixture installs have no release declaringdrupal/core ^12, and the plugin strips the core constraint so they install anyway. A module comes off the list once the release at its floor declares^12. The hosted lenient endpoint on drupal.org is not used - it currently redirects to a page that does not exist. A Composer plugin only shapes a solve it is already installed for, and the fixture has no solution until this one runs, so scripts/provision.php installs it globally before the build update; Composer loads global plugins for local projects.drush/drush ^14@dev. No tagged Drush release accepts Symfony 8. This is why the fixture setsminimum-stabilitytodevwithprefer-stable.drupal/scheduled_transitions ^2.9.0-beta4, the first release declaring Drupal 12.
The Drush backend supports Drush 13, the first release that runs Drupal 11, and newer: the Drupal 11 fixture installs 13.x and the Drupal 12 fixture 14.x-dev. It runs each command by its canonical colon-separated name, such as pm:install or core:cron, which every supported release registers, and never by a legacy alias such as pm-enable or cron. DrushBackendMethodsTest fails a command issued in any other form, and the @backend:drush scenarios run the cache, cron and user commands against the Drush each fixture installs.
The fixture also takes drupal/core from source rather than dist. From 12.0.0-beta1 the release package no longer carries core's test files, which the PHPUnit bootstrap and the Kernel suite need, and scripts/provision.php passes no --prefer-dist so the per-package setting holds.
Every contrib module carries a floor in d12/composer.json at the oldest release known to work on Drupal 12. An older release predates the major and fails on it whatever the patches do - drupal/token at its lowest resolvable release declares no return type on getSubscribedEvents() - and a patch written against one release does not apply to another. Without the floors the lowest leg fails before a single scenario runs.
The floors cover contrib only. lowest still resolves the oldest usable version of the library's own dependencies, which is what those legs are for.
Relaxing the Composer solve is only half of it. Drupal reads core_version_requirement from each extension's .info.yml and refuses to enable one that excludes the running major, so after the update scripts/provision.php appends || ^12 to that key across the installed contrib modules. The rewrite touches the throwaway build/ tree only, never the fixture sources.
Drupal 12 removes contact, history and shortcut from core, so d12/config/sync carries neither those modules nor the config that depended on them, and the ModuleTrait scenarios use syslog and contextual, which both majors ship. 12.0.0-beta1 goes further: it moves Olivero, Claro and Search out of core, which the fixture installs from contrib under the same machine names, and removes Toolbar and the Syndicate block, which the fixture drops.
To take the Drupal 12 fixture as far as its legs do, set the 3 variables they set. ahoy build resets the containers, so the PHP version has to be on the build as well as the provisioning:
PHP_VERSION=8.5 DRUPAL_VERSION=12 BEHAT=4 ahoy buildThe suites then run against it with DRUPAL_VERSION=12 BEHAT=4 in front of ahoy test-unit, ahoy test-kernel and ahoy test-bdd.
Getting contrib installed is not the same as getting it to run. Drupal 12 and Symfony 8 between them broke 14 of the modules the fixture installs, so d12/composer.json carries a patch for each under extra.patches, applied by cweagans/composer-patches. The patches live in d12/patches/ and fall into 5 groups:
| What changed | Modules |
|---|---|
Drupal 12 removed the magic original property |
redirect, webform |
Symfony 8 removed Request::get() |
webform |
| Drupal 12 removed annotation-only plugin discovery | ctools, webform |
| Drupal 12 and Symfony 8 tightened method signatures | commerce, date_recur, dynamic_entity_reference, eck, entity_reference_revisions, paragraphs, pathauto, profile, redirect, scheduled_transitions, state_machine, time_field, webform |
Drupal 12 stopped discovering template_preprocess_HOOK() |
eck |
Drupal 12 also deleted the Archiver plugin system, which webform injected but never used, and moved the text_with_summary field type out of text into a module of its own, which the fixture now requires and enables.
Every patch is generated against the released source rather than against the build/ tree, which carries whatever the last provisioning applied. A patch generated from build/ silently loses any hunk the tree already has.
A patch stops being needed the day its module ships a Drupal 12 release, at which point both the patch and the module's floor come out.
drush cim can enable the modules, abort on a fatal raised while it creates config entities, and still exit 0. The site then comes up with its modules enabled and none of the content types, fields, webforms or entity types the suite asserts on. scripts/provision.php therefore reads a config entity only the fixture defines and fails when the import did not land, on every major - without that check a Drupal 12 build reports success on an empty site.
Coverage is not collected on the Drupal 12 legs. Drupal 12 brings PHPUnit 12, so dvdoug/behat-code-coverage 5.5 does install there and Behat 4 coverage becomes possible for the first time, but the coverage report stays on the settled Drupal 11 legs while core 12 is a pre-release.
1 job, on Node 24, with no Docker stack. It builds the site in docs/, and a broken link or anchor fails the build. It then runs the site's unit tests, its spell check and its accessibility checks, as docs/README.md describes. The unit tests include the check that the step and helper references ship in the Composer package. Last, when the Netlify secrets are set, it deploys the build: a preview for each pull request, linked in a comment on it, and the development site on every push to main, as Previews describes.
.github/workflows/release-docs.yml publishes the site to GitHub Pages on every release tag, and on a manual run from a tag or branch, as Publishing describes.
- Build the fixture site and make the required changes
ahoy drush cex -yahoy update-fixturesto copy configuration changes from build directory to the fixtures directory
docs.php is the only generator of reference documentation. One run writes every generated page and region:
| Target | Holds |
|---|---|
| STEPS.md | The step vocabulary, on 1 page |
| HELPERS.md | Every helper, on 1 page. The documentation site has no helper pages, so this is the only helper reference |
| docs/content/web-steps/, docs/content/drupal-steps/ | The step vocabulary on the documentation site, 1 section per context: the index in the marked region of README.md, and 1 page per trait, linking the trait's helpers in HELPERS.md |
| docs/content/guides/configuration-reference.md | The extension options and the tag reference |
A step trait names its page with a @title tag in its docblock, in its own group before any other tag: @title Batch API names the page of Drupal\BatchTrait. The title names what the trait works with, in sentence case, so it reads as an entry in the sidebar, and each section lists its pages in title order. A helper trait has no page, so it carries no title.
The same run validates the steps format, that every documented step matches exactly 1 definition, that every step trait carries a title, that every published helper carries a summary, that every registered tag in a docblock, an example or a feature file takes its value after :, and that every environment variable the source reads is documented.
ahoy update-docs # Update documentation
ahoy lint-docs # Check documentation for errors
An extension option and a tag are both generated from their definition in the source, so neither can be added without appearing in the reference. Write the option's ->info() in BehatStepsExtension and the tag's description in tag_registry(), then run ahoy update-docs. Never hand-edit inside a generated region.
STEPS.md and HELPERS.md ship in the Composer package, where a consuming project's AI agent reads them, and the rest are pages of the documentation site in docs/. docs/README.md covers the site: working on it locally, the checks and publishing to behat-steps.drevops.com. Documentation covers the writing conventions. Both references open with a link to the skill in STEP_SKILL_FILE, as Shipped skills describes.
skills/ holds the AI agent skills the package ships, 1 directory per skill in the Agent Skills format, and Composer installs them in a consuming project's vendor/drevops/behat-steps/skills/. skills/create-behat-step/SKILL.md writes a step of the project's own on the helpers, in a trait its FeatureContext uses.
A shipped skill runs against the release it ships with, so it reads the facts it needs from that release's STEPS.md, HELPERS.md and src/, and states only the procedure and the rules a project's code follows. Its frontmatter holds only the keys the specification defines, so every agent accepts it, and it takes its arguments from the request rather than from a placeholder only 1 agent fills in.
.claude/skills/ holds the skills that stay out of the package: update-architecture-docs for working on this repository, and migrate-behat-steps, which a project fetches from GitHub because it runs while vendor/ still holds 3.x.
3 tests hold a shipped skill to its release:
- tests/phpunit/src/SkillFormatTest.php fails a skill whose frontmatter strays from the specification, or that names a method neither
DrupalContextnor the skill's own code declares. - tests/phpunit/src/CreateBehatStepSkillTest.php composes the example trait of
create-behat-steponto aDrupalContextsubclass. It fails when the skill's code calls a method thatHELPERS.mddoesn't publish and Mink'sRawMinkContextdoesn't declare public, and when the skill names a method that is none of those, no method of its example and none of the protected and step methods it warns against. ItsPROTECTED_METHODSandSTEP_METHODSlist those, and it fails when 1 of them stops being protected or a step. docs/tests/unit/distributionArchive.test.jsfails when a skill, or a package file a skill reads, stays out of the archive.
So renaming, unpublishing or publishing a helper that a skill names fails the unit suite until the skill follows. Change both in the same pull request.