Skip to main content

Extending the extension layer

Follow this page when a change has to reach every context at once. The extension layer is src/Extension. It holds BehatStepsExtension and the services that extension builds into Behat's container, and the context initializer injects those services into the contexts. You change them from a Behat extension of your own, in 3 ways:

  • Replace the HTTP client factory.
  • Swap a registry or an authenticator through its *.class parameter.
  • Replace option resolution.

This page also covers registering a browser adapter from a context.

The container sits outside the 5 surfaces semantic versioning covers, so check What semantic versioning covers before you build on these changes.

From a Behat extension of your own​

Make each container change on this page in the process() method of a Behat extension of your own. Behat calls the load() method of every extension before it calls any process(). Behat Steps defines its services in load(), so they exist when your process() runs, whatever order the profile lists the extensions in.

To add the extension:

  1. Write an extension class such as this:

    <?php

    declare(strict_types=1);

    namespace Acme\Behat;

    use Behat\Testwork\ServiceContainer\Extension as ExtensionInterface;
    use Behat\Testwork\ServiceContainer\ExtensionManager;
    use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
    use Symfony\Component\DependencyInjection\ContainerBuilder;

    final class AcmeExtension implements ExtensionInterface {

    public function getConfigKey(): string {
    return 'acme';
    }

    public function initialize(ExtensionManager $extension_manager): void {}

    public function configure(ArrayNodeDefinition $builder): void {}

    public function load(ContainerBuilder $container, array $config): void {}

    public function process(ContainerBuilder $container): void {
    // Replace a Behat Steps service here.
    }

    }
  2. In behat.php, pass AcmeExtension to a Behat\Config\Extension and register that on the profile beside BehatStepsExtension:

    $profile->withExtension(new Extension(\Acme\Behat\AcmeExtension::class));

The HTTP client factory and browser adapters​

The 2 extension points in this section are meant for package authors. To decorate the transport for 1 context, use a context override.

To give every context another HTTP client, such as a tracing client for debugging or a mock client for offline tests, replace the behat_steps.http_client_factory service. The container builds your class with the shared transport and the base URL. Your class implements HttpClientFactoryInterface, and extending the shipped HttpClientFactory keeps the rules that HTTP clients describes:

public function process(ContainerBuilder $container): void {
$container->getDefinition('behat_steps.http_client_factory')
->setClass(AcmeHttpClientFactory::class);
}

To give another browser driver a page client, register an adapter that implements HttpClientCapabilityInterface from your context, such as in its constructor:

$this->getBrowserCapabilityResolver()
->registerAdapter(AcmeDriverAdapter::class);

Each context keeps its own resolver, so register the adapter in every context that needs it. The resolver tries your adapter ahead of the shipped ones. Capabilities of the browser driver covers writing the adapter itself.

Swap a registry or an authenticator​

The container builds the 3 registries and 2 authenticators from the classes that *.class container parameters name. To put a class of your own in place of 1 of them, set its parameter from your extension. The shipped classes are final, so your class implements the interface. The registry interfaces sit in DrevOps\BehatSteps\Extension\Registry, and the authenticator interfaces in DrevOps\BehatSteps\Extension\Auth. The following table lists each parameter:

ParameterShipped classYour class implementsThe container passes
behat_steps.backend_registry.classBackendRegistryBackendRegistryInterfaceAn empty array, followed by 1 registerBackend() call for each backend the extension registers
behat_steps.scenario_tag_registry.classScenarioTagRegistryScenarioTagRegistryInterfaceNothing
behat_steps.user_registry.classUserRegistryUserRegistryInterfaceNothing
behat_steps.authenticator.classAuthenticatorAuthenticatorInterface, and optionally FastLogoutInterface and UrlLoginInterface@mink, @behat_steps.user_registry, @behat_steps.backend_registry, @behat_steps.basic_authenticator, %mink.parameters% and %behat_steps.parameters%
behat_steps.basic_authenticator.classBasicAuthenticatorBasicAuthenticatorInterface@mink and %mink.parameters%

Set the parameter in process():

public function process(ContainerBuilder $container): void {
$container->setParameter(
'behat_steps.user_registry.class',
AcmeUserRegistry::class,
);
}

Only the class changes. The container builds yours with the arguments and calls the table lists, in that order. Every service and context that receives 1 of these services takes it by its interface, so nothing else needs replacing.

FastLogoutInterface changes how AuthTrait logs out in the teardown after each scenario:

  • With it, AuthTrait calls fastLogout(). The shipped Authenticator::fastLogout() resets the browser session, so the site runs no logout hook. The method also logs out of the first backend in the scenario's order that provides authentication, when the scenario already reached that backend.
  • Without it, AuthTrait calls logout() only while a user is still logged in.

UrlLoginInterface lets AuthTrait::authLoginWithUrl() log a user in by visiting a URL. The When I log in through the one-time login link for the user :name step calls it. The shipped Authenticator::loginWithUrl() resets the session, visits the URL and confirms that a user is logged in. It then records the user as the current user and logs in on the backend, as login() does. With an authenticator that lacks the interface, authLoginWithUrl() throws a \RuntimeException that names the authenticator class.

Replace option resolution​

The behat_steps.config.resolver_factory service builds the resolver that every option read goes through. Trait options lists the layers a trait option resolves through. To resolve options your own way, import Symfony\Component\DependencyInjection\Definition in your extension and register a TraitOptionResolverFactoryInterface implementation under that ID:

public function process(ContainerBuilder $container): void {
$definition = new Definition(AcmeOptionResolverFactory::class);
$container->setDefinition('behat_steps.config.resolver_factory', $definition);
}

The factory's create() takes the context class, the context's config argument and the extension's steps section. It returns the TraitOptionResolverInterface that getOption(), getOptionBool() and the other typed readers go through. The shipped TraitOptionResolverFactory is final, so a factory of your own implements the interface. To keep the container's arguments, set the behat_steps.config.resolver_factory.class parameter instead of calling setDefinition(). The container then passes @behat_steps.config.schema_reader, @behat_steps.scenario_tag_registry and @behat_steps.config.tag_override_resolver.

Behat builds a context before the initializer injects the service, so the context's constructor still checks its config argument through the shipped factory. A config value the shipped resolution rejects fails there, whatever factory the service holds.

Where to next​

  • Extending the step vocabulary covers what a single context of yours overrides without a Behat extension.
  • Data flow shows when the services the extension builds run and how they reach a context.