天天看點

使用 Xunit.DependencyInjection 改造測試項目

使用 Xunit.DepdencyInjection 改造測試項目

使用

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());
}
           

實際案例

單元測試

來看我們項目裡的一個單元測試的一個改造,改造之前是這樣的:

使用 Xunit.DependencyInjection 改造測試項目

這個測試項目使用了老版本的

AutoMapper

,每個有使用到

AutoMapper

的地方都會需要在測試用例裡調用一下注冊

AutoMapper

mapping 關系的方法來注冊 mapping 關系,因為

Register

方法裡直接調用的

Mapper.Initialize

方法注冊 mapping 關系,多次調用的話會抛出異常,是以每個測試用例方法裡用到

AutoMapper

的都有這個一段惡心的邏輯

第一次修改,我在

Register

方法做一個簡單的改造,把

try...catch

移除掉了:

使用 Xunit.DependencyInjection 改造測試項目

但是這樣還是很不爽,每個用到

AutoMapper

的測試用例還是需要調用一下

Register

方法

Xunit.DepdencyInjection

之後就可以隻在

Startup

中的

Configure

方法裡注冊一下就可以,隻需要調用一次就可以了

使用 Xunit.DependencyInjection 改造測試項目

後面我們把

AutoMapper

更新了,使用依賴注入模式使用

AutoMapper

,改造之後的使用

使用 Xunit.DependencyInjection 改造測試項目
使用 Xunit.DependencyInjection 改造測試項目

直接在測試用例的類中注入需要的服務

IMapper

即可

內建測試

內建測試也是類似的,內建測試我用自己的項目作為一個示例

我的內建測試項目最初是用

xunit

裡的

CollectionFixture

結合

WebHost

來實作的(從 2.2 更新過來的,),在 .net core 3.1 裡可以直接配置

WebHostedService

就可以了,而

Xunit.DependencyInjection

是基于 微軟的

GenericHost

的是以,也會比較簡單的做內建。

Startup

裡 通過

ConfigureHost

方法配置

IHostBuilder

的擴充方法

ConfigureWebHost

,注冊測試需要的服務,在測試示例類的構造方法中注入服務即可

使用 Xunit.DependencyInjection 改造測試項目
使用 Xunit.DependencyInjection 改造測試項目
使用 Xunit.DependencyInjection 改造測試項目
使用 Xunit.DependencyInjection 改造測試項目
使用 Xunit.DependencyInjection 改造測試項目

內建測試改造變更可以參考: 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

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接配接,否則保留追究法律責任的權利。