我有一个ASP.Net Core 2.0 Web应用程序我正在使用单元测试进行改造(使用NUnit)。该应用程序运行正常,迄今为止大多数测试工作正常。
但是,测试身份验证/授权(用户是否已登录并可以访问[Authorize]
已过滤的操作)是否失败...
System.ArgumentNullException: Value cannot be null.
Parameter name: provider
... ...后
await HttpContext.SignInAsync(principal);
......但目前尚不清楚其根本原因是什么。代码执行在此处停止,并且IDE中没有显示异常,但代码执行返回给调用者,然后终止(但我仍然在VS的输出窗口中看到The program '[13704] dotnet.exe' has exited with code 0 (0x0).
。)
测试资源管理器显示红色并显示引用的异常(否则我不知道问题。)
我正在努力创建一个指向人们的复制品(到目前为止已经涉及到一点点。)
有谁知道如何查明根本原因?这是与DI相关的问题(测试中是否需要提供但是正常执行)?
UPDATE1:提供请求的身份验证码...
public async Task<IActionResult> Registration(RegistrationViewModel vm) {
if (ModelState.IsValid) {
// Create registration for user
var regData = createRegistrationData(vm);
_repository.AddUserRegistrationWithGroup(regData);
var claims = new List<Claim> {
new Claim(ClaimTypes.NameIdentifier, regData.UserId.ToString())
};
var ident = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(ident);
await HttpContext.SignInAsync(principal); // FAILS HERE
return RedirectToAction("Welcome", "App");
} else {
ModelState.AddModelError("", "Invalid registration information.");
}
return View();
}
失败的测试代码......
public async Task TestRegistration()
{
var ctx = Utils.GetInMemContext();
Utils.LoadJsonData(ctx);
var repo = new Repository(ctx);
var auth = new AuthController(repo);
auth.ControllerContext = new ControllerContext();
auth.ControllerContext.HttpContext = new DefaultHttpContext();
var vm = new RegistrationViewModel()
{
OrgName = "Dev Org",
BirthdayDay = 1,
BirthdayMonth = "January",
BirthdayYear = 1979
};
var orig = ctx.Registrations.Count();
var result = await auth.Registration(vm); // STEPS IN, THEN FAILS
var cnt = ctx.Registrations.Count();
var view = result as ViewResult;
Assert.AreEqual(0, orig);
Assert.AreEqual(1, cnt);
Assert.IsNotNull(result);
Assert.IsNotNull(view);
Assert.IsNotNull(view.Model);
Assert.IsTrue(string.IsNullOrEmpty(view.ViewName) || view.ViewName == "Welcome");
}
UPDATE3 :基于chat @nkosi suggested,这是由于我无法满足HttpContext
的依赖注入要求所带来的问题。
However,尚不清楚的是:如果它实际上是一个不提供正确服务依赖性的问题,为什么代码正常工作(未经测试时)。 SUT(控制器)只接受一个IRepository参数(这就是在任何情况下提供的所有参数。)为什么创建一个重载的ctor(或mock)只是为了测试,当现有的ctor是在运行程序时调用的所有ctor和它运行没有问题?
UPDATE4 :虽然@Nkosi用解决方案回答了错误/问题,但我仍然想知道为什么IDE没有准确/一致地呈现基础异常。这是一个错误,还是由于async / await运算符和NUnit Test Adapter / runner?为什么没有例外&#34;弹出&#34;就像我在调试测试时所期望的那样,退出代码仍为零(通常表示成功返回状态)?
答案 0 :(得分:12)
尚不清楚的是:如果它实际上是一个不提供正确服务依赖性的问题,为什么代码正常工作(未经测试时)。 SUT(控制器)只接受一个
IRepository
参数(这就是在任何情况下提供的所有参数。)为什么创建一个重载的ctor(或mock)只是为了测试,当现有的ctor是所有被调用的时候运行该程序,它运行没有问题?
您在这里混合了一些东西:首先,您不需要创建单独的构造函数。不用于测试,也不用于在应用程序中实际运行它。
您应该将控制器具有的所有直接依赖项定义为构造函数的参数,以便当它作为应用程序的一部分运行时,依赖项注入容器将向控制器提供这些依赖项。
但这也是重要的一点:在运行应用程序时,有一个依赖注入容器,负责创建对象并提供所需的依赖项。所以你实际上并不需要过多担心它们来自哪里。但是在单元测试时这是不同的。在单元测试中,我们不想使用依赖注入,因为这只会隐藏依赖关系,因此可能会产生可能与我们的测试冲突的副作用。在单元测试中依赖依赖注入是一个非常好的迹象,表明你不是单元测试,而是进行集成测试(至少除非你实际测试的是DI容器) )。
相反,在单元测试中,我们希望显式创建所有 对象,明确提供所有依赖项。这意味着我们新建控制器并传递控制器具有的所有依赖项。理想情况下,我们使用模拟,因此我们不依赖于单元测试中的外部行为。
这大部分时间都很直接。不幸的是,控制器有一些特殊之处:控制器具有在MVC生命周期中自动提供的ControllerContext
属性。 MVC中的一些其他组件具有类似的功能(例如,ViewContext
也会自动提供)。这些属性不是构造函数注入的,因此依赖项不是显式可见的。根据控制器的作用,在单元测试控制器时,您可能还需要设置这些属性。
进行单元测试后,您在控制器操作中使用HttpContext.SignInAsync(principal)
,不幸的是,您正在使用HttpContext
直接操作。
SignInAsync
是will basically do the following:
context.RequestServices.GetRequiredService<IAuthenticationService>().SignInAsync(context, scheme, principal, properties);
因此,为方便起见,此方法将使用service locator pattern从依赖项注入容器中检索服务以执行登录。因此,HttpContext
上的这一个方法调用将引入更多隐式依赖关系,您只能在测试失败时发现这些依赖关系。这应该是why you should avoid the service locator pattern的一个很好的例子:构造函数中的显式依赖关系更易于管理。 - 但是在这里,这是一种方便的方法,因此我们将不得不忍受这一点,只需调整测试即可使用。
实际上,在继续之前,我想在这里提一个好的替代解决方案:由于控制器是AuthController
我只能想象其核心目的之一是进行身份验证,将用户签入和签出和事情。因此,最好不要使用HttpContext.SignInAsync
,而是在控制器上将IAuthenticationService
作为显式依赖,并直接调用其上的方法。这样,您就可以在测试中实现明确的依赖关系,而无需参与服务定位器。
当然,这将是此控制器的特殊情况,并且不适用于 HttpContext
上每个可能的扩展方法调用。那么让我们来解决我们如何正确测试这个问题:
正如我们从代码中可以看到SignInAsync
实际上做了什么,我们需要为IServiceProvider
提供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
工作。
不幸的是,还有更多内容。正如我在this other answer(您已经找到)中所解释的那样,当您在单元测试中设置RedirectToActionResult
时,从控制器返回RequestServices
会导致问题。由于RequestServices
不为null,RedirectToAction
的实现将尝试解析IUrlHelperFactory
,并且该结果必须为非null。因此,我们需要稍微扩展我们的模拟以提供一个:
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 Test Adapter / runner?
我在异步测试中看到过类似的东西,我无法正确调试它们或者异常无法正确显示。我不记得在Visual Studio和xUnit的最新版本中看到这一点(我个人使用的是xUnit,而不是NUnit)。如果有帮助,从dotnet test
命令行运行测试通常会正常工作,您将获得正确的(异步)堆栈跟踪故障。
答案 1 :(得分:2)
这是与DI相关的问题(测试中是否需要提供但未正常执行的内容)?
是强>
您正在调用框架在运行时为您设置的功能。在隔离的单元测试期间,您需要自己设置它们。
Controller的HttpContext缺少用于解析IServiceProvider
的{{1}}。该服务实际上是IAuthenticationService
为了让......
SignInAsync
...在单元测试期间执行完成的await HttpContext.SignInAsync(principal); // FAILS HERE
操作中,您需要模拟服务提供者,以便Registration
扩展方法不会失败。
更新单元测试安排
SignInAsync
其中//...code removed for brevity
auth.ControllerContext.HttpContext = new DefaultHttpContext() {
RequestServices = createServiceProviderMock()
};
//...code removed for brevity
是用于模拟将用于填充createServiceProviderMock()
HttpContext.RequestServices
我还建议模拟public IServiceProvider createServiceProviderMock() {
var authServiceMock = new Mock<IAuthenticationService>();
authServiceMock
.Setup(_ => _.SignInAsync(It.IsAny<HttpContext>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<AuthenticationProperties>()))
.Returns(Task.FromResult((object)null)); //<-- to allow async call to continue
var serviceProviderMock = new Mock<IServiceProvider>();
serviceProviderMock
.Setup(_ => _.GetService(typeof(IAuthenticationService)))
.Returns(authServiceMock.Object);
return serviceProviderMock.Object;
}
,以便对该控制器操作进行隔离单元测试,以确保它在没有任何负面影响的情况下流向完成
答案 2 :(得分:1)
@poke提到您最好不要在单元测试中使用依赖注入并显式提供依赖(使用模拟),但是,我在集成测试中遇到了这个问题,并且我发现问题是由RequestServices
属性引起的HttpContext
在测试中没有正确初始化(因为我们在测试中没有使用实际的HttpContext),所以我像下面一样注册了HttpContextAccessor
并自己(手动)通过了所有必需的服务,问题得到解决。参见下面的代码
Services.AddSingleton<IHttpContextAccessor>(new HttpContextAccessor() { HttpContext = new DefaultHttpContext() { RequestServices = Services.BuildServiceProvider() } });
我同意这不是一个很干净的解决方案,但是请注意,我仅在测试中编写和使用了此代码,以便在您的应用程序IHttpContextAccessor
中提供所需的HttContext依赖关系(测试方法中未自动提供)。 ,HttpContext
及其所需的服务由框架自动提供。
这是我的测试基类构造函数中所有依赖项的注册方法
public class MyTestBaseClass
{
protected ServiceCollection Services { get; set; } = new ServiceCollection();
MyTestBaseClass
{
Services.AddDigiTebFrameworkServices();
Services.AddDigiTebDBContextService<DigiTebDBContext>
(Consts.MainDBConnectionName);
Services.AddDigiTebIdentityService<User, Role, DigiTebDBContext>();
Services.AddDigiTebAuthServices();
Services.AddDigiTebCoreServices();
Services.AddSingleton<IHttpContextAccessor>(new HttpContextAccessor() { HttpContext = new DefaultHttpContext() { RequestServices = Services.BuildServiceProvider() } });
}
}