Saturday, 8 February 2014

Unit Testing Umbraco Surface Controllers

Unit testing Umbraco surface controllers isn't straightforward, due to issues with mocking or faking dependencies. There has been some discussion about it and there are some workarounds regarding using certain test base classes. But in general it's not an easy thing to do, at least at the moment.

Another approach (or workaround) for this is to move the thing you are trying to test outside of the controller and into another class - that itself depends only on standard ASP.Net MVC. Test that instead, leaving your controller so simple that in itself there remains little value in testing it directly.

As an example, I had this to test:

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult SignUp(NewsletterSignUpModel model)
        {
            if (ModelState.IsValid)
            {
                // Model valid so sign-up email address
                var result = _newsletterSignUpService.SignUp(model.Email, model.FirstName, model.LastName);
                TempData["SignUpResult"] = result;
                return RedirectToCurrentUmbracoPage();
            }

            return CurrentUmbracoPage();
        }

Pretty simple registration form. Maybe not much value in testing really, but as I've found these can get quite complex with a lot of logic around validation, age checks, CAPTCHAs etc. So it seems worthwhile to be able to do this.

You can see it has a dependency on a "NewsletterSignUpService" which does the actual signing up of the user (to MailChimp in this case). An instance of this service is provided to the controller via the constructor using dependency injection.

In order to test this, I modified the code to create another class that contains much of this logic - the only difference is it returns a boolean result rather than the Umbraco custom ActionResults such as RedirectToCurrentUmbracoPage:

public class NewsletterSignUpPageControllerCommandHandler : INewsletterSignUpPageControllerCommandHandler
{
    private readonly INewsletterSignUpService _newsletterSignUpService;

    public NewsletterSignUpPageControllerCommandHandler(INewsletterSignUpService newsletterSignUpService)
    {
        _newsletterSignUpService = newsletterSignUpService;
    }

    public bool HandleSignUp(NewsletterSignUpModel model, ModelStateDictionary modelState, TempDataDictionary tempData)
    {
        if (modelState.IsValid)
        {
            // Model valid so sign-up email address
            var result = _newsletterSignUpService.SignUp(model.Email, model.FirstName, model.LastName);
            tempData["SignUpResult"] = result;
            return true;
        }

        return false;
    }
}

You can see the dependency on the service is now in here, and I can then change my controller to depend instead on this new class:

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult SignUp(NewsletterSignUpModel model)
        {
            if (_newsletterSignUpCommandHandler.HandleSignUp(model, ModelState, TempData))
            {
                return RedirectToCurrentUmbracoPage();
            }

            return CurrentUmbracoPage();
        }

And having done that I can then write the tests on the command handler class (mocking the dependent service layer to return the responses I want to simulate), e.g.:

#region Newsletter sign-up tests

[TestMethod]
public void NewsletterSignUpPageCommandHandler_SignUpPostInvalid_ReturnsFalse()
{
    // Arrange
    var model = new NewsletterSignUpModel
    {
        Email = string.Empty,
    };
    var modelStateDictionary = new ModelStateDictionary();
    modelStateDictionary.AddModelError("Email", "Email is required.");
    var tempDataDictionary = new TempDataDictionary();
    var handler = new NewsletterSignUpPageControllerCommandHandler(MockNewsletterSignUpService());

    // Act
    var result = handler.HandleSignUp(model, modelStateDictionary, tempDataDictionary);

    // Assert
    Assert.IsFalse(result);
    Assert.AreEqual(0, tempDataDictionary.Keys.Count);
}

[TestMethod]
public void NewsletterSignUpPageCommandHandler_SignUpPostValid_ReturnsTrue()
{
    // Arrange
    var model = new NewsletterSignUpModel
    {
        Email = "fred@test.com",
    };
    var modelStateDictionary = new ModelStateDictionary();
    var tempDataDictionary = new TempDataDictionary();
    var handler = new NewsletterSignUpPageControllerCommandHandler(MockNewsletterSignUpService());

    // Act
    var result = handler.HandleSignUp(model, modelStateDictionary, tempDataDictionary);

    // Assert
    Assert.IsTrue(result);
    Assert.IsNotNull(tempDataDictionary["SignUpResult"]);
    Assert.AreEqual(NewsletterSignUpResult.Success, (NewsletterSignUpResult)tempDataDictionary["SignUpResult"]);
}

[TestMethod]
public void NewsletterSignUpPageCommandHandler_SignUpPostValidButAlreadySignedUp_ReturnsTrue()
{
    // Arrange
    var model = new NewsletterSignUpModel
    {
        Email = "fred2@test.com",
    };
    var modelStateDictionary = new ModelStateDictionary();
    var tempDataDictionary = new TempDataDictionary();
    var handler = new NewsletterSignUpPageControllerCommandHandler(MockNewsletterSignUpService());

    // Act
    var result = handler.HandleSignUp(model, modelStateDictionary, tempDataDictionary);

    // Assert
    Assert.IsTrue(result);
    Assert.IsNotNull(tempDataDictionary["SignUpResult"]);
    Assert.AreEqual(NewsletterSignUpResult.FailedAlreadySignedUp, (NewsletterSignUpResult)tempDataDictionary["SignUpResult"]);
}

#endregion

#region Mocks

private static INewsletterSignUpService MockNewsletterSignUpService()
{
    var mock = new Mock<INewsletterSignUpService>();
    mock.Setup(x => x.SignUp(It.IsAny<string>(), It.IsAny(), It.IsAny())).Returns(NewsletterSignUpResult.Success);
    mock.Setup(x => x.SignUp(It.Is<string>(y => y == "fred2@test.com"), It.IsAny(), It.IsAny())).Returns(NewsletterSignUpResult.FailedAlreadySignedUp);
    return mock.Object;
}

#endregion

Quite nice I think - means we can have testable code, and also follows the good design principles of having "thin" controllers with work delegated to small, defined classes.

No comments:

Post a Comment