Friday, 7 August 2015

(Yet) Another Look at Unit Testing Umbraco Surface Controllers

A quick recap

A little while back now I spent a bit of time investigating how one might go about unit testing Umbraco surface controllers. With the help of information posted by some others that had similarly looked into it I found there was a method that worked, but it wasn't ideal for a number of reasons.

Firstly there was a need to utilise an Umbraco test helpers dll, that contained implementations of various base classes that could be used to support testing. No great hardship to use that but it did mean you had to compile the source to get it and it led to some other restrictions on your tests.

One of those being you had to use NUnit. Again, a perfectly decent test runner but if you generally used MSTest or something else you had to make a switch.

And lastly in order to create various surface controller dependencies, you had to use some rather impenetrable looking reflection code to get around the fact that certain classes and methods were marked as internal rather than public. This worked, and only needed to be written once in a a base class to utilise it in all your tests, but was rather brittle. Code marked as internal is done so for a reason, one of those being that even if it's potentially useful to provide public access to developers, the core team haven't yet committed to the implementation so reserve the right to change it, even in a minor upgrade.

Putting all that together probably explains that although I did have a method that worked it wasn't something I made part of my general work with Umbraco.

The situation today

One thing I've been following and looking forward to the release of version 7.3 of Umbraco is the discussion on improved testing support. And given the recent release of this in beta, wanted to check it out.

Am pleased to report things are a lot easier now. There's still a bit of set-up and mocking code required, but that's unavoidable to ensure the surface controller has access to things like the Umbraco context in it's operations. But there are no base classes that you are required to depend on, no tie to a particular unit testing framework and no longer a requirement for reflection code.

As well as what follows, you can see some more examples of this in the Umbraco source code itself - which I used for reference. You can also take a look through the code of the Umbraco REST API which as noted in the comments below also contains several examples.

An example

I'll go back to the simple example I was looking to test in the previous blog post:

[HttpPost]
public ActionResult CreateComment(CommentViewModel model)
{
    if (!ModelState.IsValid)
    {
        return CurrentUmbracoPage();
    }

    TempData.Add("CustomMessage", "Thanks for your comment.");

    return RedirectToCurrentUmbracoPage();
}

And I have these two tests:

[TestMethod]
public void CreateComment_WithValidComment_RedirectsWithMessage()
{
    // Arrange
    var controller = GetController();
    var model = new CommentViewModel
    {
        Name = "Fred",
        Email = "fred@freddie.com",
        Comment = "Can I test this?",
    };

    // Act
    var result = controller.CreateComment(model);

    // Assert
    var redirectToUmbracoPageResult = result as RedirectToUmbracoPageResult;
    Assert.IsNotNull(redirectToUmbracoPageResult);
    Assert.AreEqual(1000, redirectToUmbracoPageResult.PublishedContent.Id);
    Assert.IsNotNull(controller.TempData["CustomMessage"]);
}

[TestMethod]
public void CreateComment_WithInValidComment_RedisplaysForm()
{
    // Arrange
    var controller = GetController();
    var model = new CommentViewModel
    {
        Name = "Fred",
        Email = string.Empty,
        Comment = "Can I test this?",
    };
    controller.ModelState.AddModelError("Email", "Email is required.");

    // Act
    var result = controller.CreateComment(model);

    // Assert
    var umbracoPageResult = result as UmbracoPageResult;
    Assert.IsNotNull(umbracoPageResult);
    Assert.IsNull(controller.TempData["CustomMessage"]);
}

To support testing there's a need to make a minor amend to the controller itself, to create a constructor that allows the passing of an instance (mocked for testing) of the Umbraco context and helper:

public class BlogPostSurfaceController : Umbraco.Web.Mvc.SurfaceController
{
    private readonly UmbracoHelper _umbracoHelper;

    public BlogPostSurfaceController(UmbracoContext umbracoContext)
        : base(umbracoContext)
    {
    }

    public BlogPostSurfaceController(UmbracoContext umbracoContext, UmbracoHelper umbracoHelper)
        : base(umbracoContext)
    {
        _umbracoHelper = umbracoHelper;
    }

    public override UmbracoHelper Umbraco
    {
        get { return _umbracoHelper ?? base.Umbraco; }
    }
}

And then in the test project I need to implement the helper method I've created above called GetController(). I've placed all this code in my test class but in practice it would make sense to move much of this into your own test base class, so it can be reused across tests.

private BlogPostSurfaceController GetController()
{
    var appCtx = MockApplicationContext();
    var umbCtx = MockUmbracoContext(appCtx);

    var controller = new BlogPostSurfaceController(umbCtx, new UmbracoHelper(umbCtx));
    SetupControllerContext(umbCtx, controller);

    return controller;
}

private static ApplicationContext MockApplicationContext()
{
    return new ApplicationContext(CacheHelper.CreateDisabledCacheHelper(),
        new ProfilingLogger(new Mock<ILogger>().Object, new Mock<IProfiler>().Object));
}

private static UmbracoContext MockUmbracoContext(ApplicationContext appCtx)
{
    return UmbracoContext.EnsureContext(
        new Mock<HttpContextBase>().Object,
        appCtx,
        new Mock<WebSecurity>(null, null).Object,
        Mock.Of<IUmbracoSettingsSection>(section => section.WebRouting == Mock.Of<IWebRoutingSection>(routingSection => routingSection.UrlProviderMode == "AutoLegacy")),
        Enumerable.Empty<IUrlProvider>(),
        true);
}

private static void SetupControllerContext(UmbracoContext umbCtx, Controller controller)
{
    var webRoutingSettings = Mock.Of<IWebRoutingSection>(section => section.UrlProviderMode == "AutoLegacy");
    var contextBase = umbCtx.HttpContext;
    var pcr = new PublishedContentRequest(new Uri("http://localhost/test"),
        umbCtx.RoutingContext,
        webRoutingSettings,
        s => Enumerable.Empty<string>())
    {
        PublishedContent = MockContent(),
    };

    var routeData = new RouteData();
    var routeDefinition = new RouteDefinition
    {
        PublishedContentRequest = pcr
    };
    routeData.DataTokens.Add("umbraco-route-def", routeDefinition);
    controller.ControllerContext = new ControllerContext(contextBase, routeData, controller);
}

private static IPublishedContent MockContent()
{
    return Mock.Of<IPublishedContent>(publishedContent => publishedContent.Id == 1000);            
}

Just for completeness, to run the code you will need to pull in the following from NuGet:

    PM> Install-Package UmbracoCms.Core -Pre
    PM> Install-Package Moq

And make sure you add framework references to System.Web, System.Configuration and System.Web.ApplicationServices.

The full list of using statements are as follows:

using System;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using SurfaceControllerUnitTests.Controllers;
using SurfaceControllerUnitTests.Models;
using Umbraco.Core;
using Umbraco.Core.Configuration.UmbracoSettings;
using Umbraco.Core.Logging;
using Umbraco.Core.Models;
using Umbraco.Core.Profiling;
using Umbraco.Web;
using Umbraco.Web.Mvc;
using Umbraco.Web.Routing;
using Umbraco.Web.Security;

Great to see this improved testing support with the latest Umbraco. Not seen too much being made of it yet but for me an important part of this upcoming Umbraco release is it now being so much more accessible to testing.

2 comments:

  1. It's worth noting that the entire rest api project is controller unit tested, so would be worth looking there for more examples

    /shannon

    ReplyDelete
  2. Thanks Shannon, I've added a link to that in the copy above.

    ReplyDelete