Skip to main content

Extending the step vocabulary

The step vocabulary is the step and helper traits under src/Steps and the 3 contexts that compose them: WebRawContext, WebContext and DrupalContext. Usage explains how to use it as it ships, and this page covers what you add on top: steps of your own, overrides for what no option reaches, options for a trait of your own, and the rules Behat applies when your context composes or overrides what it inherits. You'll need it once a flow deserves a step in your project's own words, or a trait needs a value its options can't give it.

Writing a step of your own​

A step of your own is a method with a Behat step attribute on a context of your own, built on the same helpers the shipped steps call. Web helpers and Drupal helpers list every one of them.

A suite that writes its own Drupal steps composes the helper for the concern it touches. EntityLifecycleTrait brings entity creation and the AfterScenario pass that removes what was created:

<?php

use Behat\Step\When;
use DrevOps\BehatSteps\Backend\Entity\EntityStub;
use DrevOps\BehatSteps\WebRawContext;
use DrevOps\BehatSteps\Steps\Helper\Drupal\EntityLifecycleTrait;

class SpecContext extends WebRawContext {

use EntityLifecycleTrait;

#[When('I publish a page titled :title')]
public function publish(string $title): void {
$this->entityLifecycleCreateNode(new EntityStub('node', 'page', [
'title' => $title,
]));
}

}

WebRawContext also composes 3 of the web helper traits (LastStepTrait, RequestHeadersTrait and StringTrait), so a project's own step definitions reach them on $this. Composing 1 of those helper traits in a step trait as well shares the same state rather than duplicating it.

Register what it creates​

The entityLifecycleCreate*() helpers register each entity they create, so the end-of-scenario pass in Automatic cleanup deletes it. An entity created through a capability directly isn't registered until the step does it.

A step of your own registers what it created, passing the stub the capability returned and the key its entity type stores the ID under, mid for a media item:

$stub = new EntityStub('media', 'document', ['name' => 'Annual report']);
$created = $this->backendFor(ContentCapabilityInterface::class)
->createEntity($stub);
$this->entityLifecycleRegister($created, 'mid');

A step that sends its own request​

HTTP clients covers the 3 clients a step sends its own requests through.

A domain step that needs a response without disturbing the page asks for the detached client, exactly as the shipped traits do. The browser starts with no history, so it takes an absolute URL:

#[Then('the sitemap should list the path :path')]
public function sitemapAssertListsPath(string $path): void {
$browser = $this->httpDetachedClient();
$browser->request('GET', $this->locatePath('/sitemap.xml'));
$content = $browser->getInternalResponse()->getContent();

if (!str_contains($content, $this->locatePath($path))) {
$message = sprintf('The sitemap does not list the path "%s".', $path);
throw new ExpectationException($message, $this->getSession()->getDriver());
}
}

The page stays where the scenario left it, and the sitemap is fetched as the scenario's visitor, so it works behind basic auth or a login.

Go further than the options​

An option covers the values a trait expects to vary. Anything else means overriding the trait's own seam in the composing context:

class UiContext extends WebRawContext {

use ModalTrait;

public function modalGetSelectors(): array {
return ['.acme-dialog'];
}

}

A suite that registers WebContext overrides the same seam by extending it and redeclaring the method, without repeating any Behat attribute:

class AcmeWebContext extends WebContext {

public function modalGetSelectors(): array {
return ['.acme-dialog'];
}

}

An override replaces the resolution entirely, so the option, the steps default and any tag no longer reach that value. Web helpers and Drupal helpers list every seam a context can override.

A context override: a flaky staging server​

A context also overrides how its traits send their own requests. getHttpClientFactory() returns the factory behind the detached and bare clients, as How a step reaches each client describes.

Decorate the transport in the context, and every detached and bare client it builds retries a failed request:

use DrevOps\BehatSteps\DrupalContext;
use DrevOps\BehatSteps\Extension\Http\HttpClientFactoryInterface;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class FeatureContext extends DrupalContext {

public function getHttpClientFactory(): HttpClientFactoryInterface {
return parent::getHttpClientFactory()->withTransport(
static fn(HttpClientInterface $transport): HttpClientInterface
=> new RetryableHttpClient($transport),
);
}

}

That covers downloads, the hreflang check and the engine fetch for every trait in that context. RetryableHttpClient retries a request up to 3 times. Its default strategy retries a request that failed in transit only for an idempotent method such as GET, but it retries a 429 or 503 response whatever the method, so a POST a step sends can reach the server twice. For a context whose steps send writes, pass RetryableHttpClient a GenericRetryStrategy that limits 429 and 503 to GenericRetryStrategy::IDEMPOTENT_METHODS. The decorator receives the shared transport, so the site's settings still apply. Page requests aren't affected, because Mink builds the page client.

Declare options for a trait of your own​

A project writing its own configurable trait declares each option as a DrevOps\BehatSteps\Extension\Config\Option returned from a <prefix>ConfigSchema() method, where <prefix> is the prefix the trait's other members carry:

protected function acmeConfigSchema(): array {
return [
new Option(
'enabled',
default: TRUE,
description: 'Whether the Acme hook runs.',
),
new Option(
'wait_timeout',
default: 5000,
description: 'How long to wait, in milliseconds.',
tags: ['slow' => 30000],
),
];
}

The declared default carries the option's type, and the composing context reads it at that type with getOptionBool(), getOptionInt(), getOptionFloat(), getOptionString() or getOptionArray(). A default of NULL names no type, and getOption() reads it.

Behat Steps finds the method by its name, so the trait needs no registration. The group is the method's prefix in snake case: acmeConfigSchema() declares the acme group, and its options resolve through the chain Trait options states, from steps and the context's config to the tags.

The rules that govern composition​

Behat's attribute inheritance works in opposite directions for a trait that's composed and a class that's extended:

What the project doesResult
Registers 2 contexts composing the same traitFatal, RedundantStepException
A subclass re-composes a trait its parent already hasFatal, every step in it registers twice
A subclass overrides an inherited step, no attributeWorks, the subclass body runs, the step text is inherited
A subclass overrides an inherited step and repeats the attributeFatal, the pattern registers twice
A subclass overrides an inherited step with a different patternBoth patterns register, both run the subclass body
A composed trait's method is redeclared without the attributeThe step is silently removed
A composed trait is aliased as protected and the attribute redeclaredWorks, wraps the original
A subclass overrides an inherited hook, no attributeWorks, the subclass body runs
A subclass overrides an inherited hook and repeats the attributeRuns twice, silently

The last row is the least discoverable: HookRepository doesn't deduplicate, so a hook whose attribute is repeated on the override fires once for the parent declaration and once for the subclass one. Override an inherited hook without repeating its attribute.

Where to next​