目前尚不清楚的是:如果实际上是未提供适当的服务依赖项的问题,那么为什么代码可以正常工作(在未测试时)。 SUT(控制器)仅接受IRepository
参数(所以这就是在任何情况下提供的所有内容。)为什么要创建一个重载的 ctor(或模拟)只是为了测试,当现有的 ctor 是运行程序时调用的所有内容并且运行没有问题时?
您在这里混淆了一些事情:首先,您不需要创建单独的构造函数。不适用于测试,也不适用于将其作为应用程序的一部分实际运行。
您应该将控制器具有的所有直接依赖项定义为构造函数的参数,以便当它作为应用程序的一部分运行时,依赖项注入容器会将这些依赖项提供给控制器。
但这也是这里重要的一点:运行应用程序时,有一个依赖项注入容器负责创建对象并提供所需的依赖项。所以你实际上不需要太担心它们来自哪里。但这在单元测试时是不同的。在单元测试中,我们不想使用依赖项注入,因为这只会隐藏依赖项,因此可能会产生与我们的测试冲突的副作用。在单元测试中依赖依赖注入是一个很好的迹象,表明您不这样做unit测试但做一体化而是进行测试(至少除非您实际测试 DI 容器)。
相反,在单元测试中,我们想要创建所有对象明确地显式提供所有依赖项。这意味着我们更新控制器并传递控制器具有的所有依赖项。理想情况下,我们使用模拟,这样我们就不会在单元测试中依赖外部行为。
大多数时候这都是非常简单的。不幸的是,控制器有一些特殊之处:控制器有一个ControllerContext
在 MVC 生命周期中自动提供的属性。 MVC 中的一些其他组件也有类似的东西(例如ViewContext
也会自动提供)。这些属性不是构造函数注入的,因此依赖项不是显式可见的。根据控制器的功能,在对控制器进行单元测试时,您可能还需要设置这些属性。
进行单元测试时,您正在使用HttpContext.SignInAsync(principal)
在你的控制器操作中,所以不幸的是,你正在使用HttpContext
直接地。
SignInAsync
是一种扩展方法,其中基本上会做以下事情 https://github.com/aspnet/HttpAbstractions/blob/rel/2.0.0/src/Microsoft.AspNetCore.Authentication.Abstractions/AuthenticationHttpContextExtensions.cs#L142:
context.RequestServices.GetRequiredService<IAuthenticationService>().SignInAsync(context, scheme, principal, properties);
因此,为了纯粹的方便起见,该方法将使用服务定位器模式 https://en.wikipedia.org/wiki/Service_locator_pattern从依赖项注入容器检索服务以执行登录。所以只有这一个方法调用HttpContext
将进一步拉动implicit仅当测试失败时您才会发现的依赖项。这应该作为一个很好的例子为什么应该避免服务定位器模式 http://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/:构造函数中的显式依赖项更易于管理。 – 但在这里,这是一种方便的方法,所以我们必须忍受它,并调整测试以适应这种情况。
实际上,在继续之前,我想在这里提到一个很好的替代解决方案:由于控制器是一个AuthController
我只能想象它的核心目的之一是进行身份验证,登录和注销用户等等。所以不使用实际上可能是个好主意HttpContext.SignInAsync
但相反有IAuthenticationService
as an 显式依赖控制器上,并直接调用其上的方法。这样,您就可以在测试中实现明确的依赖关系,并且不需要涉及服务定位器。
当然,这对于该控制器来说是一个特殊情况,不适用于every可能调用扩展方法HttpContext
。那么让我们来看看如何正确地测试它:
从代码中我们可以看出什么SignInAsync
实际上,我们需要提供一个IServiceProvider
for HttpContext.RequestServices
并使其能够返回IAuthenticationService
。所以我们会嘲笑这些:
var authenticationServiceMock = new Mock<IAuthenticationService>();
authenticationServiceMock
.Setup(a => a.SignInAsync(It.IsAny<HttpContext>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<AuthenticationProperties>()))
.Returns(Task.CompletedTask);
var serviceProviderMock = new Mock<IServiceProvider>();
serviceProviderMock
.Setup(s => s.GetService(typeof(IAuthenticationService)))
.Returns(authenticationServiceMock.Object);
然后,我们可以通过该服务提供者ControllerContext
创建控制器后:
var controller = new AuthController();
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
{
RequestServices = serviceProviderMock.Object
}
};
这就是我们需要做的HttpContext.SignInAsync
work.
不幸的是,还有更多的事情要做。正如我在中所解释的这个另一个答案 https://stackoverflow.com/a/48875448/216074(你已经找到了),返回一个RedirectToActionResult
当您有以下情况时,从控制器将导致问题RequestServices
在单元测试中设置。自从RequestServices
不为空,执行RedirectToAction
将尝试解决IUrlHelperFactory
,并且该结果必须是非空的。因此,我们需要稍微扩展我们的模拟以提供该模拟:
var urlHelperFactory = new Mock<IUrlHelperFactory>();
serviceProviderMock
.Setup(s => s.GetService(typeof(IUrlHelperFactory)))
.Returns(urlHelperFactory.Object);
幸运的是,我们不需要做任何其他事情,也不需要向工厂模拟添加任何逻辑。只要有就足够了。
这样,我们就可以正确测试控制器的操作:
// mock setup, as above
// …
// arrange
var controller = new AuthController(repositoryMock.Object);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
{
RequestServices = serviceProviderMock.Object
}
};
var registrationVm = new RegistrationViewModel();
// act
var result = await controller.Registration(registrationVm);
// assert
var redirectResult = result as RedirectToActionResult;
Assert.NotNull(redirectResult);
Assert.Equal("Welcome", redirectResult.ActionName);
我仍然想知道为什么 IDE 不能准确/一致地呈现底层异常。这是一个错误,还是由于 async/await 运算符和 NUnit 测试适配器/运行器造成的?
我过去在异步测试中也看到过类似的情况,我无法正确调试它们或者异常无法正确显示。我不记得在最新版本的 Visual Studio 和 xUnit 中看到过这个(我个人使用的是 xUnit,而不是 NUnit)。如果有帮助,请从命令行运行测试dotnet test
通常会正常工作,并且您将获得正确的(异步)失败堆栈跟踪。