如何对HttpContext.SignInAsync()进行单元测试?

19

SignInAsync()源代码

我在进行单元测试时遇到了一些问题。

  1. DefaultHttpContext.RequestServicesnull
  2. 我尝试创建 AuthenticationService 对象,但不知道要传递什么参数

我该怎么办?如何对 HttpContext.SignInAsync() 进行单元测试?

被测试的方法

public async Task<IActionResult> Login(LoginViewModel vm, [FromQuery]string returnUrl)
{
    if (ModelState.IsValid)
    {
        var user = await context.Users.FirstOrDefaultAsync(u => u.UserName == vm.UserName && u.Password == vm.Password);
        if (user != null)
        {
            var claims = new List<Claim>
            {
                new Claim(ClaimTypes.Name, user.UserName)
            };
            var identity = new ClaimsIdentity(claims, "HappyDog");

            // here
            await HttpContext.SignInAsync(new ClaimsPrincipal(identity));
            return Redirect(returnUrl ?? Url.Action("Index", "Goods"));
        }
    }
    return View(vm);
}

我目前为止尝试过的。

[TestMethod]
public async Task LoginTest()
{
    using (var context = new HappyDogContext(_happyDogOptions))
    {
        await context.Users.AddAsync(new User { Id = 1, UserName = "test", Password = "password", FacePicture = "FacePicture" });
        await context.SaveChangesAsync();

        var controller = new UserController(svc, null)
        {
            ControllerContext = new ControllerContext
            {
                HttpContext = new DefaultHttpContext
                {
                    // How mock RequestServices?
                    // RequestServices = new AuthenticationService()?
                }
            }
        };
        var vm = new LoginViewModel { UserName = "test", Password = "password" };
        var result = await controller.Login(vm, null) as RedirectResult;
        Assert.AreEqual("/Goods", result.Url);
    }
}
3个回答

42

HttpContext.SignInAsync 是一个使用 RequestServices 的扩展方法,而 RequestServices 则是 IServiceProvider。这就是你必须模拟的。

context.RequestServices
    .GetRequiredService<IAuthenticationService>()
    .SignInAsync(context, scheme, principal, properties);

您可以手动创建从使用的接口派生的类来创建假/模拟,或者使用像Moq这样的模拟框架。

//...code removed for brevity

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));

var serviceProviderMock = new Mock<IServiceProvider>();
serviceProviderMock
    .Setup(_ => _.GetService(typeof(IAuthenticationService)))
    .Returns(authServiceMock.Object);

var controller = new UserController(svc, null) {
    ControllerContext = new ControllerContext {
        HttpContext = new DefaultHttpContext {
            // How mock RequestServices?
            RequestServices = serviceProviderMock.Object
        }
    }
};

//...code removed for brevity

您可以在Moq的快速入门页面了解如何使用它。

您也可以像处理其他依赖项一样轻松地模拟HttpContext,但如果存在默认实现且不会导致不良行为,则使用它可以使安排变得简单得多。

例如,可以通过ServiceCollection创建一个真实的IServiceProvider来使用它。

//...code removed for brevity

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));

var services = new ServiceCollection();
services.AddSingleton<IAuthenticationService>(authServiceMock.Object);

var controller = new UserController(svc, null) {
    ControllerContext = new ControllerContext {
        HttpContext = new DefaultHttpContext {
            // How mock RequestServices?
            RequestServices = services.BuildServiceProvider();
        }
    }
};

//...code removed for brevity

这样,如果有其他依赖项,它们可以被模拟并在服务集合中注册,以便根据需要进行解决。


4
非常好的回答。 - Chris Pratt
2
我在使用这段代码时遇到了一个错误:“System.InvalidOperationException:未注册类型为'Microsoft.AspNetCore.Mvc.ViewFeatures.ITempDataDictionaryFactory'的服务。” 我通过模拟ITempDataDictionary并将其分配给controller.TempData来解决了这个问题。 - Jason Learmouth
@JasonLearmouth 这个答案针对 OP 的特定目标类型 IAuthenticationService,如果使用其他未设置模拟的类型,则会失败。当从服务调用中返回 null 时,您会收到默认错误。 - Nkosi
@Nkosi,我同意你的观点,所以我给你的答案点赞并且没有对它进行编辑。我发表了关于错误和解决方案的评论,因为仅凭错误信息很难弄清楚应该模拟什么以及在哪里分配它。 - Jason Learmouth
1
@SangeetAgarwal,看一下我在这里提供的答案:https://dev59.com/Eq7la4cB1Zd3GeqPYitH#52182813 - Nkosi
显示剩余2条评论

2

如果你们正在寻找NSubstitute的示例(Asp.net Core)。

    IAuthenticationService authenticationService = Substitute.For<IAuthenticationService>();

        authenticationService
            .SignInAsync(Arg.Any<HttpContext>(), Arg.Any<string>(), Arg.Any<ClaimsPrincipal>(),
                Arg.Any<AuthenticationProperties>()).Returns(Task.FromResult((object) null));

        var serviceProvider = Substitute.For<IServiceProvider>();
        var authSchemaProvider = Substitute.For<IAuthenticationSchemeProvider>();
        var systemClock = Substitute.For<ISystemClock>();

        authSchemaProvider.GetDefaultAuthenticateSchemeAsync().Returns(Task.FromResult
        (new AuthenticationScheme("idp", "idp", 
            typeof(IAuthenticationHandler))));

        serviceProvider.GetService(typeof(IAuthenticationService)).Returns(authenticationService);
        serviceProvider.GetService(typeof(ISystemClock)).Returns(systemClock);
        serviceProvider.GetService(typeof(IAuthenticationSchemeProvider)).Returns(authSchemaProvider);

        context.RequestServices.Returns(serviceProvider);


        // Your act goes here

        // Your assert goes here

2

在.NET Core 2.2中,这对我没用 - 它仍然期望另一个接口:ISystemClock。因此,我决定采取另一种方法,即像这样封装整个东西:

最初的回答

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;

namespace Utilities.HttpContext
{
    public interface IHttpContextWrapper
    {
        Task SignInAsync(Controller controller, string subject, string name, AuthenticationProperties props);
    }
}

然后我有一个正常使用的实现和一个测试用的实现。

Original Answer: 最初的回答

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;

namespace Utilities.HttpContext
{
    public class DefaultHttpContextWrapper : IHttpContextWrapper
    {
        public async Task SignInAsync(Controller controller, string subject, string name, AuthenticationProperties props)
        {
            await controller.HttpContext.SignInAsync(subject, name, props);
        }
    }
}

...and the fake implementation:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;

namespace Utilities.HttpContext
{
    public class FakeHttpContextWrapper : IHttpContextWrapper
    {
        public Task SignInAsync(Controller controller, string subject, string name, AuthenticationProperties props)
        {
            return Task.CompletedTask;
        }
    }
}

然后我只需在控制器的构造函数中使用.NET Core的本地DI容器(在Startup.cs中)将所需的实现注入为接口。


services.AddScoped<IHttpContextWrapper, DefaultHttpContextWrapper>();

最终,调用看起来像这样(使用我的控制器传递):
await _httpContextWrapper.SignInAsync(this, user.SubjectId, user.Username, props);

2
对我来说,在Core 2.2中,已接受的解决方案非常有效。 - Etienne Charland
我个人认为这是一个更好的方法。我不认为你应该在IdentityServer扩展方法内部模拟依赖项。如果代码发生变化,那么你的测试就会失败。创建一个HttpContext包装器更加简洁和安全。 - user3154431

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