-
Notifications
You must be signed in to change notification settings - Fork 43
DI Containers
Page version: 3.x / 2.x
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.
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.
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.
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.
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.
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.
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.
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)
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;
.
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).
The default DI container (called also DefaultDependencyContainer
) is used by LightBDD if no other DI is configured.
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.
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.
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.
- 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>()
oropt=>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>
orT[]
dependencies, they have to be explicitly registered using the factory method.
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 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
.
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.
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, wheretakeOwnership
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 andlimit
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.
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).
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
- What Is New
- Quick Start
- Tests Structure and Conventions
- Scenario Steps Definition
- Composite Steps Definition
- SetUp and TearDown
- DI Containers
- Step parameters
- Test Progress Notification
- Generating Reports
- Execution Time Measurement
- Feature Fixture
- Step Comments
- Step Attachments
- Extending Test Behavior
- Test Utilities
- Test Framework Integrations
- LightBDD Configuration
- Visual Studio Extensions
- How to