Thursday 28 January 2016

Sitecore FakeDB and real world testing

One of the biggest issues in my opinion with Sitecore at present, is that it is relatively hard to unit test anything you build for it.  This is because none of the integral pieces (Item, Database, Security, etc.) have interfaces, are often sealed classes or have no parameter-less constructors, making it extremely hard to mock.  There are ways around this, such as stand-alone mocks Microsoft Fakes, but I have never found this particularly easy to implement and they don't work well ReSharper's test runners.

Obviously components that don't integrate with Sitecore directly (controllers for example) or where perhaps you can use Glass Mapper can be mocked out, to some extent.

I recently came across Sitecore FakeDB (https://marketplace.sitecore.net/Modules/Sitecore_FakeDb.aspx) and wondered if this might be the answer to my problems.

I wondered if we could test something substantial like a pipeline processor?

The code examples below assume that:
  • You are familiar with how to set up the configuration for Sitecore FakeDB (see the documentation tab on the above Sitecore Marketplace link).
  • You are familiar with xUnit (https://xunit.codeplex.com/)
  • You are familiar with mocking  and/or duck casting technologies.
  • You have a basic understanding of Sitecore.
I also used a combination of NSubstitute (http://nsubstitute.github.io/) and Moq (https://github.com/Moq/moq4), as Moq had issues setting up the GetItem<T> method found on Glass Mapper's SitecoreService class.

N.B. Moq kept reporting no such extension method - I don't believe that GetItem<T> is an extension method - but I needed to get going so I used NSubstitute instead.

Brief:

I want to write a test for a pipeline processor, without having to make too many changes to the existing code.

I have an item resolver; an HttpRequestProcessor that looks at the current URL passed via arguments, and makes a decision to redirect to another or not.

All HttpRequestProcessors are passed HttpRequestArgs in a Process method which contains contextual information such as the current URL, etc.

The code:

Here is my process method (the starting point for my pipeline processor):

public override void Process(HttpRequestArgs args)
{

   Assert.ArgumentNotNull(args, "args");
   if (Context.Item != null || Context.Database == null || Context.Database.Name.ToLowerInvariant() == "core" || args.Url.ItemPath.Length == 0)
    {
       return;
    }

   if (args.Url.FilePath.Contains("/sku/"))
    {
        GetSkuItem(args);
    }

   if (!args.Url.FilePath.Contains("/brands/")) return;
   Context.Item = GetBucketItem(args);
}

So the first issue I hit was how to construct the HttpRequestArgs, which is in itself is a sealed class (so cannot be mocked) and is constructed using a combination of both HttpRequest (not base) and HttpResponse (not base) both of which are hard to mock out.

This is what I came up with:

private HttpRequestArgs CreateHttpRequestArgs(string url)
{
  _redirect = new StringBuilder();

  var response = new HttpResponse(new StringWriter(_redirect));
  var request =
      new HttpRequestArgs(
          new System.Web.HttpContext(new HttpRequest("", url, ""),
          response), HttpRequestType.End);

  var dynMethod = request.GetType().GetMethod("Initialize", BindingFlags.NonPublic | BindingFlags.Instance);
      dynMethod.Invoke(request, null);
  return request;
}

The important part here is the small piece of reflection at the end, that calls the private Initialize method on the HttpRequestArgs object, which amongst other things populates the URL on the object itself.

The StringWriter above is passed a StringBuilder, which we will use later. 

Now my test methods can use this to construct the HttpRequestArgs and send it through to the processor.

So, lets start with something really simple, no database (anything else will require us to start using Sitecore FakeDB).

[Fact]
public void Null_Database_Item()
{
  var request = CreateHttpRequestArgs("http://myhost/");

  _bucketItemResolver.Process(request);

  Sitecore.Context.Item.Should().BeNull();
}

N.B. The _bucketItemResolver class is initialized in the constructor for this class

Now lets pass in context item, which should also fail at the first if in the above Process method where it checks for a null context item.

[Fact]
public void A_Context_Item()
{
   using (var db = new Db {new DbItem("Home") {{"Title", "Welcome!"}}})
   {

     var request = CreateHttpRequestArgs("http://myhost/");

     var homeItem = db.GetItem("/sitecore/content/home");
     Assert.Equal("Welcome!", homeItem["Title"]);

     Sitecore.Context.Item = homeItem;

     _bucketItemResolver.Process(request);

     Sitecore.Context.Item.Should().Be(homeItem);
    }
}

In the above code, we construct the Fake Db with a single item.  We set the context to be that item, and when the test is run the Process method checks for a null context and halts any further processing immediately because that condition fails.

A more complex test:

One of the URL checks I have, is one that looks for the following pattern: '/sku/'. 

The processor does the following if it finds a URL pattern match in the GetSkuItem method:
  1. Splits up the file-path to determine if there is a SKU number after the pattern.
  2. Does a product search with SKU against a custom index to find the product.
  3. Uses a link provider to build the URL to that product
  4. Redirects to the new URL
private void GetSkuItem(HttpRequestArgs args)
{
   try
   {
      var filepath = args.Url.FilePath;

      var splitPath = filepath.ToLowerInvariant().Split('/').RemoveWhere(p => p.Equals(string.Empty));
      var pathPaths = splitPath as string[] ?? splitPath.ToArray();
      if (!pathPaths.Any() || pathPaths.Count() <= 1) return;
      var productSearchString = pathPaths[1];
      string redirectUrl;
      var originalUrl = args.Url.FilePath;
      lock (RedirectionCache)
      {
          if (!RedirectionCache.TryGetValue(originalUrl, out redirectUrl))
          {
              var product = ProductsSearchIndex.GetProductBySku(productSearchString);
              if (product != null)
              {
                   redirectUrl = new ProductLinkProvider().GetItemUrl(product.GetItem(),
                     (UrlOptions) UrlOptions.DefaultOptions.Clone());

                   if (!string.IsNullOrWhiteSpace(redirectUrl))
                   //Add new url to the cache.
                   if (!RedirectionCache.ContainsKey(originalUrl))
                      RedirectionCache.Add(originalUrl, redirectUrl);
               }
           }
        }

        if (!string.IsNullOrWhiteSpace(redirectUrl)) // There is a redirection match
        {
            try
            {
                args.Context.Response.RedirectPermanent(redirectUrl, true);
            }
            catch (ThreadAbortException)
            {
               Log.Info(string.Format("Permanent Redirection being applied for {0} to {1}", originalUrl, redirectUrl),
                     this);
            }
            catch (NullReferenceException)
            {
               //Used for testing
               args.Context.Response.Write(redirectUrl);
               throw;
            }
         }
      }
      catch (ThreadAbortException)
      {

      }
      catch (Exception ex)
      {
         Log.Error(ex.Message, ex, this);
      }
} 

Here is the test that I wrote:

[Fact]
public void Its_A_Sku()
{
   var index = Substitute.For<Sitecore.ContentSearch.ISearchIndex>();
   Sitecore.ContentSearch.ContentSearchManager.SearchConfiguration.Indexes.Add("product_master_search_index", index);

   using (var db = new Db
           {
              new DbItem("Home") {{"Title", "Welcome!"}},
              new DbItem("Globals/Import/products/10074-test-toy")
                {
                  {"Name", "10074-test-toy"},
                  {"SKU", "10074"},
                  {"Product Name", "10074 Test Toy"},
                  {"Brand", "{989FA9CC-0522-4C6F-AECD-B11F6AC84CFB}"}
                }
            })
    {
        var homeItem = db.GetItem("/sitecore/content/home");
        Assert.Equal("Welcome!", homeItem["Title"]);

        var request = CreateHttpRequestArgs("http://myhost/sku/10074");

        var searchResultItem = Substitute.For<ProductSearchResultItem>();

        searchResultItem.GetItem()
            .Returns(db.GetItem("/sitecore/content/Globals/Import/products/10074-test-toy"));
        searchResultItem.SKU = "10074";
        searchResultItem.Product_Name = "10074 Test Toy";
        searchResultItem.Name = "10074-test-toy";

        var mockglassProduct = new Moq.Mock<GlassProduct>();

        mockglassProduct.Setup(p => p.TemplateId).Returns(IImported_ProductConstants.TemplateId.Guid);
        mockglassProduct.Setup(p => p.Product_Name).Returns(searchResultItem.Product_Name);
        mockglassProduct.Setup(p => p.Name).Returns(searchResultItem.Name);
        mockglassProduct.Setup(p => p.Brand).Returns(new Imported_Brand{Name = "Test Brand"});

        var mockSitecoreService = Substitute.For<ISitecoreService>();

        mockSitecoreService.GetItem<GlassProduct>(Arg.Any<Guid>())
                           .Returns(mockglassProduct.Object);


        ProductLinkProvider.SitecoreService = mockSitecoreService;

        index.CreateSearchContext()
            .GetQueryable<ProductSearchResultItem>()
            .Returns((new[] {searchResultItem}).AsQueryable());

        _bucketItemResolver.Process(request);

        Sitecore.Context.Item.Should().BeNull();

        _redirect.ToString().Should().Be(string.Format("/brands/{0}/{1}", mockglassProduct.Object.Brand.Name, mockglassProduct.Object.Name));
     }
}

  • I recreate the search index using Sitecore FakeDB.
  • Set up the in-memory database with two items.
  • Mock up the Glass Mapper SitecoreService to return the expected search result item.
  • Set the index to return the correct item from the in-memory DB (make sure the values are correct as linQ to Sitecore will be performed against the item).
  • Execute the processor.
  • And finally check the StringBuilder to see if it has the redirected URL in it.

 I had to make a couple of changes to the existing code to get this test to work. 

  1. I had to add the NullReferenceException catch as there was no way to get the HttpResponse which is used to do the redirect to delegate and do something else instead.  If the processor arguments used HttpResponseBase I would have been able to mock out a delegate method to call. 

    Its possible that the redirect might be able to be done with some other Sitecore Utility methods, but as these would undoubtedly be static, again it would be hard to mock them out.

    I took the opportunity here to populate the StringBuilder with a value - not the neatest but at least I can test a result.

  2. In the ProductLinkProvider class we use the SitecoreService.  Unfortunately this is resolved from DI using Kernel.Resolve (found on a static helper class), so I had to add a property to the class, which I could use in testing, to set up my mocked version of the service and have the code check for a valid service in that property. 

    N.B. If I had had time, I may have been able to rewrite the provider so that the SitecoreService was injected in properly.
Conclusions

All in all I am very happy with Sitecore FakeDb and will be using it to write more comprehensive tests in the future.  I give it two thumbs up!

If in the future Sitecore can make some minor changes with testing in mind, then that would be the icing on the cake.

No comments:

Post a Comment