Use Xunit.DependencyInjection
Transformation test project
Intro
This article has been delayed for a long time , It has been introduced before Xunit.DependencyInjection
This project , This project was written by a master Xunit
Based on Microsoft GenericHost and An extension library for dependency injection implementation , It can make it easier for you to implement dependency injection in test projects , And I think another good point is that it can better control the operation process , For example, many initialization operations are done before starting the test , Better process control .
Recently, most of our company's testing projects are based on Xunit.DependencyInjection
Transformed , The effect is very good .
Recently, I started my test project manually from the original one Web Host It's based on Xunit.DepdencyInjection
To use , At the same time, it is also preparing for the update of integration test of a project of our company , It's delicious to use ~
I think Xunit.DependencyInjection
Solved my two big pain points , One is that dependency injection code doesn't write well , One is a simpler process control process , Here is a general introduction to
XUnit.DependencyInjection
Workflow
Xunit.DepdencyInjection
The main process is DependencyInjectionTestFramework in , See https://github.com/pengweiqhca/Xunit.DependencyInjection/blob/7.0/Xunit.DependencyInjection/DependencyInjectionTestFramework.cs
First of all, I will try to find the... In the project Startup
, This Startup
Very similar to asp.net core Medium Startup
, Almost exactly , It's just a little different , Startup
Dependency injection is not supported , Can not be like asp.net core Put in a like that IConfiguration
Object to get the configuration , besides , and asp.net core Of Startup
Have the same experience , If you can't find this Startup
There are no services or special configurations that need dependency injection , Use it directly Xunit
The original XunitTestFrameworkExecutor
, If you find it Startup
From Startup
Configure in the agreed method Host
, Registration service and initialization configuration process , Finally using DependencyInjectionTestFrameworkExecutor
Carry out our test case.
The source code parsing
The source code uses C#8 Some of the new grammar of , The code is very simple , The following code uses nullable reference types :
DependencyInjectionTestFramework
Source code
public sealed class DependencyInjectionTestFramework : XunitTestFramework
{
public DependencyInjectionTestFramework(IMessageSink messageSink) : base(messageSink) { }
protected override ITestFrameworkExecutor CreateExecutor(AssemblyName assemblyName)
{
IHost? host = null;
try
{
// obtain Startup example
var startup = StartupLoader.CreateStartup(StartupLoader.GetStartupType(assemblyName));
if (startup == null) return new XunitTestFrameworkExecutor(assemblyName, SourceInformationProvider, DiagnosticMessageSink);
// establish HostBuilder
var hostBuilder = StartupLoader.CreateHostBuilder(startup, assemblyName) ??
new HostBuilder().ConfigureHostConfiguration(builder =>
builder.AddInMemoryCollection(new Dictionary<string, string> { { HostDefaults.ApplicationKey, assemblyName.Name } }));
// call Startup Medium ConfigureHost Method configuration Host
StartupLoader.ConfigureHost(hostBuilder, startup);
// call Startup Medium ConfigureServices Method register service
StartupLoader.ConfigureServices(hostBuilder, startup);
// Register default service , structure Host
host = hostBuilder.ConfigureServices(services => services
.AddSingleton(DiagnosticMessageSink)
.TryAddSingleton<ITestOutputHelperAccessor, TestOutputHelperAccessor>())
.Build();
// call Startup Medium Configure Method to initialize
StartupLoader.Configure(host.Services, startup);
// return testcase executor, Ready to start running test cases
return new DependencyInjectionTestFrameworkExecutor(host, null,
assemblyName, SourceInformationProvider, DiagnosticMessageSink);
}
catch (Exception e)
{
return new DependencyInjectionTestFrameworkExecutor(host, e,
assemblyName, SourceInformationProvider, DiagnosticMessageSink);
}
}
}
StarpupLoader
Source code
public static Type? GetStartupType(AssemblyName assemblyName)
{
var assembly = Assembly.Load(assemblyName);
var attr = assembly.GetCustomAttribute<StartupTypeAttribute>();
if (attr == null) return assembly.GetType($"{assemblyName.Name}.Startup");
if (attr.AssemblyName != null) assembly = Assembly.Load(attr.AssemblyName);
return assembly.GetType(attr.TypeName) ?? throw new InvalidOperationException($"Can't load type {attr.TypeName} in '{assembly.FullName}'");
}
public static object? CreateStartup(Type? startupType)
{
if (startupType == null) return null;
var ctors = startupType.GetConstructors();
if (ctors.Length != 1 || ctors[0].GetParameters().Length != 0)
throw new InvalidOperationException($"'{startupType.FullName}' must have a single public constructor and the constructor without parameters.");
return Activator.CreateInstance(startupType);
}
public static IHostBuilder? CreateHostBuilder(object startup, AssemblyName assemblyName)
{
var method = FindMethod(startup.GetType(), nameof(CreateHostBuilder), typeof(IHostBuilder));
if (method == null) return null;
var parameters = method.GetParameters();
if (parameters.Length == 0)
return (IHostBuilder)method.Invoke(startup, Array.Empty<object>());
if (parameters.Length > 1 || parameters[0].ParameterType != typeof(AssemblyName))
throw new InvalidOperationException($"The '{method.Name}' method of startup type '{startup.GetType().FullName}' must without parameters or have the single 'AssemblyName' parameter.");
return (IHostBuilder)method.Invoke(startup, new object[] { assemblyName });
}
public static void ConfigureHost(IHostBuilder builder, object startup)
{
var method = FindMethod(startup.GetType(), nameof(ConfigureHost));
if (method == null) return;
var parameters = method.GetParameters();
if (parameters.Length != 1 || parameters[0].ParameterType != typeof(IHostBuilder))
throw new InvalidOperationException($"The '{method.Name}' method of startup type '{startup.GetType().FullName}' must have the single 'IHostBuilder' parameter.");
method.Invoke(startup, new object[] { builder });
}
public static void ConfigureServices(IHostBuilder builder, object startup)
{
var method = FindMethod(startup.GetType(), nameof(ConfigureServices));
if (method == null) return;
var parameters = method.GetParameters();
builder.ConfigureServices(parameters.Length switch
{
1 when parameters[0].ParameterType == typeof(IServiceCollection) =>
(context, services) => method.Invoke(startup, new object[] { services }),
2 when parameters[0].ParameterType == typeof(IServiceCollection) &&
parameters[1].ParameterType == typeof(HostBuilderContext) =>
(context, services) => method.Invoke(startup, new object[] { services, context }),
2 when parameters[1].ParameterType == typeof(IServiceCollection) &&
parameters[0].ParameterType == typeof(HostBuilderContext) =>
(context, services) => method.Invoke(startup, new object[] { context, services }),
_ => throw new InvalidOperationException($"The '{method.Name}' method in the type '{startup.GetType().FullName}' must have a 'IServiceCollection' parameter and optional 'HostBuilderContext' parameter.")
});
}
public static void Configure(IServiceProvider provider, object startup)
{
var method = FindMethod(startup.GetType(), nameof(Configure));
method?.Invoke(startup, method.GetParameters().Select(p => provider.GetService(p.ParameterType)).ToArray());
}
Actual case
unit testing
Let's look at a modification of a unit test in our project , Before the transformation, it was like this :
This test project uses an older version of AutoMapper
, Each has to use AutoMapper
You need to call the registration in the test case AutoMapper
mapping Relationship method to register mapping Relationship , because Register
Method called directly in the Mapper.Initialize
Methods registration mapping Relationship , Multiple calls will throw an exception , So every test case method uses AutoMapper
All of them have this disgusting logic
The first revision , I am here Register
Method to make a simple transformation , hold try...catch
Removed :
But it's still uncomfortable , Each uses AutoMapper
We still need to call Register
Method
Use Xunit.DepdencyInjection
After that, you can just Startup
Medium Configure
Register in the method , Just call it once
We'll put AutoMapper
The upgrade , Use the dependency injection pattern to use AutoMapper
, Use after modification
Inject the required service directly into the class of the test case IMapper
that will do
Integration testing
Integration testing is similar , Integration testing I use my own project as an example
My integration test project was originally developed with xunit
Inside CollectionFixture
combination WebHost
To achieve ( from 2.2 Updated ,), stay .net core 3.1 It can be configured directly WebHostedService
That's all right. , and Xunit.DependencyInjection
Is based on Microsoft GenericHost
So , It will also be easier to do integration .
stay Startup
in adopt ConfigureHost
Method configuration IHostBuilder
Extension method of ConfigureWebHost
, Registration test required services , Inject services into the constructor of the test sample class
Integration test modification changes can refer to : https://github.com/OpenReservation/ReservationServer/commit/d30e35116da0b8d4bf3e65f0a1dcabcad8fecae0
Startup Supported methods
CreateHostBuilder
public class Startup
{
public IHostBuilder CreateHostBuilder([AssemblyName assemblyName]) { }
}
Use this method to customize IHostBuilder
You can use this method when , This method may not be used very often , Can pass ConfigureHost
Method to configure Host
The default is direct new HostBuilder()
, Want to build aspnet.core It is configured by default HostBuilder
, have access to Host.CreateDefaultBuilder()
To create IHostBuilder
ConfigureHost
To configureHost
public class Startup
{
public void ConfigureHost(IHostBuilder hostBuilder) { }
}
adopt ConfigureHost
To configure the Host
, You can configure IConfiguration
, You can also configure the services to be registered
Configuration can be done through IHostBuilder
Extension method of ConfigureAppConfiguration
To update the configuration
ConfigureServices
public class Startup
{
public void ConfigureServices(IServiceCollection services[, HostBuilderContext context]) { }
}
If you don't need to read IConfiguration
It can be used directly ConfigurationServices(IServiceCollection services)
Method
If you need to read IConfiguration
, Can pass ConfigureServices(IServiceCollection services, HostBuilderContext context)
Methods by HostBuilderContext.Configuration
To access the configuration object IConfiguration
Configure
public class Startup
{
public void Configure([IServiceProvider applicationServices]) { }
}
Configure
Methods can have no parameters , It also supports all injected Services , and asp.net core Inside Configure
The method is similar to , You can usually do some initialization configuration in this method
More
If you're using Xunit
When you encounter the above problems , I recommend you try Xunit.DependenceInjection
This project , It's worth a try ~~
Reference
- https://github.com/pengweiqhca/Xunit.DependencyInjection
- https://github.com/pengweiqhca/Xunit.DependencyInjection/blob/7.0/Xunit.DependencyInjection/DependencyInjectionTestFramework.cs
- https://github.com/OpenReservation/ReservationServer
- https://github.com/OpenReservation/ReservationServer/commit/d30e35116da0b8d4bf3e65f0a1dcabcad8fecae0