Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
uid: BoDiWorkaroundsArticle
---

# Workarounds for the BoDi DI container

Reqnroll (and SpecFlow before it) use a custom dependency injection container named BoDi.
It does not implement the complete set of functionality of `IServiceCollection` or `IServiceProvider` and what's it does implement differs from them in some important ways.
However, the .NET Dependency Injection ecosystem fully expects the pair of `IServiceCollection` and `IServiceProvider`.
Many libraries expose extension methods for `IServiceCollection` which use that API to add themselves to a container.

In an attempt to close this gap, Screenplay offers two limited **adapters** which allow some usage of BoDi's `IObjectContainer` as a service collection and as a service provider.
These are accessible via the extension methods [`ToServiceCollection`] and [`ToServiceProvider`].
_Be warned that these are known to be imperfect_; they suffer from multiple limitations and will not work in all use-cases.
If you encounter showstopper problems using BoDi, _do not forget that Reqnroll provides support for swapping its DI container_ with another.
[This article shows you how to replace the BoDi container with Autofac], for example.

The remainder of this page lists some of the particular difficulties and workarounds which have solutions and/or further documentation.

[`ToServiceCollection`]: xref:CSF.Screenplay.ObjectContainerExtensions.ToServiceCollection(Reqnroll.BoDi.IObjectContainer)
[`ToServiceProvider`]: xref:CSF.Screenplay.ObjectContainerExtensions.ToServiceProvider(Reqnroll.BoDi.IObjectContainer)
[This article shows you how to replace the BoDi container with Autofac]: https://docs.reqnroll.net/latest/integrations/autofac.html

## BoDi cannot natively resolve `IEnumerable<T>` of registered services

Say you were to separately register (add) three different implementations of `IMyCustomService` to a normal `ServiceCollection` instance. If you subsequently resolve `IEnumerable<IMyCustomService>` from the resultant service provider then you would get a collection of custom service instances containing three items, one of each of the three implementations. This is built-in functionality for the standard Microsoft Service Collection.

_BoDi cannot do this without some help_. The Screenplay Reqnroll plugin includes that help in the form of an extension method for `IServiceCollection`: [`AddEnumerableAdapter<T>()`]. Use this method to enable enumerable resolution (as described above) for the specified generic type. You must use this method for each type T of `IEnumerable<T>` your tests need to be capable of resolving.
There are further technical details about this limitation and the workaround available in the remarks for [`EnumerableResolutionAdapter<T>`].

[`AddEnumerableAdapter<T>()`]: xref:CSF.Screenplay.ServiceCollectionExtensions.AddEnumerableAdapter``1(Microsoft.Extensions.DependencyInjection.IServiceCollection)
[`EnumerableResolutionAdapter<T>`]: xref:CSF.Screenplay.EnumerableResolutionAdapter`1

## BoDi does not natively support the Microsoft Options Pattern

This is only relevant if you intend to make use of the [Microsoft Options Pattern] in your tests. The Selenium plugin does (via [CSF.WebDriver.Extensions]) and if using it, you must include `.AddOptionsAdapters<WebDriverCreationOptionsCollection>()` in your DI setup.

This limitation is a specific case of BoDi's inability to resolve enumerables of registered services (above). There is an extension method specifically crafted for this: [`AddOptionsAdapters<TOptions>()`]. This method must be used once for each type of TOptions you wish to use. It puts adapters/workarounds in place, to allow resolution of (for example) `IOptions<TOptions>` in your tests.

[Microsoft Options Pattern]: https://learn.microsoft.com/en-us/dotnet/core/extensions/options
[CSF.WebDriver.Extensions]: https://github.com/csf-dev/CSF.Extensions.WebDriver
[`AddOptionsAdapters<TOptions>()`]: xref:CSF.Screenplay.ServiceCollectionExtensions.AddOptionsAdapters``1(Microsoft.Extensions.DependencyInjection.IServiceCollection)

## Liskov Substitution Principle violations

The adapters for using BoDi as an `IServiceCollection` and `IServiceProvider` are imperfect and limited.
In addition, they do not provide the complete range of functionality which is expected by the contract of these two interfaces.
This is [a violation of LSP].

There is extensive documentation about the differences in implementation details in the remarks for the classes [`ServiceCollectionAdapter`] and [`ServiceProviderAdapter`].

[a violation of LSP]: https://en.wikipedia.org/wiki/Liskov_substitution_principle
[`ServiceCollectionAdapter`]: xref:CSF.Screenplay.ServiceCollectionAdapter
[`ServiceProviderAdapter`]: xref:CSF.Screenplay.ServiceProviderAdapter
4 changes: 4 additions & 0 deletions CSF.Screenplay.Docs/docs/gettingStarted/reqnroll/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Further detail is provided below.
Reqnroll has a built-in mechanism which offers an opportunity to add services to its DI container; these are [the event hook attributes].
This is a viable technique because [Reqnroll's internal DI container "BoDi"] is unusual in that it permits retrospective addition of services.

> [!IMPORTANT]
> The Reqnroll BoDi DI container has a number of [quirks and limitations which require workarounds]. Read the linked documentation page for more info.

Use this technique by writing a small binding class like the following, replacing the comment with your DI registrations.
There's no need to use `.AddScreenplay()` here, _the integration does that already_.
For example, to add [the Screenplay/Selenium extension], add a line like `.AddSelenium()`.
Expand Down Expand Up @@ -51,6 +54,7 @@ public class DependenciesSetup(IObjectContainer reqnrollContainer)
[the event hook attributes]: https://docs.reqnroll.net/latest/automation/context-injection.html#advanced-options
[Reqnroll's internal DI container "BoDi"]: https://github.com/reqnroll/Reqnroll/tree/main/Reqnroll/BoDi
[the Screenplay/Selenium extension]: ../../extensions/selenium/index.md
[quirks and limitations which require workarounds]: BoDiWorkarounds.md

## Step 4: Writing step bindings

Expand Down
12 changes: 12 additions & 0 deletions CSF.Screenplay.Reqnroll/EnumerableResolutionAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,19 @@ namespace CSF.Screenplay
/// The limitation of this type (as a workaround) is that this type must be added to the container manually for each <see cref="IEnumerable{T}"/>
/// type which could be resolved from the container.
/// </para>
/// <para>
/// A developer might ask: <i>Why not detect attempts to resolve <c>IEnumerable&lt;T&gt;</c> in <see cref="ServiceProviderAdapter"/></i>, why do
/// we need this class? That is because whilst BoDi provides a Resolve All method, it does not use it internally for resolving generic enumerables.
/// That is, the internal resolution pipeline does not detect attempts to resolve <see cref="IEnumerable{T}"/> and direct them to the resolve-all
/// functionality. Instead they would fail with <see cref="ObjectContainerException"/> on the basis of "cannot resolve an interface without a
/// registration".
/// This would mean, if we attempted to isolate this functionality into the service provider adapter we would be able to resolve generic enumerables
/// of services OK, but we would not be able to resolve anything which depended upon generic enumerables of services.
/// Registering this adapter class (adding it to the container) for the appropriate enumerable type forces that 'redirect away to the resolve-all
/// functionality' behaviour into the resolution pipeline.
/// </para>
/// </remarks>
/// <typeparam name="T">The type of enumerable service which this adapter covers</typeparam>
public class EnumerableResolutionAdapter<T> : IEnumerable<T> where T : class
{
readonly IObjectContainer container;
Expand Down
28 changes: 22 additions & 6 deletions CSF.Screenplay.Reqnroll/ObjectContainerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ public static class ObjectContainerExtensions
/// </summary>
/// <remarks>
/// <para>
/// Note that this is an imperfect solution. The BoDi container shipped with Reqnroll/SpecFlow does not support all the functionality
/// which is expected from <see cref="IServiceCollection"/>. Many methods of the returned object will throw <see cref="NotSupportedException"/>
/// if attempts are made to use them (a known LSP violation). Additionally, not all service collection DI behaviour will operate in the same
/// manner when using this adapter. In short "your mileage may vary".
/// Note that this is an imperfect conversion and suffers from numerous
/// <see href="https://en.wikipedia.org/wiki/Liskov_substitution_principle">Liskov Substitution Principle violations</see>.
/// In other words, the BoDi container used by Reqnroll/SpecFlow does not support the API defined for <see cref="IServiceCollection"/>.
/// Using a BoDi container as a Service Collection requires workarounds for common scenarios and and comes with several
/// limitations which have no workaround.
/// </para>
/// <para>
/// However, for the most simple of usages, this enables the use of "Add to DI" logic which has been crafted for service collection,
/// in such a way that services may be added to the BoDi container without additional logic.
/// For more information, including suggestions to mitigate or avoid this, see
/// <xref href="BoDiWorkaroundsArticle?text=the+BoDi+workarounds+article"/> and the documentation for the class
/// <see cref="ServiceCollectionAdapter"/>.
/// </para>
/// </remarks>
/// <param name="bodiContainer">A Reqnroll/SpecFlow BoDi DI container.</param>
Expand All @@ -38,6 +40,20 @@ public static IServiceCollection ToServiceCollection(this IObjectContainer bodiC
/// Gets an adapter object which permits the use of the BoDi <see cref="IObjectContainer"/> as if it were an
/// <see cref="IServiceProvider"/>.
/// </summary>
/// <remarks>
/// <para>
/// Note that this is an imperfect conversion and suffers from a
/// <see href="https://en.wikipedia.org/wiki/Liskov_substitution_principle">Liskov Substitution Principle violation</see>.
/// In other words, the BoDi container used by Reqnroll/SpecFlow does not support the API defined for <see cref="IServiceProvider"/>.
/// Using a BoDi container as a Service Provider requires workarounds for common scenarios and and comes with some
/// limitations which have no workaround.
/// </para>
/// <para>
/// For more information, including suggestions to mitigate or avoid this, see
/// <xref href="BoDiWorkaroundsArticle?text=the+BoDi+workarounds+article"/> and the documentation for the class
/// <see cref="ServiceProviderAdapter"/>.
/// </para>
/// </remarks>
/// <param name="bodiContainer">A Reqnroll/SpecFlow BoDi DI container.</param>
/// <returns>An adapter object which implements the functionality of <see cref="IServiceProvider"/></returns>
public static IServiceProvider ToServiceProvider(this IObjectContainer bodiContainer)
Expand Down
40 changes: 28 additions & 12 deletions CSF.Screenplay.Reqnroll/ServiceCollectionAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,25 @@ namespace CSF.Screenplay
/// many methods, and the indexer for <see cref="ServiceDescriptor"/>, it will raise <see cref="NotSupportedException"/>.
/// The only functionality supported is <see cref="IsReadOnly"/> and <see cref="Add(ServiceDescriptor)"/>.
/// </para>
/// <para>
/// Obviously, this is a serious <see href="https://en.wikipedia.org/wiki/Liskov_substitution_principle">Liskov Substitution Principle violation</see>!
/// Some of these limitations <xref href="BoDiWorkaroundsArticle?text=may+be+worked-around+with+helper+functionality"/>, but some might have no workaround available.
/// This adapter class has been tested and found to be usable in simple testing scenarios though, which is why it is still provided.
/// It is a low-cost, low-effort workaround. If you find this insufficient then <i>consider replacing Reqnroll's DI container</i>.
/// <see href="https://docs.reqnroll.net/latest/integrations/autofac.html">An example of how to do this, using the Autofac container, is documented
/// on Reqnroll's website.</see>
/// </para>
/// <para>
/// An additional limitation of this adapter is that adding multiple implementations of the same base type (for example, many classes implementing the same interface)
/// does not allow a consumer to resolve <see cref="IEnumerable{T}"/> of that base type from the resulting service provider.
/// Whilst the Microsoft service collection/provider automatically interpret an attempt to resolve <c>IEnumerable&lt;T&gt;</c> as <i>resolve every implementation of T</i>,
/// BoDi does not. Use the extension method <see cref="ServiceCollectionExtensions.AddEnumerableAdapter{TService}(IServiceCollection)"/> to enable resolution of enumerable
/// collections of service types.
/// </para>
/// </remarks>
public class ServiceCollectionAdapter : IServiceCollection
{
const string NotSupportedMessage = "This method is not supported for ServiceCollectionAdapter; a BoDi object container is not a true service collection.";
const string NotSupportedMessage = "This is not supported for ServiceCollectionAdapter; a BoDi object container is not a true service collection.";

// Suppress SonarQube issue S3011, I'm using BindingFlags.NonPublic here but only from internally within the same class.
// The technique calling for reflection is to make an open generic method runtime-generic instead of compile-time. It's
Expand All @@ -44,32 +59,33 @@ static readonly MethodInfo
/// <exception cref="NotSupportedException">Always thrown</exception>
public ServiceDescriptor this[int index]
{
get => throw new NotSupportedException("This indexer is not supported for this adapter.");
set => throw new NotSupportedException("This indexer is not supported for this adapter.");
get => throw new NotSupportedException(NotSupportedMessage);
set => throw new NotSupportedException(NotSupportedMessage);
}

/// <summary>
/// Not supported, always returns zero.
/// Unsupported, this property always returns zero.
/// </summary>
public int Count => default;

/// <summary>
/// Partially supported, always returns <see langword="false" />
/// Partially supported, always returns <see langword="false" />.
/// </summary>
public bool IsReadOnly => false;

/// <summary>
/// Partially-supported, will add the specified service descriptor to the current object container.
/// Adds the specified service descriptor to the current object container.
/// </summary>
/// <remarks>
/// <para>
/// This method supports only transient or singleton services. If the <see cref="ServiceDescriptor.Lifetime"/> present on the item specified
/// is <see cref="ServiceLifetime.Scoped"/> then the service descriptor will not be added and will be silently ignored.
/// <i>This method supports only transient or singleton services</i>. If the <see cref="ServiceDescriptor.Lifetime"/> present on the item specified
/// is <see cref="ServiceLifetime.Scoped"/> <i>then the service descriptor will not be added and will be silently ignored</i>.
/// </para>
/// <para>
/// In reality, the Reqnroll BoDi object container only really supports singleton services at this level. So, any services added as
/// <see cref="ServiceLifetime.Transient"/> will actually become singletons here. Whilst the BoDi container does support scoped services, they
/// must be added directly to the scope instance and cannot be added in advance.
/// In reality, the Reqnroll BoDi object container only really supports <b>singleton</b> services at this level. So, any services added as
/// <see cref="ServiceLifetime.Transient"/> will actually become singletons here. Whilst the BoDi container does support scoped services in a
/// manner of speaking, they must be added directly to the container from that scope. Services which are scoped must be added (as singletons)
/// directly into the scope; they cannot be added in advance.
/// </para>
/// </remarks>
/// <param name="item">The service descriptor</param>
Expand Down Expand Up @@ -110,7 +126,7 @@ void RegisterFactory<T>(ServiceDescriptor item) where T : class
/// Not supported; always throws <see cref="NotSupportedException"/>.
/// </summary>
/// <exception cref="NotSupportedException">Always thrown</exception>
public void Clear() => throw new NotSupportedException();
public void Clear() => throw new NotSupportedException(NotSupportedMessage);

/// <summary>
/// Not supported; always throws <see cref="NotSupportedException"/>.
Expand Down
Loading
Loading