27

我正在通过添加一个新属性来扩展 ApplicationUser 类(如教程 Create an ASP.NET MVC 5 App with Facebook and Google OAuth2 and OpenID Sign-on (C#) 中所示)

public class ApplicationUser : IdentityUser
{
    public DateTime BirthDate { get; set; }
}

现在我想创建一个单元测试来验证我的 AccountController 是否正确保存了 BirthDate。

我创建了一个名为 TestUserStore 的内存用户存储

[TestMethod]
public void Register()
{
    // Arrange
    var userManager = new UserManager<ApplicationUser>(new TestUserStore<ApplicationUser>());
    var controller = new AccountController(userManager);

    // This will setup a fake HttpContext using Moq
    controller.SetFakeControllerContext();

    // Act
    var result =
        controller.Register(new RegisterViewModel
        {
            BirthDate = TestBirthDate,
            UserName = TestUser,
            Password = TestUserPassword,
            ConfirmPassword = TestUserPassword
        }).Result;

    // Assert
    Assert.IsNotNull(result);

    var addedUser = userManager.FindByName(TestUser);
    Assert.IsNotNull(addedUser);
    Assert.AreEqual(TestBirthDate, addedUser.BirthDate);
}

controller.Register 方法是 MVC5 生成的样板代码,但出于参考目的,我将其包含在此处。

// POST: /Account/Register
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Register(RegisterViewModel model)
{
    if (ModelState.IsValid)
    {
        var user = new ApplicationUser() { UserName = model.UserName, BirthDate = model.BirthDate };
        var result = await UserManager.CreateAsync(user, model.Password);
        if (result.Succeeded)
        {
            await SignInAsync(user, isPersistent: false);
            return RedirectToAction("Index", "Home");
        }
        else
        {
            AddErrors(result);
        }
    }

    // If we got this far, something failed, redisplay form
    return View(model);
}

当我调用 Register 时,它会调用 SignInAsync,这就是问题所在。

private async Task SignInAsync(ApplicationUser user, bool isPersistent)
{
    AuthenticationManager.SignOut(DefaultAuthenticationTypes.ExternalCookie);
    var identity = await UserManager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie);
    AuthenticationManager.SignIn(new AuthenticationProperties() { IsPersistent = isPersistent }, identity);
}

在最低层,样板代码包括这个花絮

private IAuthenticationManager AuthenticationManager
{
    get
    {
        return HttpContext.GetOwinContext().Authentication;
    }
}

这就是问题的根源所在。对 GetOwinContext 的调用是一种扩展方法,我无法模拟,也无法用存根替换(当然,除非我更改了样板代码)。

当我运行这个测试时,我得到了一个异常

Test method MVCLabMigration.Tests.Controllers.AccountControllerTest.Register threw exception: 
System.AggregateException: One or more errors occurred. ---> System.NullReferenceException: Object reference not set to an instance of an object.
at System.Web.HttpContextBaseExtensions.GetOwinEnvironment(HttpContextBase context)
at System.Web.HttpContextBaseExtensions.GetOwinContext(HttpContextBase context)
at MVCLabMigration.Controllers.AccountController.get_AuthenticationManager() in AccountController.cs: line 330
at MVCLabMigration.Controllers.AccountController.<SignInAsync>d__40.MoveNext() in AccountController.cs: line 336

在以前的版本中,ASP.NET MVC 团队非常努力地使代码可测试。从表面上看,现在测试 AccountController 似乎并不容易。我有一些选择。

我可以

  1. 修改样板代码,使其不调用扩展方法并在该级别处理此问题

  2. 设置 OWin 管道以进行测试

  3. 避免编写需要 AuthN / AuthZ 基础设施的测试代码(不是一个合理的选择)

我不确定哪条路更好。任何一个都可以解决这个问题。我的问题归结为最佳策略。

注意:是的,我知道我不需要测试不是我写的代码。提供 MVC5 的 UserManager 基础设施就是这样一个基础设施,但是如果我想编写测试来验证我对 ApplicationUser 的修改或验证取决于用户角色的行为的代码,那么我必须使用 UserManager 进行测试。

4

3 回答 3

27

我正在回答我自己的问题,所以如果您认为这是一个很好的答案,我可以从社区中了解一下。

第 1 步:修改生成的 AccountController 以使用支持字段为 AuthenticationManager 提供属性设置器。

// Add this private variable
private IAuthenticationManager _authnManager;

// Modified this from private to public and add the setter
public IAuthenticationManager AuthenticationManager
{
    get
    {
        if (_authnManager == null)
            _authnManager = HttpContext.GetOwinContext().Authentication;
        return _authnManager;
    }
    set { _authnManager = value; }
}

第二步: 修改单元测试,为 Microsoft.OWin.IAuthenticationManager 接口添加一个 mock

[TestMethod]
public void Register()
{
    // Arrange
    var userManager = new UserManager<ApplicationUser>(new TestUserStore<ApplicationUser>());
    var controller = new AccountController(userManager);
    controller.SetFakeControllerContext();

    // Modify the test to setup a mock IAuthenticationManager
    var mockAuthenticationManager = new Mock<IAuthenticationManager>();
    mockAuthenticationManager.Setup(am => am.SignOut());
    mockAuthenticationManager.Setup(am => am.SignIn());

    // Add it to the controller - this is why you have to make a public setter
    controller.AuthenticationManager = mockAuthenticationManager.Object;

    // Act
    var result =
        controller.Register(new RegisterViewModel
        {
            BirthDate = TestBirthDate,
            UserName = TestUser,
            Password = TestUserPassword,
            ConfirmPassword = TestUserPassword
        }).Result;

    // Assert
    Assert.IsNotNull(result);

    var addedUser = userManager.FindByName(TestUser);
    Assert.IsNotNull(addedUser);
    Assert.AreEqual(TestBirthDate, addedUser.BirthDate);
}

现在测试通过了。

好主意?馊主意?

于 2013-11-01T17:17:55.310 回答
5

我的需求是相似的,但我意识到我不想对我的 AccountController 进行纯粹的单元测试。相反,我想在尽可能接近其自然栖息地的环境中对其进行测试(如果需要,可以进行集成测试)。所以我不想模拟周围的物体,而是使用真实的物体,尽可能少地使用我自己的代码。

HttpContextBaseExtensions.GetOwinContext 方法也妨碍了我,所以我对 Blisco 的提示非常满意。现在我的解决方案中最重要的部分如下所示:

/// <summary> Set up an account controller with just enough context to work through the tests. </summary>
/// <param name="userManager"> The user manager to be used </param>
/// <returns>A new account controller</returns>
private static AccountController SetupAccountController(ApplicationUserManager userManager)
{
    AccountController controller = new AccountController(userManager);
    Uri url = new Uri("https://localhost/Account/ForgotPassword"); // the real string appears to be irrelevant
    RouteData routeData = new RouteData();

    HttpRequest httpRequest = new HttpRequest("", url.AbsoluteUri, "");
    HttpResponse httpResponse = new HttpResponse(null);
    HttpContext httpContext = new HttpContext(httpRequest, httpResponse);
    Dictionary<string, object> owinEnvironment = new Dictionary<string, object>()
    {
        {"owin.RequestBody", null}
    };
    httpContext.Items.Add("owin.Environment", owinEnvironment);
    HttpContextWrapper contextWrapper = new HttpContextWrapper(httpContext);

    ControllerContext controllerContext = new ControllerContext(contextWrapper, routeData, controller);
    controller.ControllerContext = controllerContext;
    controller.Url = new UrlHelper(new RequestContext(contextWrapper, routeData));
    // We have not found out how to set up this UrlHelper so that we get a real callbackUrl in AccountController.ForgotPassword.

    return controller;
}

我还没有成功地让一切正常工作(特别是,我无法让 UrlHelper 在 ForgotPassword 方法中生成正确的 URL),但现在我的大部分需求都已得到满足。

于 2015-07-27T07:34:21.603 回答
3

我使用了与您类似的解决方案 - 模拟 IAuthenticationManager - 但我的登录代码位于 LoginManager 类中,该类通过构造函数注入获取 IAuthenticationManager。

    public LoginHandler(HttpContextBase httpContext, IAuthenticationManager authManager)
    {
        _httpContext = httpContext;
        _authManager = authManager;
    }

我正在使用Unity注册我的依赖项:

    public static void RegisterTypes(IUnityContainer container)
    {
        container.RegisterType<HttpContextBase>(
            new InjectionFactory(_ => new HttpContextWrapper(HttpContext.Current)));
        container.RegisterType<IOwinContext>(new InjectionFactory(c => c.Resolve<HttpContextBase>().GetOwinContext()));
        container.RegisterType<IAuthenticationManager>(
            new InjectionFactory(c => c.Resolve<IOwinContext>().Authentication));
        container.RegisterType<ILoginHandler, LoginHandler>();
        // Further registrations here...
    }

但是,我想测试我的 Unity 注册,事实证明,如果不伪造(a)HttpContext.Current(足够难)和(b)GetOwinContext(),这很棘手 - 正如您所发现的那样,这是不可能直接做到的.

我找到了 Phil Haack 的HttpSimulator形式的解决方案,并对 HttpContext 进行一些操作以创建基本的Owin 环境。到目前为止,我发现设置一个虚拟 Owin 变量足以使 GetOwinContext() 工作,但是 YMMV。

public static class HttpSimulatorExtensions
{
    public static void SimulateRequestAndOwinContext(this HttpSimulator simulator)
    {
        simulator.SimulateRequest();
        Dictionary<string, object> owinEnvironment = new Dictionary<string, object>()
            {
                {"owin.RequestBody", null}
            };
        HttpContext.Current.Items.Add("owin.Environment", owinEnvironment);
    }        
}

[TestClass]
public class UnityConfigTests
{
    [TestMethod]
    public void RegisterTypes_RegistersAllDependenciesOfHomeController()
    {
        IUnityContainer container = UnityConfig.GetConfiguredContainer();
        HomeController controller;

        using (HttpSimulator simulator = new HttpSimulator())
        {
            simulator.SimulateRequestAndOwinContext();
            controller = container.Resolve<HomeController>();
        }

        Assert.IsNotNull(controller);
    }
}

如果您的 SetFakeControllerContext() 方法完成了这项工作,HttpSimulator 可能有点过头了,但它看起来像是一个用于集成测试的有用工具。

于 2014-06-26T15:40:45.903 回答