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.
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.
The first road block is the HttpRequestArgs parameters that are passed into the method, we this problem we have only really two options:
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(); } } } }
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.
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(); } } } }
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.
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.
Glass.Mappper.Sc is supported by the generous donations of the Glass.Mapper.Sc community! Their donations help fund the time, effort and hosting for the project.
These supporters are AMAZING and a huge thank you to all of you. You can find our list of supporters on the Rockstars page.
If you use Glass.Mapper.Sc and find it useful please consider supporting the project. Not only will you help the project but you could also get a discount on the Glass.Mapper.Sc training and join the Rockstars page.