Unit Testing ASP.NET MVC5 App

2019-01-30 07:12发布

问题:

I'm extending the ApplicationUser class by adding a new property (as shown in the tutorial 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; }
}

Now I want to create a Unit Test to verify that my AccountController is correctly saving the BirthDate.

I've created an in-memory user store named 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);
}

The controller.Register method is boilerplate code generated by MVC5 but for reference purposes I'm including it here.

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

When I call Register, it calls SignInAsync which is where the trouble will occur.

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

At the lowest layer, the boilerplate code includes this tidbit

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

This is where the root of the problm occurs. This call to GetOwinContext is an extension method which I cannot mock and I cannot replace with a stub (unless of course I change the boilerplate code).

When I run this test I get an exception

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

In prior releases the ASP.NET MVC team worked very hard to make the code testable. It seems on the surface that now testing an AccountController is not going to be easy. I have some choices.

I can

  1. Modify the boiler plate code so that it doesn't call an extension method and deal with this problem at that level

  2. Setup the OWin pipeline for testing purposes

  3. Avoid writing testing code that requires the AuthN / AuthZ infrastructure (not a reasonable option)

I'm not sure which road is better. Either one could solve this. My question boils down to which is the best strategy.

Note: Yes, I know that I don't need to test code that I didn't write. The UserManager infrastructure provided MVC5 is such a piece of infrastructure BUT if I want to write tests that verify my modifications to ApplicationUser or code that verifies behavior that depends upon user roles then I must test using UserManager.

回答1:

I'm answering my own question so I can get a sense from the community if you think this is a good answer.

Step 1: Modify the generated AccountController to provide a property setter for the AuthenticationManager using a backing field.

// 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; }
}

Step 2: Modify the unit test to add a mock for the Microsoft.OWin.IAuthenticationManager interface

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

Now the test passes.

Good idea? Bad idea?



回答2:

My needs are similar, but I realized that I don't want a pure unit test of my AccountController. Rather I want to test it in an environment that is as close as possible to its natural habitat (integration test, if you want). So I don't want to mock the surrounding objects, but use the real ones, with as little of my own code as I can get away with.

The HttpContextBaseExtensions.GetOwinContext method also got in my way, so I was very happy with Blisco's hint. Now the most important part of my solution looks like this:

/// <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;
}

I have not yet succeeded to get everything working (in particular, I could not get UrlHelper to produce a proper URL in the ForgotPassword method), but most of my needs are covered now.



回答3:

I've used a solution similar to yours - mocking an IAuthenticationManager - but my login code is in a LoginManager class that takes the IAuthenticationManager via constructor injection.

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

I'm using Unity to register my dependencies:

    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...
    }

However, I'd like to test my Unity registrations, and this has proved tricky without faking (a) HttpContext.Current (hard enough) and (b) GetOwinContext() - which, as you've found, is impossible to do directly.

I've found a solution in the form of Phil Haack's HttpSimulator and some manipulation of the HttpContext to create a basic Owin environment. So far I've found that setting a single dummy Owin variable is enough to make GetOwinContext() work, but 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);
    }
}

HttpSimulator may be overkill if your SetFakeControllerContext() method does the job, but it looks like a useful tool for integration testing.