使用 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

相关文章
相关标签/搜索