diff --git a/CHANGELOG.md b/CHANGELOG.md index 64776184c..7435f99b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,14 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## [Unreleased] -## Fixed +### Fixed - When the `TestContext` was disposed, it disposed of all services via the service provider. However, if there were ongoing renders happening, this could cause inconsistent state in the render tree, since the `TestRenderer` could try to access the service provider to instantiate components. This release changes the dispose phase such that the renderer gets disposed first, then the service provider. The disposal of any services that implement `IAsyncDisposable` is now also awaited. Fixed by [@egil](https://github.com/egil) and [@linkdotnet](https://github.com/linkdotnet). Reported by [@BenSchoen](https://github.com/BenSchoen) in https://github.com/bUnit-dev/bUnit/issues/1227. + +### Added + +- Support for custom service provider factories (`IServiceProviderFactory`). This enables the use of Autofac and other frameworks for dependency injection like on real-world ASP.NET Core / Blazor projects. By [@inf9144](https://github.com/inf9144). ## [1.23.9] - 2023-09-06 diff --git a/docs/samples/tests/xunit/CustomServiceProviderFactory.cs b/docs/samples/tests/xunit/CustomServiceProviderFactory.cs new file mode 100644 index 000000000..545e6a97d --- /dev/null +++ b/docs/samples/tests/xunit/CustomServiceProviderFactory.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Bunit.Docs.Samples; + +public sealed class CustomServiceProvider : IServiceProvider, IServiceScopeFactory, IServiceScope { + private readonly IServiceProvider _serviceProvider; + + public CustomServiceProvider(IServiceCollection serviceDescriptors) + => _serviceProvider = serviceDescriptors.BuildServiceProvider(); + + public object GetService(Type serviceType) { + if (serviceType == typeof(IServiceScope) || serviceType == typeof(IServiceScopeFactory)) + return this; + + if (serviceType == typeof(DummyService)) + return new DummyService(); + + return _serviceProvider.GetService(serviceType); + } + + void IDisposable.Dispose() { } + public IServiceScope CreateScope() => this; + IServiceProvider IServiceScope.ServiceProvider => this; + +} + +public sealed class CustomServiceProviderFactoryContainerBuilder { + private readonly IServiceCollection _serviceDescriptors; + + public CustomServiceProviderFactoryContainerBuilder(IServiceCollection serviceDescriptors) + => this._serviceDescriptors = serviceDescriptors; + + public IServiceProvider Build() + => new CustomServiceProvider(_serviceDescriptors); +} + +public sealed class CustomServiceProviderFactory : IServiceProviderFactory { + public CustomServiceProviderFactoryContainerBuilder CreateBuilder(IServiceCollection services) + => new CustomServiceProviderFactoryContainerBuilder(services); + + public IServiceProvider CreateServiceProvider(CustomServiceProviderFactoryContainerBuilder containerBuilder) + => containerBuilder.Build(); +} \ No newline at end of file diff --git a/docs/samples/tests/xunit/CustomServiceProviderFactoryUsage.cs b/docs/samples/tests/xunit/CustomServiceProviderFactoryUsage.cs new file mode 100644 index 000000000..df4e5800d --- /dev/null +++ b/docs/samples/tests/xunit/CustomServiceProviderFactoryUsage.cs @@ -0,0 +1,89 @@ +using Autofac; +using Autofac.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Text; +using Xunit; + +namespace Bunit.Docs.Samples; +public class CustomServiceProviderFactoryUsage : TestContext +{ + [Fact] + public void CustomServiceProviderViaFactoryReturns() + { + Services.UseServiceProviderFactory(new CustomServiceProviderFactory()); + + var dummyService = Services.GetService(); + + Assert.NotNull(dummyService); + } + + [Fact] + public void CustomServiceProviderViaDelegateReturns() + { + Services.UseServiceProviderFactory(x => new CustomServiceProvider(x)); + + var dummyService = Services.GetService(); + + Assert.NotNull(dummyService); + } + + [Fact] + public void AutofacServiceProviderViaFactoryReturns() + { + void ConfigureContainer(ContainerBuilder containerBuilder) + { + containerBuilder + .RegisterType() + .AsSelf(); + } + + Services.UseServiceProviderFactory(new AutofacServiceProviderFactory(ConfigureContainer)); + + //get a service which was installed in the Autofac ContainerBuilder + + var dummyService = Services.GetService(); + + Assert.NotNull(dummyService); + + //get a service which was installed in the bUnit ServiceCollection + + var testContextBase = Services.GetService(); + + Assert.NotNull(testContextBase); + Assert.Equal(this, testContextBase); + } + + [Fact] + public void AutofacServiceProviderViaDelegateReturns() + { + ILifetimeScope ConfigureContainer(IServiceCollection services) + { + var containerBuilder = new ContainerBuilder(); + + containerBuilder + .RegisterType() + .AsSelf(); + + containerBuilder.Populate(services); + + return containerBuilder.Build(); + } + + Services.UseServiceProviderFactory(x => new AutofacServiceProvider(ConfigureContainer(x))); + + //get a service which was installed in the Autofac ContainerBuilder + + var dummyService = Services.GetService(); + + Assert.NotNull(dummyService); + + //get a service which was installed in the bUnit ServiceCollection + + var testContextBase = Services.GetService(); + + Assert.NotNull(testContextBase); + Assert.Equal(this, testContextBase); + } +} diff --git a/docs/samples/tests/xunit/bunit.docs.xunit.samples.csproj b/docs/samples/tests/xunit/bunit.docs.xunit.samples.csproj index 03f4838e3..d77512f72 100644 --- a/docs/samples/tests/xunit/bunit.docs.xunit.samples.csproj +++ b/docs/samples/tests/xunit/bunit.docs.xunit.samples.csproj @@ -5,6 +5,8 @@ + + diff --git a/docs/site/docs/providing-input/inject-services-into-components.md b/docs/site/docs/providing-input/inject-services-into-components.md index b3becb15e..639142b55 100644 --- a/docs/site/docs/providing-input/inject-services-into-components.md +++ b/docs/site/docs/providing-input/inject-services-into-components.md @@ -51,6 +51,35 @@ Here is a test where the fallback service provider is used: In this example, the `DummyService` is provided by the fallback service provider, since it is not registered in the default service provider. +## Using a custom IServiceProvider implementation +A custom service provider factory can be registered with the built-in `TestServiceProvider`. It is used to create the underlying IServiceProvider. This enables a few interesting use cases, such as using an alternative IoC container (which should implement the `IServiceProvider` interface). This approach can be useful if the fallback service provider is not an option. For example, if you have dependencies in the fallback container, that rely on dependencies which are in the main container and vice versa. + +### Registering Autofac service provider factory +The example makes use of `AutofacServiceProviderFactory` and `AutofacServiceProvider` from the package `Autofac.Extensions.DependencyInjection` and shows how to use an Autofac dependency container with bUnit. + +Here is a test where the Autofac service provider factory is used: + +[!code-csharp[](../../../samples/tests/xunit/CustomServiceProviderFactoryUsage.cs?start=31&end=50)] + +Here is a test where the Autofac service provider is used via delegate: + +[!code-csharp[](../../../samples/tests/xunit/CustomServiceProviderFactoryUsage.cs?start=55&end=80)] + +### Registering a custom service provider factory +The examples contain dummy implementations of `IServiceProvider` and `IServiceProviderFactory`. Normally those implementations are supplied by the creator of your custom dependency injection solution (e.g. Autofac example above). This dummy implementations are not intended to use as is. + +This is an example of how to implement and use a dummy custom service provider factory. + +[!code-csharp[](../../../samples/tests/xunit/CustomServiceProviderFactory.cs?start=8&end=46)] + +Here is a test where the custom service provider factory is used: + +[!code-csharp[](../../../samples/tests/xunit/CustomServiceProviderFactoryUsage.cs?start=13&end=17)] + +Here is a test where the custom service provider is used via delegate: + +[!code-csharp[](../../../samples/tests/xunit/CustomServiceProviderFactoryUsage.cs?start=22&end=26)] + ## Further reading A closely related topic is mocking. To learn more about mocking in bUnit, go to the page. diff --git a/src/bunit.core/TestServiceProvider.cs b/src/bunit.core/TestServiceProvider.cs index 7af3a9148..9ba8d0699 100644 --- a/src/bunit.core/TestServiceProvider.cs +++ b/src/bunit.core/TestServiceProvider.cs @@ -15,6 +15,7 @@ public sealed class TestServiceProvider : IServiceProvider, IServiceCollection, private IServiceProvider? serviceProvider; private IServiceProvider? fallbackServiceProvider; private ServiceProviderOptions options = DefaultServiceProviderOptions; + private Func serviceProviderFactory; /// /// Gets a value indicating whether this has been initialized, and @@ -60,8 +61,68 @@ public TestServiceProvider(IServiceCollection? initialServiceCollection = null) private TestServiceProvider(IServiceCollection initialServiceCollection, bool initializeProvider) { serviceCollection = initialServiceCollection; + serviceProviderFactory = () => serviceCollection.BuildServiceProvider(Options); + if (initializeProvider) - serviceProvider = serviceCollection.BuildServiceProvider(); + InitializeProvider(); + } + + /// + /// Use a custom service provider factory for creating the underlying IServiceProvider. + /// + /// custom service provider factory + public void UseServiceProviderFactory(Func serviceProviderFactory) + { + if (serviceProviderFactory is null) + { + throw new ArgumentNullException(nameof(serviceProviderFactory)); + } + + this.serviceProviderFactory = () => serviceProviderFactory(serviceCollection); + } + + /// + /// Use a custom service provider factory for creating the underlying IServiceProvider. + /// + /// + /// Type of the container builder. + /// See + /// + /// custom service provider factory + /// builder configuration action + public void UseServiceProviderFactory(IServiceProviderFactory serviceProviderFactory, Action? configure = null) where TContainerBuilder : notnull + { + if (serviceProviderFactory is null) + { + throw new ArgumentNullException(nameof(serviceProviderFactory)); + } + + UseServiceProviderFactory( + serviceCollection => + { + var containerBuilder = serviceProviderFactory.CreateBuilder(serviceCollection); + configure?.Invoke(containerBuilder); + return serviceProviderFactory.CreateServiceProvider(containerBuilder); + }); + } + + /// + /// Creates the underlying service provider. Throws if it was already build. + /// Automatically called while getting a service if unitialized. + /// No longer will accept calls to the AddService's methods. + /// See + /// +#if !NETSTANDARD2_1 + [MemberNotNull(nameof(serviceProvider))] +#endif + private void InitializeProvider() + { + CheckInitializedAndThrow(); + + serviceCollection.AddSingleton(this); + rootServiceProvider = serviceProviderFactory.Invoke(); + serviceScope = rootServiceProvider.CreateScope(); + serviceProvider = serviceScope.ServiceProvider; } /// @@ -92,14 +153,9 @@ public object GetService(Type serviceType) private object? GetServiceInternal(Type serviceType) { if (serviceProvider is null) - { - serviceCollection.AddSingleton(this); - rootServiceProvider = serviceCollection.BuildServiceProvider(options); - serviceScope = rootServiceProvider.CreateScope(); - serviceProvider = serviceScope.ServiceProvider; - } + InitializeProvider(); - var result = serviceProvider.GetService(serviceType); + var result = serviceProvider!.GetService(serviceType); if (result is null && fallbackServiceProvider is not null) result = fallbackServiceProvider.GetService(serviceType); @@ -113,7 +169,6 @@ public object GetService(Type serviceType) /// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - /// public void Dispose() { diff --git a/tests/bunit.core.tests/TestServiceProviderTest.cs b/tests/bunit.core.tests/TestServiceProviderTest.cs index 047d3fc5d..92b5ba293 100644 --- a/tests/bunit.core.tests/TestServiceProviderTest.cs +++ b/tests/bunit.core.tests/TestServiceProviderTest.cs @@ -264,6 +264,39 @@ public void Test037() result.ShouldNotBeNull(); } + [Fact(DisplayName = "Test custom service provider factory")] + public void Test038() + { + using var sut = new TestServiceProvider(); + sut.AddSingleton(); + var dummyServiceProviderFactory = new DummyServiceProviderFactory(); + sut.UseServiceProviderFactory(dummyServiceProviderFactory); + + var result = sut.GetRequiredService(); + + result.ShouldNotBeNull(); + dummyServiceProviderFactory.TestContainerBuilder.ShouldNotBeNull(); + dummyServiceProviderFactory.TestContainerBuilder.TestServiceProvider.ShouldNotBeNull(); + dummyServiceProviderFactory.TestContainerBuilder.TestServiceProvider.ResolvedTestServices.ShouldContain(result); + dummyServiceProviderFactory.TestContainerBuilder.TestServiceProvider.ResolvedTestServices.Count.ShouldBe(1); + } + + [Fact(DisplayName = "Test custom service provider factory as delegate")] + public void Test039() + { + using var sut = new TestServiceProvider(); + sut.AddSingleton(); + DummyServiceProvider dummyServiceProvider = null; + sut.UseServiceProviderFactory(x => dummyServiceProvider = new DummyServiceProvider(x)); + + var result = sut.GetRequiredService(); + + result.ShouldNotBeNull(); + dummyServiceProvider.ShouldNotBeNull(); + dummyServiceProvider.ResolvedTestServices.ShouldContain(result); + dummyServiceProvider.ResolvedTestServices.Count.ShouldBe(1); + } + private sealed class DummyService { } private sealed class AnotherDummyService { } @@ -301,4 +334,51 @@ public void Dispose() IsDisposed = true; } } + + private sealed class DummyServiceProvider : IServiceProvider, IServiceScopeFactory, IServiceScope + { + private readonly IServiceCollection serviceDescriptors; + + public readonly List ResolvedTestServices = new(); + + public DummyServiceProvider(IServiceCollection serviceDescriptors) + => this.serviceDescriptors = serviceDescriptors; + + public object? GetService(Type serviceType) + { + if (serviceType == typeof(IServiceScope) || serviceType == typeof(IServiceScopeFactory)) + return this; + + var result = Activator.CreateInstance(serviceDescriptors.Single(x => x.ServiceType == serviceType).ImplementationType); + ResolvedTestServices.Add(result); + return result; + } + + void IDisposable.Dispose() { } + public IServiceScope CreateScope() => this; + IServiceProvider IServiceScope.ServiceProvider => this; + + } + + private sealed class DummyServiceProviderFactoryContainerBuilder + { + private readonly IServiceCollection serviceDescriptors; + + public DummyServiceProvider? TestServiceProvider { get; private set; } + + public DummyServiceProviderFactoryContainerBuilder(IServiceCollection serviceDescriptors) => this.serviceDescriptors = serviceDescriptors; + + public IServiceProvider Build() => TestServiceProvider = new DummyServiceProvider(serviceDescriptors); + } + + private sealed class DummyServiceProviderFactory : IServiceProviderFactory + { + public DummyServiceProviderFactoryContainerBuilder TestContainerBuilder { get; private set; } + + public DummyServiceProviderFactoryContainerBuilder CreateBuilder(IServiceCollection services) + => TestContainerBuilder = new DummyServiceProviderFactoryContainerBuilder(services); + + public IServiceProvider CreateServiceProvider(DummyServiceProviderFactoryContainerBuilder containerBuilder) + => containerBuilder.Build(); + } }