65.9K
CodeProject 正在变化。 阅读更多。
Home

依赖树测试

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2023年2月27日

CPOL

8分钟阅读

viewsIcon

10289

在这里,我们将讨论如何使用依赖注入容器来测试来自多个项目的类之间的交互。

引言

如今,依赖注入容器的使用非常普遍。类的构造函数接受其他类的实例,它们依赖于其他类,等等。依赖注入容器负责管理整个实例树的构建。

这个系统是有代价的。例如,在测试期间,您必须创建类的所有依赖项的实例才能测试该类。您可以使用类似 Moq 的工具来完成此任务。但在这种情况下,类更改会出现问题。如果您想添加或删除任何构造函数参数,即使该参数不影响它们,您也必须更改测试。

在测试期间,我们还有另一项任务要解决。假设我们不想测试一个孤立的类,而是想测试我们系统中某个部分几个类协同工作的情况。我们的依赖注入容器会创建一个包含各种类实例的整个树。而您想测试整个树。让我们看看如何做到这一点,我们将面临哪些障碍以及如何克服它们。

对构造函数更改的弹性

假设我们有一个类需要测试

public class System
{
    public System(
        IService1 service1,
        IService2 service2
    )
    {
        ...
    }

    ...
}

通常,此类情况下的测试如下所示

[TestMethod]
public void SystemTest()
{
    var service1Mock = new Mock<IService1>();
    var service2Mock = new Mock<IService2>();

    var system = new System(
        service1Mock.Object,
        service2Mock.Object
    );

    ...
}

但今天,我想为我的 System 类添加日志记录

public class System
{
    public System(
        IService1 service1,
        IService2 service2,
        ILogger logger
    )
    {
        ...
    }

    ...
}

现在这些类的测试无法编译。我必须到所有创建 System 类实例的地方并更改那里的代码

[TestMethod]
public void SystemTest()
{
    var service1Mock = new Mock<IService1>();
    var service2Mock = new Mock<IService2>();
    var loggerMock = new Mock<ILogger>();

    var system = new System(
        service1Mock.Object,
        service2Mock.Object,
        loggerMock.Object
    );

    ...
}

当然,为了减少工作量,我可以将创建类实例的代码移到一个单独的方法中。这样我就不必在很多地方进行更改了。

private Mock<IService1> service1Mock = new();
private Mock<IService2> service2Mock = new();
private Mock<ILogger> loggerMock = new();

private System CreateSystem()
{
    return new System(
        service1Mock.Object,
        service2Mock.Object,
        loggerMock.Object
    );
}

[TestMethod]
public void SystemTest()
{
    var system = CreateSystem();

    ...
}

但这种方法也有其缺点。即使我不需要 ILogger mock,我也必须创建一个,只是为了将其传递给我的类的构造函数。

幸运的是,有 AutoMocker。您只需使用 CreateInstance 创建类的实例

private AutoMocker _autoMocker = new();

[TestMethod]
public void SystemTest()
{
    var system = _autoMocker.CreateInstance<System>();

    ...
}

此方法可以创建任何类的实例,甚至是 sealed 类。它就像一个依赖注入容器,分析构造函数并为其参数创建 mock。

在任何时候,您都可以获取任何您想要的 mock 来设置其行为或验证其方法的调用

var service1Mock = _autoMocker.GetMock<IService1>();

此外,如果您不想使用 Moq mock,而是有自己的一些接口实现,您可以在调用 CreateInstance 之前执行此操作

var testService1 = new TestService1();
_autoMocker.Use<IService1>(testService1);

太棒了!现在您可以自由地更改构造函数的签名,而不必担心在成千上万个地方更改测试。

然而,测试仍然可以编译并不意味着它们在更改类后仍会通过。另一方面,已经多次说过,测试应该检查类的契约,而不是其实现。如果契约未更改,测试仍应通过。如果契约已更改,则无法避免更改测试。

然而,在我们开始使用 AutoMocker 之前,我们立即看到了哪些测试受到了我们构造函数更改的影响,并且只能运行这些测试。现在我们可能不得不运行所有测试,除非我们有一些关于在哪里存储一个类的所有测试的约定。但在这里,每个人都必须自己选择。

我们继续。

依赖项测试

我的一个同事建议更进一步。事实上,在我们的应用程序中,我们仍然在创建一个依赖注入容器。在那里,我们注册了我们所有的类和接口。那么为什么我们不从这个容器中获取类的实例进行测试呢?在这种情况下,我们将测试生产中实际使用的对象树。这对于集成测试将非常有益。

例如,我们的依赖项注册代码如下所示

services.AddLogging();
services.AddDomainClasses();
services.AddRepositories();
...

我们将其移到一个单独的方法中

public static class ServicesConfiguration
{
    public static void RegisterEverything(IServiceCollection services)
    {
        services.AddLogging();
        services.AddDomainClasses();
        services.AddRepositories();
        ...
    }
}

并使用此方法注册我们的服务

ServicesConfiguration.RegisterEverything(services);

现在我们也可以在测试中使用此方法

[TestMethod]
public void SystemTest()
{
    IServiceCollection services = new ServiceCollection();
    ServicesConfiguration.RegisterEverything(services);
    var provider = services.BuildServiceProvider();

    using var scope = provider.CreateScope();

    var system = scope.ServiceProvider.GetRequiredService<System>();

    ...
}

即使您的类未在依赖注入容器中注册,但如果您只想从中获取其构造函数的参数,您可以这样做

var system = ActivatorUtilities.CreateInstance<System>(_scope.ServiceProvider);

很自然,可能需要对已注册的服务进行一些更改。例如,如果您不使用 IConfiguration 来获取数据库连接字符串,您可能需要更改它们

IServiceCollection services = new ServiceCollection();
Configuration.RegisterEverything(services);

services.RemoveAll<IConnectionStringsProvider>();
services.AddSingleton<IConnectionStringsProvider>(new TestConnectionStringsProvider());

如果您使用 IConfiguration,您可以使用内存存储来创建自己的配置

var builder = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appSettings.json", optional: true, reloadOnChange: true)
    .AddInMemoryCollection(settings);

var configuration = builder.Build();

但即使在这种情况下,我们有时仍然希望使用 mock。您看,即使在集成测试中,也有您无法控制的外部依赖项。如果您仍然可以重新创建自己的数据库,那么还有一些您通过 HTTP 请求使用的外部服务。您仍然想使用 mock 来模仿这些外部请求。

为了支持 mock,我们需要编写一定量的代码。我将所有与获取服务实例和管理 mock 相关逻辑移到了一个类中

public class SimpleConfigurator : IServiceProvider, IDisposable
{
    private readonly IDictionary<Type, 
    Mock> _registeredMocks = new Dictionary<Type, Mock>();
    private readonly IServiceCollection _services;

    private IServiceProvider _serviceProvider;
    private IServiceScope? _scope;
    private bool _configurationIsFinished = false;

    public SimpleConfigurator(IServiceCollection services)
    {
        _services = services;
    }

    public void Dispose()
    {
        _scope?.Dispose();
    }

    /// <summary>
    /// Creates instance of <typeparamref name="T"/> type using dependency container
    /// to resolve constructor parameters.
    /// </summary>
    /// <typeparam name="T">Type of instance.</typeparam>
    /// <returns>Instance of <typeparamref name="T"/> type.</returns>
    public T CreateInstance<T>()
    {
        PrepareScope();

        return ActivatorUtilities.CreateInstance<T>(_scope!.ServiceProvider);
    }

    /// <summary>
    /// Returns service registered in the container.
    /// </summary>
    /// <param name="serviceType">Service type.</param>
    /// <returns>Instance of a service from the container.</returns>
    public object? GetService(Type serviceType)
    {
        PrepareScope();

        return _scope!.ServiceProvider.GetService(serviceType);
    }

    /// <summary>
    /// Replaces in the dependency container records of <typeparamref name="T"/> type
    /// with a singleton mock and returns the mock.
    /// </summary>
    /// <typeparam name="T">Type of service.</typeparam>
    /// <returns>Mock for the <typeparamref name="T"/> type.</returns>
    /// <exception cref="InvalidOperationException">This method can't be called after
    /// any service is resolved from the container.</exception>
    public Mock<T> GetMock<T>()
        where T : class
    {
        if (_registeredMocks.ContainsKey(typeof(T)))
        {
            return (Mock<T>)_registeredMocks[typeof(T)];
        }

        if (!_configurationIsFinished)
        {
            var mock = new Mock<T>();

            _registeredMocks.Add(typeof(T), mock);

            _services.RemoveAll<T>();
            _services.AddSingleton(mock.Object);

            return mock;
        }
        else
        {
            throw new InvalidOperationException($"You can not create new mock 
            after any service is already resolved (after call of 
            {nameof(CreateInstance)} or {nameof(GetService)})");
        }
    }

    private void PrepareScope()
    {
        if (!_configurationIsFinished)
        {
            _configurationIsFinished = true;

            _serviceProvider = _services.BuildServiceProvider();

            _scope = _serviceProvider.CreateScope();
        }
    }
}

让我们更详细地检查一下这个类。

这个类实现了标准的 IServiceProvider 接口,所以您可以使用该接口的所有功能来获取服务实例。此外,CreateInstance 方法允许您创建容器中未注册的类的实例,但其构造函数参数可以从容器解析。

该类在解析任何服务之前创建一个新的作用域(_scope 字段)。这使您可以使用即使是为作用域注册的服务(例如,使用 AddScope 方法)。作用域将在 Dispose 方法中被销毁。这就是为什么该类实现 IDisposable 接口的原因。

现在来说明获取 mock(GetMock 方法)。在这里,我们实现了以下想法。您可以创建任何 mock,但只能在第一个服务从容器解析之前。之后,您将无法创建新的 mock。原因是容器使用某些特定的依赖项实例来创建服务。这意味着服务对象可以存储对这些类实例的引用。而现在无法替换这些引用。因此,在第一个服务解析后创建的 mock 实际上是无用的。这就是为什么我们不允许创建它们。

所有已创建的 mock 都存储在字典 _registeredMocks 中。_configurationIsFinished 字段包含有关是否已有服务被解析的信息。

请注意,当我们创建 mock 时,我们会从容器中删除该类型的所有条目,并用这个 mock 替换。如果您需要测试获取的不仅仅是一个实例,而是该类型的对象集合的代码,这种方法可能不够。在这种情况下,您将不得不扩展此类功能的范围以满足您的需求。

项目级别测试

到目前为止,我们使用依赖注入容器来测试整个应用程序。但还有另一种选择。在我们公司,解决方案包含几个对应于领域区域的部分。每个部分可以包含几个项目(程序集)—用于领域类,用于基础设施,... 例如

  • Users.Domain
  • Users.Repository
  • Users.Api

  • Orders.Domain
  • Orders.Repository
  • Orders.Api

而每个这样的项目都为 IServiceCollection 提供一个扩展方法,用于注册该项目中的类

public static class ContainerConfig
{
    public static void RegisterDomainServices(this IServiceCollection services)
    {
        services.AddScope<ISystem, System>();
        services.AddScope<IService1, Service1>();
        ...
    }
}

最后,我们的主项目只是使用了所有这些扩展方法。

假设我们想在项目级别创建测试。这意味着我们只想测试一个特定项目中定义的类之间的交互。这似乎很简单。我们创建一个 ServiceCollection 实例,对该实例执行我们的扩展方法,现在我们就处于测试整个应用程序时相同的境地了。

但这里有一个严重的区别。当我们测试整个应用程序时,我们的 ServiceCollection 类实例中注册了所有类。在单独的项目情况下,情况并非如此。这个扩展方法只注册该项目中定义的类。但这些类可能依赖于不在该项目中实现,但在其他地方实现的接口。

例如,我们的 System 类依赖于 IService1IService2 接口。这两个接口都在包含 System 类的同一项目中定义。但 IService1 接口在那里有自己的实现,而 IService2 接口没有。预计它将在其他项目中实现,我们的应用程序将从那里获取它。

那么我们如何仅使用同一项目中的类来测试 System 类呢?这个想法是强迫我们的依赖注入容器为未注册的接口使用 mock。为了做到这一点,我们需要一个能够处理缺失依赖项情况的容器。我使用了 DryIoc。让我们看看如何创建必要的功能

public class Configurator : IServiceProvider, IDisposable
{
    private readonly AutoMocker _autoMocker = new AutoMocker();
    private readonly IDictionary<Type, Mock> 
            _registeredMocks = new Dictionary<Type, Mock>();
    private readonly IServiceCollection _services;

    private IContainer? _container;
    private IServiceScope? _scope;
    private bool _configurationIsFinished = false;

    public Configurator(IServiceCollection? services = null)
        : this(FillServices(services))
    {
    }

    public Configurator(Action<IServiceCollection> configuration)
    {
        _services = new ServiceCollection();

        configuration?.Invoke(_services);
    }

    private static Action<IServiceCollection> FillServices(IServiceCollection? services)
    {
        return internalServices =>
        {
            if (services != null)
            {
                foreach (var description in services)
                {
                    internalServices.Add(description);
                }
            }
        };
    }

    public void Dispose()
    {
        _scope?.Dispose();

        _container?.Dispose();
    }

    /// <summary>
    /// Creates instance of <typeparamref name="T"/> type using dependency container
    /// to resolve constructor parameters.
    /// </summary>
    /// <typeparam name="T">Type of instance.</typeparam>
    /// <returns>Instance of <typeparamref name="T"/> type.</returns>
    public T CreateInstance<T>()
    {
        PrepareScope();

        return ActivatorUtilities.CreateInstance<T>(_scope!.ServiceProvider);
    }

    /// <summary>
    /// Returns service registered in the container.
    /// </summary>
    /// <param name="serviceType">Service type.</param>
    /// <returns>Instance of a service from the container.</returns>
    public object? GetService(Type serviceType)
    {
        PrepareScope();

        return _scope!.ServiceProvider.GetService(serviceType);
    }

    /// <summary>
    /// Replaces in the dependency container records of <typeparamref name="T"/> type
    /// with a singleton mock and returns the mock.
    /// </summary>
    /// <typeparam name="T">Type of service.</typeparam>
    /// <returns>Mock for the <typeparamref name="T"/> type.</returns>
    /// <exception cref="InvalidOperationException">This method can't be called after
    /// any service is resolved from the container.</exception>
    public Mock<T> GetMock<T>()
        where T : class
    {
        if (_registeredMocks.ContainsKey(typeof(T)))
        {
            return (Mock<T>)_registeredMocks[typeof(T)];
        }

        if (!_configurationIsFinished)
        {
            var mock = new Mock<T>();

            _registeredMocks.Add(typeof(T), mock);

            _services.RemoveAll<T>();
            _services.AddSingleton(mock.Object);

            return mock;
        }
        else
        {
            throw new InvalidOperationException
            ($"You can not create new mock after any service 
            is already resolved (after call of {nameof(CreateInstance)} 
            or {nameof(GetService)})");
        }
    }

    private void PrepareScope()
    {
        if (!_configurationIsFinished)
        {
            _configurationIsFinished = true;

            _container = CreateContainer();

            _scope = _container.BuildServiceProvider().CreateScope();
        }
    }

    private IContainer CreateContainer()
    {
        Rules.DynamicRegistrationProvider dynamicRegistration = 
                                          (serviceType, serviceKey) =>
        new[]
        {
            new DynamicRegistration(DelegateFactory.Of(_ =>
            {
                if(_registeredMocks.ContainsKey(serviceType))
                {
                    return _registeredMocks[serviceType].Object;
                }

                var mock = _autoMocker.GetMock(serviceType);

                _registeredMocks[serviceType] = mock;

                return mock.Object;
            }))
        };

        var rules = Rules.Default.WithDynamicRegistration(
            dynamicRegistration,
            DynamicRegistrationFlags.Service | DynamicRegistrationFlags.AsFallback);

        var container = new Container(rules);

        container.Populate(_services);

        return DryIocAdapter.WithDependencyInjectionAdapter(container);
    }
}

Configurator 类与前面显示的 SimpleConfigurator 类非常相似,但它有几个重要的区别。首先,我们使用 DryIoc 而不是 Microsoft 依赖注入容器。对于这个容器,我们为需要未注册依赖项的情况设置了行为

Rules.DynamicRegistrationProvider dynamicRegistration = (serviceType, serviceKey) =>
new[]
{
    new DynamicRegistration(DelegateFactory.Of(_ =>
    {
        if(_registeredMocks.ContainsKey(serviceType))
        {
            return _registeredMocks[serviceType].Object;
        }

        var mock = _autoMocker.GetMock(serviceType);

        _registeredMocks[serviceType] = mock;

        return mock.Object;
    }))
};

var rules = Rules.Default.WithDynamicRegistration(
    dynamicRegistration,
    DynamicRegistrationFlags.Service | DynamicRegistrationFlags.AsFallback);

var container = new Container(rules);

在这种情况下,我们创建一个 Moq mock 并保存对其的引用。这使我们以后可以获取它进行配置和验证。

现在我们可以仅使用同一项目中的类来测试我们的系统

[TestMethod]
public void TestSystem()
{
    using var configurator = new Configurator(service => 
                             { services.RegisterDomainServices() });

    var system = configurator.GetRequiredService<System>();

    var service2Mock = configurator.GetMock<IService2>();

    ...
}

当然,我们不必只局限于一个项目。例如,我们可以以这种方式测试与同一领域相关的几个项目的类。

结论

在本文中,我们讨论了如何利用现有的依赖注入容器基础设施进行测试。毫无疑问,有很多方法可以改进提出的系统。但我希望我已经为您提供了一个可能有用的框架。

如果您喜欢我的文章,您可以在我的 博客 上阅读更多内容。

附注:示例的源代码可以在 GitHub 上找到。

历史

  • 2023 年 2 月 27 日:初始版本
© . All rights reserved.