使用 Xunit.DependencyInjection
改造測試專案
Intro
這篇文章拖了很長時間沒寫,之前也有介紹過 Xunit.DependencyInjection
這個專案,這個專案是由大師寫的一個 Xunit
基於微軟 GenericHost 和 依賴注入實現的一個擴充套件庫,可以讓你更方便更容易的在測試專案裡實現依賴注入,而且我覺得另外一點很好的是可以更好的控制操作流程,比如很多在啟動測試之前去做的初始化操作,更好用的流程控制。
最近把我們公司的測試專案大多基於 Xunit.DependencyInjection
改造了,使用效果很好。
最近把我的測試專案從原來自己手動啟動一個 Web Host 改成了基於 Xunit.DepdencyInjection
來使用,同時也是為我們公司的一個專案的整合測試的更新做準備,用起來很香~
我覺得 Xunit.DependencyInjection
解決了我兩個很大的痛點,一個是依賴注入的程式碼寫起來不爽,一個是更簡單的流程控制處理,下面大概介紹一下
XUnit.DependencyInjection
工作流程
Xunit.DepdencyInjection
主要的流程在 DependencyInjectionTestFramework 中,詳見 https://github.com/pengweiqhca/Xunit.DependencyInjection/blob/7.0/Xunit.DependencyInjection/DependencyInjectionTestFramework.cs
首先會去嘗試尋找專案中的 Startup
,這個 Startup
很類似於 asp.net core 中的 Startup
,幾乎完全一樣,只是有一點不同, Startup
不支援依賴注入,不能像 asp.net core 中那樣注入一個 IConfiguration
物件來獲取配置,除此之外,和 asp.net core 的 Startup
有著一樣的體驗,如果找不到這樣的 Startup
就會認為沒有需要依賴注入的服務和特殊的配置,直接使用 Xunit
原有的 XunitTestFrameworkExecutor
,如果找到了 Startup
就從 Startup
約定的方法中配置 Host
,註冊服務以及初始化配置流程,最後使用 DependencyInjectionTestFrameworkExecutor
執行我們的 test case.
原始碼解析
原始碼使用了 C#8 的一些新語法,程式碼十分簡潔,下面程式碼使用了可空引用型別:
DependencyInjectionTestFramework
原始碼
public sealed class DependencyInjectionTestFramework : XunitTestFramework
{
public DependencyInjectionTestFramework(IMessageSink messageSink) : base(messageSink) { }
protected override ITestFrameworkExecutor CreateExecutor(AssemblyName assemblyName)
{
IHost? host = null;
try
{
// 獲取 Startup 例項
var startup = StartupLoader.CreateStartup(StartupLoader.GetStartupType(assemblyName));
if (startup == null) return new XunitTestFrameworkExecutor(assemblyName, SourceInformationProvider, DiagnosticMessageSink);
// 建立 HostBuilder
var hostBuilder = StartupLoader.CreateHostBuilder(startup, assemblyName) ??
new HostBuilder().ConfigureHostConfiguration(builder =>
builder.AddInMemoryCollection(new Dictionary<string, string> { { HostDefaults.ApplicationKey, assemblyName.Name } }));
// 呼叫 Startup 中的 ConfigureHost 方法配置 Host
StartupLoader.ConfigureHost(hostBuilder, startup);
// 呼叫 Startup 中的 ConfigureServices 方法註冊服務
StartupLoader.ConfigureServices(hostBuilder, startup);
// 註冊預設服務,構建 Host
host = hostBuilder.ConfigureServices(services => services
.AddSingleton(DiagnosticMessageSink)
.TryAddSingleton<ITestOutputHelperAccessor, TestOutputHelperAccessor>())
.Build();
// 呼叫 Startup 中的 Configure 方法來初始化
StartupLoader.Configure(host.Services, startup);
// 返回 testcase executor,準備開始跑測試用例
return new DependencyInjectionTestFrameworkExecutor(host, null,
assemblyName, SourceInformationProvider, DiagnosticMessageSink);
}
catch (Exception e)
{
return new DependencyInjectionTestFrameworkExecutor(host, e,
assemblyName, SourceInformationProvider, DiagnosticMessageSink);
}
}
}
StarpupLoader
原始碼
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());
}
實際案例
單元測試
來看我們專案裡的一個單元測試的一個改造,改造之前是這樣的:
這個測試專案使用了老版本的 AutoMapper
,每個有使用到 AutoMapper
的地方都會需要在測試用例裡呼叫一下注冊 AutoMapper
mapping 關係的方法來註冊 mapping 關係,因為 Register
方法裡直接呼叫的Mapper.Initialize
方法註冊 mapping 關係,多次呼叫的話會丟擲異常,所以每個測試用例方法裡用到 AutoMapper
的都有這個一段噁心的邏輯
第一次修改,我在 Register
方法做一個簡單的改造,把 try...catch
移除掉了:
但是這樣還是很不爽,每個用到 AutoMapper
的測試用例還是需要呼叫一下 Register
方法
使用 Xunit.DepdencyInjection
之後就可以只在 Startup
中的 Configure
方法裡註冊一下就可以,只需要呼叫一次就可以了
後面我們把 AutoMapper
升級了,使用依賴注入模式使用 AutoMapper
,改造之後的使用
直接在測試用例的類中注入需要的服務 IMapper
即可
整合測試
整合測試也是類似的,整合測試我用自己的專案作為一個示例
我的整合測試專案最初是用 xunit
裡的 CollectionFixture
結合 WebHost
來實現的(從 2.2 更新過來的,),在 .net core 3.1 裡可以直接配置 WebHostedService
就可以了,而 Xunit.DependencyInjection
是基於 微軟的 GenericHost
的所以,也會比較簡單的做整合。
在 Startup
裡 通過 ConfigureHost
方法配置 IHostBuilder
的擴充套件方法 ConfigureWebHost
,註冊測試需要的服務,在測試示例類的構造方法中注入服務即可
整合測試改造變更可以參考: https://github.com/OpenReservation/ReservationServer/commit/d30e35116da0b8d4bf3e65f0a1dcabcad8fecae0
Startup 支援的方法
CreateHostBuilder
public class Startup
{
public IHostBuilder CreateHostBuilder([AssemblyName assemblyName]) { }
}
使用這個方法來自定義 IHostBuilder
的時候可以用這個方法,通常可能不太會用到這個方法,可以通過 ConfigureHost
方法來配置 Host
預設是直接 new HostBuilder()
, 想要構建 aspnet.core 裡預設配置的 HostBuilder
, 可以使用 Host.CreateDefaultBuilder()
來建立 IHostBuilder
ConfigureHost
配置Host
public class Startup
{
public void ConfigureHost(IHostBuilder hostBuilder) { }
}
通過 ConfigureHost
來配置 Host
,可以通過這個方法配置 IConfiguration
,也可以配置要註冊的服務等
配置可以通過 IHostBuilder
的擴充套件方法 ConfigureAppConfiguration
來更新配置
ConfigureServices
public class Startup
{
public void ConfigureServices(IServiceCollection services[, HostBuilderContext context]) { }
}
如果不需要讀取 IConfiguration
可以通過直接使用 ConfigurationServices(IServiceCollection services)
方法
如果需要讀取 IConfiguration
,可以通過 ConfigureServices(IServiceCollection services, HostBuilderContext context)
方法通過 HostBuilderContext.Configuration
來訪問配置物件 IConfiguration
Configure
public class Startup
{
public void Configure([IServiceProvider applicationServices]) { }
}
Configure
方法可以沒有引數,也支援所有注入的服務,和 asp.net core 裡的 Configure
方法類似,通常可以在這個方法裡做一些初始化配置
More
如果你有在使用 Xunit
的時候遇到上述問題,推薦你試一下 Xunit.DependenceInjection
這個專案,十分值得一試~~
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