使用 Xunit.DependencyInjection 改造測試項目

使用 Xunit.DependencyInjection 改造測試項目

Intro

這篇文章拖了很長時間沒寫,以前也有介紹過 Xunit.DependencyInjection 這個項目,這個項目是由大師寫的一個 Xunit 基於微軟 GenericHost 和 依賴注入實現的一個擴展庫,可讓你更方便更容易的在測試項目裏實現依賴注入,並且我以爲另一點很好的是能夠更好的控制操做流程,好比不少在啓動測試以前去作的初始化操做,更好用的流程控制。git

最近把咱們公司的測試項目大多基於 Xunit.DependencyInjection 改造了,使用效果很好。github

最近把個人測試項目從原來本身手動啓動一個 Web Host 改爲了基於 Xunit.DepdencyInjection 來使用,同時也是爲咱們公司的一個項目的集成測試的更新作準備,用起來很香~app

我以爲 Xunit.DependencyInjection 解決了我兩個很大的痛點,一個是依賴注入的代碼寫起來不爽,一個是更簡單的流程控制處理,下面大概介紹一下asp.net

XUnit.DependencyInjection 工做流程

Xunit.DepdencyInjection 主要的流程在 DependencyInjectionTestFramework 中,詳見 https://github.com/pengweiqhca/Xunit.DependencyInjection/blob/7.0/Xunit.DependencyInjection/DependencyInjectionTestFramework.cside

首先會去嘗試尋找項目中的 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 的一些新語法,代碼十分簡潔,下面代碼使用了可空引用類型:ui

DependencyInjectionTestFramework 源碼.net

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 源碼3d

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

相關文章
相關標籤/搜索