如何使用构造函数依赖注入对ASP.NET Core应用程序进行单元测试

147

我有一个使用依赖注入的asp.net core应用程序,依赖注入在应用程序的startup.cs类中定义:

    public void ConfigureServices(IServiceCollection services)
    {

        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlServer(Configuration["Data:FotballConnection:DefaultConnection"]));


        // Repositories
        services.AddScoped<IUserRepository, UserRepository>();
        services.AddScoped<IUserRoleRepository, UserRoleRepository>();
        services.AddScoped<IRoleRepository, RoleRepository>();
        services.AddScoped<ILoggingRepository, LoggingRepository>();

        // Services
        services.AddScoped<IMembershipService, MembershipService>();
        services.AddScoped<IEncryptionService, EncryptionService>();

        // new repos
        services.AddScoped<IMatchService, MatchService>();
        services.AddScoped<IMatchRepository, MatchRepository>();
        services.AddScoped<IMatchBetRepository, MatchBetRepository>();
        services.AddScoped<ITeamRepository, TeamRepository>();

        services.AddScoped<IFootballAPI, FootballAPIService>();

这使得像这样的事情成为可能:

[Route("api/[controller]")]
public class MatchController : AuthorizedController
{
    private readonly IMatchService _matchService;
    private readonly IMatchRepository _matchRepository;
    private readonly IMatchBetRepository _matchBetRepository;
    private readonly IUserRepository _userRepository;
    private readonly ILoggingRepository _loggingRepository;

    public MatchController(IMatchService matchService, IMatchRepository matchRepository, IMatchBetRepository matchBetRepository, ILoggingRepository loggingRepository, IUserRepository userRepository)
    {
        _matchService = matchService;
        _matchRepository = matchRepository;
        _matchBetRepository = matchBetRepository;
        _userRepository = userRepository;
        _loggingRepository = loggingRepository;
    }

这很好,但在我想进行单元测试时会成为一个问题。因为我的测试库没有startup.cs文件,我无法设置依赖注入。所以具有这些接口作为参数的类将只是null。

namespace TestLibrary
{
    public class FootballAPIService
    {
        private readonly IMatchRepository _matchRepository;
        private readonly ITeamRepository _teamRepository;

        public FootballAPIService(IMatchRepository matchRepository, ITeamRepository teamRepository)

        {
            _matchRepository = matchRepository;
            _teamRepository = teamRepository;
在上面的代码中,在测试库中,_matchRepository_teamRepository将会是null。:(
我能否像ConfigureServices一样在我的测试库项目中定义依赖注入?

2
作为测试的一部分,您应该设置 System Under Test (SUT) 的依赖项。通常,您可以在创建 SUT 之前创建依赖项的模拟。但是,对于您的测试来说,仅通过调用 new SUT(mockDependency); 来创建 SUT 就足够了。 - Stephen Ross
8个回答

207
尽管 @Kritner 的回答是正确的,但为了代码完整性和更好的 DI(Dependency Injection)体验,我更喜欢以下方法:
[TestClass]
public class MatchRepositoryTests
{
    private readonly IMatchRepository matchRepository;

    public MatchRepositoryTests()
    {
        var services = new ServiceCollection();
        services.AddTransient<IMatchRepository, MatchRepositoryStub>();

        var serviceProvider = services.BuildServiceProvider();

        matchRepository = serviceProvider.GetService<IMatchRepository>();
    }
}

7
GetService<>具有一些重载,可以在使用Microsoft.Extensions.DependencyInjection时找到。 - Neville Nazerane
18
我刚刚测试了一下,这个回答比标记的答案更加正确。这个使用了依赖注入(DI)。我尝试将其用于我在网站上使用的相同扩展函数,这个功能完美地运作。 - Neville Nazerane
16
这并不是对服务进行单元测试,而是使用Microsoft的DI进行集成测试。微软已经有了用于测试DI的单元测试,因此没有必要这样做。如果您想测试对象是否已注册,则应将其视为责任分离并放入自己的测试中。单元测试对象意味着在没有外部依赖关系的情况下测试对象本身。 - Erik Philips
我该如何在使用这种方式时使用 moq 进行模拟? - Jeeva J
3
@ErikPhilips 我认为你在这里误解了目的,例如我有一个类,它具有多个依赖项,每个依赖项都有多个依赖项。通过像这个答案一样使用 DI 在测试中创建这些对象,我不是在测试 DI,而是只是使用 DI 构造我的对象,而不是编写 30 行“新建”代码来完成相同的事情 - 这需要很长时间才能弄清楚。因此,这只是在单元测试中使用 DI 构造您的对象。如果在生产代码中使用 DI 是有用的,那么在测试代码中使用它也必须是有用的。 - PandaWood
显示剩余7条评论

54

我编写了一个通用的依赖项解析器辅助类,然后在我的单元测试类中构建了IWebHost。

通用依赖项解析器

        using Microsoft.AspNetCore.Hosting;
        using Microsoft.AspNetCore.Mvc;
        using Microsoft.Extensions.Configuration;
        using Microsoft.Extensions.DependencyInjection;
        using Microsoft.Extensions.Hosting;
        public class DependencyResolverHelper
        {
            private readonly IWebHost _webHost;
    
            /// <inheritdoc />
            public DependencyResolverHelper(IWebHost webHost) => _webHost = webHost;
    
            public T GetService<T>()
            {
                var serviceScope = _webHost.Services.CreateScope();
                var services = serviceScope.ServiceProvider;
                try
                {
                  var scopedService = services.GetRequiredService<T>();
                  return scopedService;
                }
                catch (Exception e)
                {
                   Console.WriteLine(e);
                   throw;
                }
            }
        }
    }

单元测试项目:

      [TestFixture]
        public class DependencyResolverTests
        {
            private DependencyResolverHelper _serviceProvider;

            public DependencyResolverTests()
            {

                var webHost = WebHost.CreateDefaultBuilder()
                    .UseStartup<Startup>()
                    .Build();
                _serviceProvider = new DependencyResolverHelper(webHost);
            }
    
            [Test]
            public void Service_Should_Get_Resolved()
            {
                
                //Act
                var YourService = _serviceProvider.GetService<IYourService>();
    
                //Assert
                Assert.IsNotNull(YourService);
            }
    

        }

是的,这应该是默认答案。我们已经放置了一个完整的套件来测试所有需要注入的服务,并且它运行得非常好。谢谢! - facundofarias
4
对我来说,释放作用域似乎是不正确的 - https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.iservicescope?view=aspnetcore-2.2 - "一旦调用Dispose,任何已从ServiceProvider解析的作用域服务都将被处理。" - Eugene Podskal
1
您可能需要删除“using”语句以避免在DBcontexts上出现“disposed object error”。 - Mosta
1
我无法解决在.UseStartup<Startup>()中的Startup类型,可能是因为我使用的是.NET 6,而它没有Startup.cs文件。 - Tawab Wakil
1
@TawabWakil 正确,除非您创建自己的启动类或公开一个返回 IApplicationBuilder 的公共 getter 方法,否则在 .Net 6+ 中无法使用 UseStartup<Startup>() - 请参见此处获取信息 - Joshua Duxbury
显示剩余8条评论

42

在 .net core 中,控制器从一开始就考虑了依赖注入,但这并不意味着你必须使用依赖注入容器。

例如,对于以下更简单的类:

public class MyController : Controller
{

    private readonly IMyInterface _myInterface;

    public MyController(IMyInterface myInterface)
    {
        _myInterface = myInterface;
    }

    public JsonResult Get()
    {
        return Json(_myInterface.Get());
    }
}

public interface IMyInterface
{
    IEnumerable<MyObject> Get();
}

public class MyClass : IMyInterface
{
    public IEnumerable<MyObject> Get()
    {
        // implementation
    }
}

因此,在您的应用程序中,您正在使用依赖注入容器在您的startup.cs中,它仅提供MyClass的实例,以在遇到IMyInterface时使用。 但这并不意味着这是获取MyController实例的唯一方式。

单元测试场景中,您可以(也应该)提供自己的IMyInterface实现(或模拟/存根/虚拟)。示例如下:

public class MyTestClass : IMyInterface
{
    public IEnumerable<MyObject> Get()
    {
        List<MyObject> list = new List<MyObject>();
        // populate list
        return list;
    }        
}

并且在你的测试中:

[TestClass]
public class MyControllerTests
{

    MyController _systemUnderTest;
    IMyInterface _myInterface;

    [TestInitialize]
    public void Setup()
    {
        _myInterface = new MyTestClass();
        _systemUnderTest = new MyController(_myInterface);
    }

}

对于单元测试MyController的范围而言,IMyInterface的实际实现并不重要(也不应该重要),只有接口本身才是重要的。我们已经通过MyTestClass提供了“虚假”的IMyInterface实现,但你也可以使用像MoqRhinoMocks这样的模拟来实现。

总之,你不需要依赖注入容器来完成测试,只需要一个独立的、可控制的、实现/模拟/存根/伪造的测试类依赖项即可。


3
完美的回答。我甚至会进一步建议在单元测试中永远不要使用 DI 容器,除非是为了测试 DI 配置的正确性,比如应用装饰器的顺序等。 - Ric .Net
65
当你需要为多个类注入多个依赖时,我不确定这能有多少帮助。我希望能够注册默认实现(或带有默认行为的模拟实现),这样我就可以实例化对象图而无需先设置30个依赖项,而是重新配置测试所需的那些依赖项。 - Sinaesthetic
2
@Sinaesthetic,这就是测试和模拟框架的作用。nUnit允许您创建一次性或每个测试运行的方法,使您能够模拟所有内容,然后在测试中只关注配置您正在测试的方法。实际上,使用DI进行测试意味着它不再是单元测试,而是Microsoft(或第三方)DI的集成测试 - Erik Philips
2
实际上,使用 DI 进行测试并不意味着它不再是单元测试。至少在表面上我不能完全同意你的观点。通常情况下,DI 是必要的,只是为了初始化类,以便可以对该单元进行测试。重点是模拟依赖项,以便您可以测试该单元在依赖项周围的行为。我认为你可能指的是注入一个完全功能的依赖项的情况,那么除非该对象的依赖项也被模拟,否则它可能是一个集成测试。有许多一次性的场景可以讨论。 - Sinaesthetic
1
这绝对是正确的答案。我简直不敢相信它不是被投票最高的答案 - 所有得票更高的答案都没有抓住重点。关键在于,当您使用 DI 容器时,应该隔离测试系统的一部分,而您使用 DI 容器时,实际上会重新创建整个系统。在单元测试中,除非您在受测系统中使用它(这通常是不必要的),否则不应使用 DI 容器。如果您有很多依赖项,可以使用例如 NSubstitute 进行模拟。如果每个测试仍然感觉像是很大的工作量,那么您可能有太多的依赖项。 - zola25
显示剩余2条评论

19

如果您正在使用 Program.cs + Startup.cs 约定并希望快速使其工作,您可以使用一行代码重用现有的主机构建器:

using MyWebProjectNamespace;

public class MyTests
{
    readonly IServiceProvider _services = 
        Program.CreateHostBuilder(new string[] { }).Build().Services; // one liner

    [Test]
    public void GetMyTest()
    {
        var myService = _services.GetRequiredService<IMyService>();
        Assert.IsNotNull(myService);
    }
}

网站项目中的示例 Program.cs 文件:

using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;

namespace MyWebProjectNamespace
{
    public class Program
    {
        public static void Main(string[] args) =>
            CreateHostBuilder(args).Build().Run();

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
    }
}

1
太棒了!非常感谢。我可以看到在[SetUp]中设置一个新的ServiceCollection可能很有用,甚至可以模拟依赖项。但是,实际上,我想做的是使用我的Web应用程序使用的相同服务集合,并针对相同的环境运行测试。干杯! - Nexus
1
在它的简洁中闪耀着卓越! - AndyS
1
太棒了。仅仅那一行代码就让我快速地开始编写我想要的一堆单元测试。谢谢你,Matthew! - user357086

6
您可以使用asp.net core DI在测试中注入模拟的实例对象。以下是一个完整的工作示例:
为了举例说明: - 我只保留了初始问题代码片段中的IMatchService依赖项 - 我在MatchController中添加了一个DoSomething操作,以便进行测试。 - 我添加了IMatchService和MatchService类的Add方法,以便进行模拟。
请注意,将有一个带有Moq设置的方法应该是虚拟的。
[Route("api/[controller]")]
public class MatchController : AuthorizedController
{
  private readonly IMatchService _matchService;

  public MatchController(IMatchService matchService)
  {
    _matchService = matchService;
  }

  public virtual int DoSomething()
  {
    return _matchService.Add(1, 2);
  }
}

public interface IMatchService
{
  int Add(int a, int b);
}

public class MatchService : IMatchService
{
  public virtual int Add(int a, int b)
  {
    return a + b;
  }
}

通过调用 Mock.Get 方法,始终可以获取模拟对象。

为每个依赖项方便起见,我创建了两个属性,如MatchServiceMockedMatchService

public class MyTests
{
  protected IMatchService MatchService { get; set; }

  protected Mock<IMatchService> MockedMatchService => Mock.Get(MatchService);

  private IServiceProvider ServicesProvider { get; set; }

  [SetUp]
  public void SetupBeforeEachTest()
  {
    // Configure DI container
    ServiceCollection services = new ServiceCollection();
    ConfigureServices(services);
    ServicesProvider = services.BuildServiceProvider();

    // Use DI to get instances of IMatchService
    MatchService = ServicesProvider.GetService<IMatchService>();
  }

  // In this test I mock the Add method of the dependency (IMatchService) so that it returns a value I choose
  [Test]
  public void TestMethod()
  {
    // Prepare
    var matchController = ServicesProvider.GetService<MatchController>();
    int expectedResult = 5;
    MockedMatchService.Setup(x => x.Add(It.IsAny<int>(), It.IsAny<int>())).Returns(expectedResult);

    // Act - This will call the real DoSomething method because the MatchController has comes from a Mock with CallBase = true
    int result = matchController.DoSomething();

    // Check
    Assert.AreEqual(expectedResult, result);
  }

  private static void ConfigureServices(IServiceCollection services)
  {
    services.AddScoped<IMatchService>();
    services.AddScoped<MatchController>();
  }
}

2
我参考了@madjack和@Kritner的答案,并制作了一个基础可继承的依赖注入测试类,只需在其中注册您的服务并继承即可。请保留HTML标签。
public class BaseTester 
{
    protected IProductService _productService; 
    protected IEmployeeService _employeeService; 

    public BaseTester()
    {
        var services = new ServiceCollection();

        services.AddTransient<IProductService, ProductService>();
        services.AddTransient<IEmployeeService, EmployeeService>();

        var serviceProvider = services.BuildServiceProvider();

        _productService = serviceProvider.GetService<IProductService>();
        _employeeService = serviceProvider.GetService<IEmployeeService>();
    }
}

0
改进的解决方案

我通过将madjack的解决方案封装在一个抽象类中并添加了四个方法(包括两个带有回调参数的异步方法)来改进它。现在GetRequiredScopedService<TSvc>()使用private static属性services进行缓存,因此派生类不会一次又一次地创建新实例。另一个优化是将host设置为static,这样我们就不需要在派生类中每次都构建它了。我还删除了无意义的try/catch块:

    public abstract class TestWithDependencyInjection
    {
        private static readonly IHost host =
            Program.CreateHostBuilder(Constants.CommandArgs).Build();
        private static readonly IList<object> services =
            new List<object>();

        private IServiceScope svcScope;

        protected async Task<TResult> UseSvcAsync<TSvc, TResult>(
            Func<TSvc, Task<TResult>> callback, 
            bool shouldBeDisposed = true)
        {
            var scopedSvc = GetRequiredScopedService<TSvc>();
            TResult result = await callback(scopedSvc);
            if(shouldBeDisposed) 
                svcScope.Dispose();
            return result;
        }

        protected async Task UseSvcAsync<TSvc>(
            Func<TSvc, Task> callback)
        {
            var scopedSvc = GetRequiredScopedService<TSvc>();
            await callback(scopedSvc);
            svcScope.Dispose();
        }

        protected TResult UseSvc<TSvc, TResult>(
            Func<TSvc, TResult> callback, bool shouldBeDisposed = true)
        {
            var scopedSvc = GetRequiredScopedService<TSvc>();
            TResult result = callback(scopedSvc);
            if(shouldBeDisposed)
                svcScope.Dispose();
            return result;
        }

        protected void UseSvc<TSvc>(Action<TSvc> callback)
        {
            var scopedSvc = GetRequiredScopedService<TSvc>();
            callback(scopedSvc);
            svcScope.Dispose();
        }

        private TSvc GetRequiredScopedService<TSvc>()
        {
            var requiredScopedSvc = (TSvc)services.SingleOrDefault(
                svc => svc is TSvc);
            if (requiredScopedSvc != null)
                return requiredScopedSvc;
            svcScope = host.Services.CreateScope();
            requiredScopedSvc = svcScope.ServiceProvider
                .GetRequiredService<TSvc>();
            services.Add(requiredScopedSvc);
            return requiredScopedSvc;
        }
    }
从使用注入服务返回async result的示例:
            int foobarsCount = await UseSvcAsync<IFoobarSvc, int>(
                    foobarSvc => foobarSvc.GetCountAsync());
附加信息

我在返回 TResultTask<TResult> 的方法中添加了可选的 shouldBeDisposed 参数,当你想要在回调体以外使用服务的同一实例时可以将其设置为 true

            IFoobarSvc foobarSvc = UseSvc<IFoobarSvc, IFoobarSvc>(
                    foobarSvc => foobarSvc, false);

-1

为什么要在测试类中注入它们呢? 通常,您会使用诸如RhinoMocks之类的工具创建存根或模拟来测试MatchController。以下是使用该工具和MSTest的示例,您可以从中推断:

[TestClass]
public class MatchControllerTests
{
    private readonly MatchController _sut;
    private readonly IMatchService _matchService;

    public MatchControllerTests()
    {
        _matchService = MockRepository.GenerateMock<IMatchService>();
        _sut = new ProductController(_matchService);
    }

    [TestMethod]
    public void DoSomething_WithCertainParameters_ShouldDoSomething()
    {
        _matchService
               .Expect(x => x.GetMatches(Arg<string>.Is.Anything))
               .Return(new []{new Match()});

        _sut.DoSomething();

        _matchService.AssertWasCalled(x => x.GetMatches(Arg<string>.Is.Anything);
    }

RhinoMocks 3.6.1包不兼容netcoreapp1.0(.NETCoreApp,Version = v1.0)。RhinoMocks 3.6.1支持:net(.NETFramework,Version = v0.0) - ganjan
其他框架正在慢慢接管这个集合。 - Alexandru Marculescu

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接