The MVP challenge

Last week we had an MVP meetup in London for the UK MVP's where we discussed how we normally test our Sitecore solutions. We discussed several different methods of unit testing and integration testing. The last task was to take an existing piece of pipeline code and try to create some tests for it.

This pipeline task was a 404 handler in the HttpBeginRequest pipeline. These aren't normally easy classes to test because they have a parameterless constructor which means we can't use DI and they also work heavily with services that can't be mocked, e.g. Sitecore Database and the HttpContext.

The Challenge

So the challenge was how can we alter some existing code to make it more testable? Unfortunately I don't have the original code we used at the MVP meetup so instead I will be using code written by Jan Hebnes as a base with a couple of changes. My starting  code for the basic pipeline looks like this:
    public class ItemNotFoundResolver : Sitecore.Pipelines.HttpRequest.HttpRequestProcessor
    {
        private static string _cached404 = null;

        public override void Process(Sitecore.Pipelines.HttpRequest.HttpRequestArgs args)
        {
            Assert.ArgumentNotNull(args, "args");

            if (Sitecore.Context.Item != null)
            {
                // Fall through to default handler
                return;
            }

            SiteContext site = Context.Site;
            if (((site != null) && !SiteManager.CanEnter(site.Name, Context.User)))
            {
                // Fall through to default handler
                return;
            }

            PageContext page = Context.Page;
            Assert.IsNotNull(page, "No page context in processor.");
            string filePath = page.FilePath;
            if (filePath.Length > 0)
            {
                if (WebUtil.IsExternalUrl(filePath))
                {
                    args.Context.Response.Redirect(filePath, true);
                }
                else
                {
                    args.Context.RewritePath(filePath, args.Context.Request.PathInfo, args.Url.QueryString, false);
                }
            }
            else if (Context.Item == null)
            {
                this.HandleItemNotFound(args);
            }
        }

        public void HandleItemNotFound(HttpRequestArgs args)
        {
            if (args.PermissionDenied)
            {
                // Fall through to default handler
                return;
            }

            if (Context.Database == null)
            {
                // Fall through to default handler
                return;
            }

            // Find context site root
            var root = args.GetItem(Context.Site.StartPath);
            if (root == null)
            {
                // Fall through to default handler
                return;
            }

            // The 404 item is specified on the root item of the site
            var field404 = new MultilistField(root.Fields["404"]);

            var pageNotFound = field404.GetItems().FirstOrDefault();
            if (pageNotFound != null)
            {
                Context.Item = pageNotFound;
            }
            else
            {
                if (_cached404 == null)
                {
                    //we use the standard item not found url for a static file
                    var url = Settings.ItemNotFoundUrl;

                    try
                    {
                        var client = new System.Net.WebClient();
                        _cached404 = client.DownloadString(url);
                    }
                    catch
                    {
                        _cached404 = null;
                    }
                }
                else
                {
                    args.Context.Response.Clear();
                    args.Context.Response.Write(_cached404);
                    args.Context.Response.StatusCode = (int)HttpStatusCode.NotFound;
                    args.Context.Response.TrySkipIisCustomErrors = true;
                    args.Context.Response.End();

                    args.AbortPipeline();
                }
            }
        }
    }

I normally find that it is easiest to start writing tests for existing code by looking at the methods that are deepest in the call stack and then start working back up. These methods normally have the fewest dependencies and allows you to get familiar with what the code base is doing. Therefore I am going to start by looking at the HandleItemNotFound method to see what parts of this method are going to make it difficult to write tests.

Wrapping the Args

The first road block is the HttpRequestArgs parameters that are passed into the method, we this problem we have only really two options:

  1. Add any required information as parameters to method signature.
  2. Create a wrapping class that implements and interface and pass this into the method.

The first option is the simplest, however this creates two problem because our code requires method calls which aren't easy to pass in and we also will need to deal with the large number of calls to the Response object. Therefore I am going to go with option number 2. First I create the basic wrapping class and interface:

    public interface IHttpRequestArgsWrapper
    {
        
    }

    public class HttpRequestArgsWrapper : IHttpRequestArgsWrapper
    {
        
    }

I can then update the HandleItemNotFound method to take this:

        public void HandleItemNotFound(IHttpRequestArgsWrapper args)
        {

Now I need to add the properties and methods that the method required to my interface:

    public interface IHttpRequestArgsWrapper
    {
        bool PermissionDenied { get; }
        Item GetItem(string path);
        void Write404Response(string content);
        void AbortPipeline();
    }

Notice that my interface has only one method for writing the 404 response to the Response object. It is easier to abstract away the entire code block with a single method rather than deal with each method call one at a time. My concrete implementation looks like this:

    public class HttpRequestArgsWrapper : IHttpRequestArgsWrapper
    {
        private readonly HttpRequestArgs _args;

        public HttpRequestArgsWrapper(HttpRequestArgs args)
        {
            _args = args;
        }

        public bool PermissionDenied
        {
            get { return _args.PermissionDenied; }
        }

        public Item GetItem(string path)
        {
           return _args.GetItem(path);
        }

        public void Write404Response(string content)
        {
            _args.Context.Response.Clear();
            _args.Context.Response.Write(content);
            _args.Context.Response.StatusCode = (int)HttpStatusCode.NotFound;
            _args.Context.Response.TrySkipIisCustomErrors = true;
            _args.Context.Response.End();
        }

        public void AbortPipeline()
        {
            _args.AbortPipeline();
        }
    }

And my updated HandleItemNotFound:

        public void HandleItemNotFound(IHttpRequestArgsWrapper args)
        {
            if (args.PermissionDenied)
            {
                // Fall through to default handler
                return;
            }

            if (Context.Database == null)
            {
                // Fall through to default handler
                return;
            }

            // Find context site root
            var root = args.GetItem(Context.Site.StartPath);
            if (root == null)
            {
                // Fall through to default handler
                return;
            }

            // The 404 item is specified on the root item of the site
            var field404 = new MultilistField(root.Fields["404"]);

            var pageNotFound = field404.GetItems().FirstOrDefault();
            if (pageNotFound != null)
            {
                Context.Item = pageNotFound;
            }
            else
            {
                if (_cached404 == null)
                {
                    //we use the standard item not found url for a static file
                    var url = Settings.ItemNotFoundUrl;

                    try
                    {
                        var client = new System.Net.WebClient();
                        _cached404 = client.DownloadString(url);
                    }
                    catch
                    {
                        _cached404 = null;
                    }
                }
                else
                {
                    args.Write404Response(_cached404);
                    args.AbortPipeline();
                }
            }
        }
    }

Poor Mans DI

I am still left with the problem of the WebClient call which downloads my static content. I need to again abstract this away using an interface and concrete class which look like this:

    public interface IWebClientWrapper
    {
        string DownloadString(string url );
    }
    public class WebClientWrapper : IWebClientWrapper
    {
        public string DownloadString(string url)
        {
            var client = new System.Net.WebClient();
            return client.DownloadString(url);
        }
    }

So what is the best way of injecting this into my method? I don't think this should be passed in as part of the method call because it isn't data that has come from the calling method therefore I want to define it as a class property. However we can't use DI to inject it, the simplest solution is to use Poor Mans DI and have two constructors:

        public ItemNotFoundResolver():this(new WebClientWrapper())
        {
            
        }

        public ItemNotFoundResolver(IWebClientWrapper webClientWrapper)
        {
            _webClientWrapper = webClientWrapper;
        }

This works because it allows my unit tests to inject a mock object using the second constructor but Sitecore will instantiate the object using the first constructor. We also need to use the same trick to abstract away the request to the Settings.ItemNotFoundUrl and the Context.Site.StartPath.

Friendly Items

The final large hurdle are the calls to get the Sitecore Items:

            var root = args.GetItem(_settingsWrapper.SiteStartPath);
            if (root == null)
            {
                // Fall through to default handler
                return;
            }
            
            // The 404 item is specified on the root item of the site
            var field404 = new MultilistField(root.Fields["404"]);

            var pageNotFound = field404.GetItems().FirstOrDefault();
            if (pageNotFound != null)
            {
                Context.Item = pageNotFound;
            }

To remove these calls I am going to use Glass, my models will look like this:

    public class Root
    {
        [SitecoreField("404")]
        public virtual Page Item404 { get; set; }
    }

    public class Page
    {
        [SitecoreItem]
        public virtual Item Item { get; set; }
    }

The final code for the ItemNotFoundResolver class looks like this:

        public class ItemNotFoundResolver : Sitecore.Pipelines.HttpRequest.HttpRequestProcessor
    {
        private static string _cached404 = null;
        private readonly IWebClientWrapper _webClientWrapper;
        private readonly ISettingsWrapper _settingsWrapper;
        private readonly ISitecoreContext _context;

        public ItemNotFoundResolver():this(new WebClientWrapper(), new SettingsWrapper(), new SitecoreContext())
        {
            
        }

        public ItemNotFoundResolver(IWebClientWrapper webClientWrapper, ISettingsWrapper settingsWrapper, ISitecoreContext context)
        {
            _webClientWrapper = webClientWrapper;
            _settingsWrapper = settingsWrapper;
            _context = context;
        }

        public override void Process(Sitecore.Pipelines.HttpRequest.HttpRequestArgs args)
        {
            Assert.ArgumentNotNull(args, "args");

            if (Sitecore.Context.Item != null)
            {
                // Fall through to default handler
                return;
            }

            SiteContext site = Context.Site;
            if (((site != null) && !SiteManager.CanEnter(site.Name, Context.User)))
            {
                // Fall through to default handler
                return;
            }

            PageContext page = Context.Page;
            Assert.IsNotNull(page, "No page context in processor.");
            string filePath = page.FilePath;
            if (filePath.Length > 0)
            {
                if (WebUtil.IsExternalUrl(filePath))
                {
                    args.Context.Response.Redirect(filePath, true);
                }
                else
                {
                    args.Context.RewritePath(filePath, args.Context.Request.PathInfo, args.Url.QueryString, false);
                }
            }
            else if (Context.Item == null)
            {
                this.HandleItemNotFound(new HttpRequestArgsWrapper(args));
            }
        }

        public void HandleItemNotFound(IHttpRequestArgsWrapper args)
        {
            if (args.PermissionDenied)
            {
                // Fall through to default handler
                return;
            }

            if (!_settingsWrapper.DatabaseExists)
            {
                // Fall through to default handler
                return;
            }

            // Find context site root
            var root = _context.GetItem(_settingsWrapper.SiteStartPath);
            if (root == null)
            {
                // Fall through to default handler
                return;
            }
            
            // The 404 item is specified on the root item of the site
            if (root.Item404 != null)
            {
                _settingsWrapper.SetContextItem(root.Item404);
            }
            else
            {
                if (_cached404 == null)
                {
                    //we use the standard item not found url for a static file
                    var url = _settingsWrapper.ItemNotFoundUrl;

                    try
                    {
                        _cached404 = _webClientWrapper.DownloadString(url);
                    }
                    catch
                    {
                        _cached404 = null;
                    }
                }
                else
                {
                    args.Write404Response(_cached404);
                    args.AbortPipeline();
                }
            }
        }
    }

The Test

With all these changes made we can now write our first unit test to see if our code does what we expect it to:

        [Test]
        public void HandleItemNotFound_Permission_Denied()
        {
            //Assert
            var client = Substitute.For<IWebClientWrapper>();
            var settings = Substitute.For<ISettingsWrapper>();
            var context = Substitute.For<ISitecoreContext>();
            var args = Substitute.For<IHttpRequestArgsWrapper>();

            settings.DatabaseExists.Returns(true);

            var root = new Root();
            root.Item404 = new Page();

            settings.SiteStartPath.Returns("/sitecore/content/test");
            context.GetItem<Root>("/sitecore/content/test").Returns(root);

            var resolver = new ItemNotFoundResolver(client, settings, context);
            
            //Act
            resolver.HandleItemNotFound(args);

            //Assert
            settings.Received(1).SetContextItem(root.Item404);
        }

With our abstractions we are able to mock all the interactions that our code would normally do and create a full set of unit tests for this pipeline process. I would not go away and write a full set of tests that looks at all the different routes through the code and verifies them all.

Conclusion

In this post I have managed to make the HandleItemNotFound method testable using a few simple guidelines:
  • Start with a very small method that doesn't call any other methods in your code and then work back up the call stack.
  • When you create your abstractions try to think about how you might be able to reuse the abstraction in other classes.
  • Create abstractions that have related functions and not just one large abstraction class, i.e. an abstraction for WebClient, Settings, Context.
  • The methods in your abstractions should be as small as possible, do not put any business logic in your abstractions because you won't be able to test it.
  • Use Poor Mans DI in situations where you can't use a DI container.

So we can see here how we can use make event the most difficult of classes testable it just involves a little effort. If you have any comments please leave them below. You can also signup to receive updates about Glass using the signup form here or if you like us to help you learn more about unit testing in Sitecore then contact our consultancy at hello@glass.lu.

comments powered by Disqus