Skip to content
Wojtek edited this page Feb 20, 2021 · 24 revisions

Page version: 3.x / 2.x

DI containers support

LightBDD offers support for DI containers, simplifying dependencies management between scenario and step contexts. The main scenario for DI is to resolve context instances for the contextual scenarios and contextual composite steps, however, it is also possible to use DI in standard scenarios.

By default, LightBDD uses a custom implementation of DI container (that does not require any package dependencies), however, it may offer less features than well-known DI implementations.

How does DI work in LightBDD

LightBDD uses the hierarchical (scoped) container approach.

Since LightBDD 3.3.0, the following Lifetime Scopes are used by the LightBDD engine:

  • LifetimeScope.Global - container root scope covering all tests,
  • LifetimeScope.Scenario - scenario scope covering single scenario,
  • LifetimeScope.Local - local scope covering given composite step, where it is possible to have multiple nested local scopes if nested composite steps are used.

Root container

The root container is configured and instantiated during LightBDD configuration.
Its lifetime covers the execution of all scenarios.
It is disposed after LightBDD finishes execution of all scenarios, after report generation.

The root container holds all singleton instances of the dependencies that should be shared within all tests.

The implementations of DI containers should associate LifetimeScope.Global with the root container.

Container scopes

The container scopes are hierarchical and LightBDD leverages them to control the lifetime of the dependencies resolved for scenarios or composite steps.

During DI configuration it is possible to specify the instantiation strategy and instance shareability within given scopes and between nested scopes, where the possible configuration options depend on the chosen DI implementation.

Scenarios

For every scenario, a new container scope is instantiated, which is then used to resolve any dependencies needed by the scenario. The lifetime of that scope matches the scenario execution period, where any new instances resolved within the scope have a chance to be disposed after scenario finish (depending on the configuration and container implementation).

Since LightBDD 3.3.0, the engine initiates the scenario scope with LifetimeScope.Scenario parameter, allowing DI implementations to handle the scenario-specific dependency registrations.

Composite steps

Similarly to scenarios, a new container scope is also created for every composite step - no matter if it was created with custom context (WithContext<T>()) or not.

Since LightBDD 3.3.0, the engine initiates composite step scopes with LifetimeScope.Local parameter.

Please note that for nested composite steps, every composite step is executed within its own container scope.

Configuring DI container

The DI container can be configured during LightBDD configuration with the following code:

protected override void OnConfigure(LightBddConfiguration configuration)
{
    configuration.DependencyContainerConfiguration()
        .UseXXX(); //choose your container and configure it
}

...where UseXXX() method represents chosen DI implementation.

Please note that the DI configuration is an optional step. If no explicit configuration is made, the default DI container will be used by LightBDD.

Please check DI implementations section to learn how to integrate with specific DI implementations.

DI usage

Using DI containers in contextual scenarios

The contextual scenarios and composite steps are the places where DI can be used in the most seamless way.

The parameter-less versions of WithContext<T>() methods use DI to instantiate the T context and are capable of instantiating context having parameterized constructors.

Let's look at the following code:

public class MyContext
{
  public MyContext(MyDependency1 dependency1, MyDependency2 dependency2) { /* ... */ }
}

public class My_feature: FeatureFixture
{
  public void My_scenario()
  {
    Runner
      .WithContext<MyContext>() //use DI
      .RunScenario( /* ... */ );
  }

  private CompositeStep Given_customer_is_logged_in()
  {
    return CompositeStep
      .DefineNew()
      .WithContext<MyContext>() //use DI
      .AddSteps( /* ... */ )
      .Build();
  }
}

In both cases, the .WithContext<MyContext>() will use DI to resolve MyContext and it's dependencies.

Since LightBDD 3.3.0, it is possible to configure the context after its instantiation and provide the scenario or composite step-specific data.

For contexts requiring customization on the constructor level, it is possible to write:

Runner
  .WithContext(resolver => new MyContext(_dependency1, resolver.Resolve<MyDependency2>()))

or

CompositeStep
  .DefineNew()
  .WithContext(resolver => new MyContext(_dependency1, resolver.Resolve<MyDependency2>()))

For contexts requiring additional setup after instantiation, it is possible to write:

Runner
  .WithContext<MyContext>(context => context.UserId = _scenarioUserId)

or

CompositeStep
  .DefineNew()
  .WithContext<MyContext>(context => context.UserId = _scenarioUserId)

Finally, it is possible to mix both methods:

Runner
  .WithContext(
    resolver => new MyContext(_dependency1, resolver.Resolve<MyDependency2>()),
    context => context.UserId = _scenarioUserId)

or

CompositeStep
  .DefineNew()
  .WithContext(
    resolver => new MyContext(_dependency1, resolver.Resolve<MyDependency2>()),
    context => context.UserId = _scenarioUserId)

Programmatic access to DI container

In the situations where there is no explicit scenario/step context, it is still possible to use DI to resolve some dependencies programmatically within any step method:

public class My_feature: FeatureFixture
{
  private ApiClient _apiClient;

  private void Given_an_api_client()
  {
    _apiClient = StepExecution.Current.GetScenarioDependencyResolver().Resolve<ApiClient>();
  }
}

Please note that this method will fail with an exception if used outside of the step method.

Please note also that this method will always return the scenario-scope resolver object, even if called from the composite step.

To access Resolve<>() method, please add using LightBDD.Core.Dependencies;.

Access to DI container from decorators

The DI dependency resolver is accessible also from the scenario and step decorators:

class MyScenarioDecorator : IScenarioDecorator
{
    public async Task ExecuteAsync(IScenario scenario, Func<Task> scenarioInvocation)
    {
        var dependency = scenario.DependencyResolver.Resolve<MyDependency>();
        /* ... */
    }
}

class MyStepDecorator : IStepDecorator
{
    public async Task ExecuteAsync(IStep step, Func<Task> stepInvocation)
    {
        var dependency = step.DependencyResolver.Resolve<MyDependency>();
        /* ... */
    }
}

Please note that unlike StepExecution.Current.GetScenarioDependencyResolver(), the DependencyResolver property of step decorator will return a current scope resolver (so will work properly for composite steps).

DI implementations

Default DI container

The default DI container (called also DefaultDependencyContainer) is used by LightBDD if no other DI is configured.

Default behavior

If not configured, it will automatically resolve types (classes and structures), where:

  • all types will get resolved as transient, i.e. every time a new instance will be provided,
  • the public constructor will be used to instantiate the type, with all dependencies automatically resolved,
  • the types implementing IDisposable interface will be disposed on container scope disposal.

Configuration options

The container offers various configuration options allowing to:

  • specify instance scope,
  • register the given type as self, or one or multiple types and interfaces it extends/implements,
  • instantiate a type using its default public constructor, with inferred dependencies,
  • instantiate a type using custom factory method, with access to the resolver allowing dependency resolution,
  • register singleton instances and customize their disposal behavior.
class ConfiguredLightBddScopeAttribute : LightBddScopeAttribute
{
    protected override void OnConfigure(LightBddConfiguration configuration)
    {
        configuration.DependencyContainerConfiguration()
            .UseDefault(ConfigureDI);
    }

    private void ConfigureDI(IDefaultContainerConfigurator cfg)
    {
        cfg.RegisterType<UserRepository>(InstanceScope.Single, //singleton
            opt => opt.As<IUserRepository>()); //register as interface only

        cfg.RegisterType<ApiClient>(InstanceScope.Scenario);
        // ^-- register as self, one per scenario, shared with nested composite steps

        cfg.RegisterType<FeatureContext>(InstanceScope.Local); //register as self, one per any scope

        cfg.RegisterType(InstanceScope.Transient, // register transient
            r => new ModelBuilder<User>("users", r.Resolve<ApiClient>()), // use custom factory method
            opt => opt.As<IModelBuiler<User>>()); // register as interface

        cfg.RegisterInstance(new Host(), //singleton
            opt => opt.ExternallyOwned() //don't dispose
                .As<Host>().As<IHost>()); //register as both types
    }
}

The InstanceScope offers the following options:

  • Single: The same instance is returned for requests within the root and nested scopes.
  • Scenario: The instance is shared within the given scenario scope and across all nested scopes, but instantiated independently between scenarios.
  • Local: The same instance is returned for requests within the given scope, however not shared with nested scopes. Each scope will receive one instance upon request.
  • Transient: The new instance is returned upon every request.

Please note that types registered with InstanceScope.Scenario will not be resolved from the root container. They only become available from the container scope with LifetimeScope.Scenario or nested ones.

The opt parameter allows configuring RegistrationOptions, where:

  • .ExternallyOwned() configures DI to not dispose the instance,
  • .As<T>() allows registering the type as a base class or interface. It can be used multiple times. If not specified, the type is registered as self.

Resolving not registered types

Since LightBDD 3.3.0 it is possible to configure how the container should treat not explicitly registered types:

private void ConfigureDI(IDefaultContainerConfigurator cfg)
{
    // all types not explicitly registered will be resolved as transient instances
    cfg.ConfigureFallbackBehavior(FallbackResolveBehavior.ResolveTransient);
    
    // resolution of any not registered type will fail with exception
    cfg.ConfigureFallbackBehavior(FallbackResolveBehavior.ThrowException);
}

The default behavior is FallbackResolveBehavior.ResolveTransient, which is similar to previous versions of LightBDD.
The FallbackResolveBehavior.ThrowException can help however in less obvious configurations involving multiple instance scopes, as it will enforce all the dependent types being registered within the reachable scopes.

Container limitations

  • Only concrete types (classes and structs) can be automatically instantiated. To resolve interfaces, abstract classes please register the concrete type and use opt=>opt.As<TInterface>() or opt=>opt.As<TAbstract>() configuration to make it resolvable via interface/abstract type
  • Only types with 1 public constructor can be automatically instantiated. To resolve types with multiple/none public constructors, please register them using the factory method approach: cfg.RegisterType(scope, resolver => new MySpecialType("param1", resolver.Resolve<Dependency>()))
  • The DI does not support multiple registrations of the same type. If multiple registrations are made for the same type, the last registration will be effective.
  • The DI does not support collection resolution for the given type. To resolve IEnumerable<T> or T[] dependencies, they have to be explicitly registered using the factory method.

LightBDD.Autofac

For the scenarios where more advanced DI is needed, it is possible to install the LightBDD.Autofac package and use Autofac DI.

To install the Autofac DI, one of the following code is needed:

protected override void OnConfigure(LightBddConfiguration configuration)
{
  var builder = new ContainerBuilder();
  builder.Register...();
  var container = builder.Build()

  configuration.DependencyContainerConfiguration()
    .UseAutofac(container);
}

// OR

protected override void OnConfigure(LightBddConfiguration configuration)
{
  configuration.DependencyContainerConfiguration()
    .UseAutofac(ConfigureContainer);
}

ContainerBuilder ConfigureContainer()
{
  var builder = new ContainerBuilder();
  builder.Register...();
  return builder;
}

Since LightBDD 3.3.0, it is possible to register types that should be shared within the scenario and all nested scopes (composite steps), by using InstancePerMatchingLifetimeScope(LifetimeScope.Scenario) method.

builder.RegisterType<ApiClient>().InstancePerMatchingLifetimeScope(LifetimeScope.Scenario);

Unlike InstancePerMatchingLifetimeScope(), the InstancePerLifetimeScope() method makes the instance shared only within a given scope, not nested ones, thus before version 3.3.0, it was not possible to register instances to be shared within the given scenario and its composite steps but different between scenarios.

LightBDD.Extensions.DependencyInjection

LightBDD supports also integration with any DI container that is compatible with Microsoft.Extensions.DependencyInjection.Abstractions and implements IServiceProvider interface.

To do this, the LightBDD.Extensions.DependencyInjection package has to be installed.

The following code shows how to integrate with Microsoft DI from Microsoft.Extensions.DependencyInjection package:

protected override void OnConfigure(LightBddConfiguration configuration)
{
  var serviceCollection = new ServiceCollection();
  serviceCollection.Add...();

  configuration.DependencyContainerConfiguration()
    .UseContainer(serviceCollection.BuildServiceProvider());
}

It is worth noting that similarly to Autofac's InstancePerLifetimeScope(), the ServiceCollection.AddScoped<T>() makes the given instance shareable within the specified scope, but not the nested ones.

In order to make the experience more suitable to LightBDD scenario nature, since version 3.3.0 the scenario and composite steps share the same container scopes, so that types registered with AddScoped() are shared within the scenario and its composite steps.

Please note that it is a potentially breaking change, however, it is possible to control this behavior with new configuration options:

internal class ConfiguredLightBddScopeAttribute : LightBddScopeAttribute
{
    protected override void OnConfigure(LightBddConfiguration configuration)
    {
        configuration.DependencyContainerConfiguration()
            .UseContainer(
                ConfigureDI(),
                options => options.EnableScopeNestingWithinScenarios(false));
                // ^-- configures to share scope between scenario and composite steps (default behavior)
    }

    private ServiceProvider ConfigureDI()
    {
        var services = new ServiceCollection();

        services.AddScoped<ApiClient>(); //registered as scoped instance

        return services.BuildServiceProvider();
    }
}

The EnableScopeNestingWithinScenarios() works as follows:

  • true: composite steps executed within the scenario will run in the nested scope. All instances resolved in that scope will get disposed upon composite step finish, but composite steps will receive different instances of types registered as scoped than the scenario.
  • false: composite steps executed within the scenario will run in the scenario scope. The lifetime of all instances resolved from the composite steps will match scenario lifetime, and composite steps, as well as parent scenario, will receive the same instances of types registered as scoped.

The default value is false.

Support classes

ResourcePool and ResourceHandle

There are scenarios where some heavy dependencies have to be used (such as selenium drivers). Their characteristic is that they can be used by 1 test at a time and they are very costly to create.

The ResourcePool<T> class fits well in this scenario. It allows creating a pool of such objects that will be lent for the scenario execution (via ResourceHandle<T>) and given back after the test is done.

ResourcePool

The ResourcePool<T> can be initialized in 2 ways:

  • ResourcePool(TResource[] resources, bool takeOwnership = true) creates the pool with a pre-set array of resources, where takeOwnership flag specifies if the pool is responsible for disposing the resources,
  • ResourcePool(Func<TResource> resourceFactory, int limit = int.MaxValue) creates the pool with resource factory function being able to instantiate new resource and limit parameter specifying a maximum number of resources that can be managed by the pool. In this mode, the pool will start with no resources, where the resources will be instantiated on request, up to the specified limit.

ResourceHandle

To use the ResourcePool<T>, a handle has to be created by one of the following method:

  • ResourceHandle<TResource> handle = pool.CreateHandle();
  • ResourceHandle<TResource> handle = new ResourceHandle(pool);

The ResourceHandle<T> allows obtaining the resource from the pool via Task<TResource> ObtainAsync() method, where the resource is being obtained during the first call to ObtainAsync() while subsequent calls return the same instance of the resource.

The ObtainAsync() method works in the following way:

  • returns the resource, if handle already have it obtained via the previous call to ObtainAsync(), otherwise
  • if resource pool has free resources, it obtains the first one, taking it off the pool, otherwise
  • if resource pool has a method to create a resource and the limit has not been reached yet, it obtains it by requesting a new resource to be created, otherwise
  • it will await until any resource gets returned to the pool or until timeout (if specified).

Note: There is a Task<TResource> ObtainAsync(CancellationToken token) overload, allowing to specify the cancellation token.

The obtained resource is returned to the pool upon handle disposal (done manually or by DI).

ResourcePool example usage

The sample registration code is here:

public class ConfiguredLightBddScopeAttribute : LightBddScopeAttribute
{
    protected override void OnConfigure(LightBddConfiguration configuration)
    {
        configuration.DependencyContainerConfiguration()
            .UseDefaultContainer(ConfigureContainer);
    }

    private void ConfigureContainer(ContainerConfigurator config)
    {
        config.RegisterInstance(
            new ResourcePool<ChromeDriver>(CreateDriver),
            new RegistrationOptions());
    }

    private ChromeDriver CreateDriver()
    {
        var driver = new ChromeDriver();
        driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromMilliseconds(0);
        return driver;
    }
}

... and the sample usage is here:

// the context is created for each scenario

public class HtmlReportContext
{
    private readonly ResourceHandle<ChromeDriver> _driverHandle;

    // Injected by DI container
    public HtmlReportContext(ResourceHandle<ChromeDriver> driverHandle)
    {
        _driverHandle = driverHandle;
    }

    public async Task Given_page_is_open()
    {
        // this obtains the resource from the pool
        Driver = await _driverHandle.ObtainAsync();

        Driver.Navigate().GoToUrl( /* ... */);
    }
}

Continue reading: Formatting Parameterized Steps

Clone this wiki locally