Ethan Schofer
Feb 14, 2024
  1180
(2 votes)

Unit Testing Optimizely - How to deal with the ServiceLocator

Unit testing with Optimizely can be challenging. One of those challenges is Optimizely's use of the ServiceLocator pattern. ServiceLocator is generally considered to be an anti-pattern (https://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/). One of the reasons for this is the difficulty it causes with unit tests. We, as developers can avoid this pattern, but it is still baked into a lot of Optimizely's code. So, testing code that relies on Optimizely classes can run into a problem because the ServiceLocator used in those Optimizely classes doesnt have anything registered in the context of a unit test. Here is a way around this problem.

For this blog post, Im using Xunit for tests, and NSubstitute for mocking things. The method described below should conceptually work with any testing and mocking framework, but the syntax may vary.

Scope

Xunit, at the time of this post, does not have a built in way to run some code once prior to all tests. Luckily, someone named Daniel Cazzulino has created a NuGet package that will allow us to do exactly this: https://www.cazzulino.com/assembly-fixtures.html. Xunit calls classes that maintain things accross tests in a single test a 'fixture'. This package allows for the creation of an assembly fixture, a fixture that maintains a lifecycle accross all test. You can use it by having your test class implement IAssemblyFixture, a generic interface that requires an actual fixture class.

The Fixture

For this case, we need an fixture that mimics the functionalty of the service locator.

using EPiServer;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using EPiServer.Web.Templating;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;

namespace MyProject.Tests.Infrastructure;

/// <summary>
/// Mock service locator for tests
/// </summary>
public class ServiceLocatorFixture : IDisposable
{
    /// <summary>
    /// Add items to DI on constructor
    /// </summary>
    public ServiceLocatorFixture()
    {
        this.Providing<IContentLoader>();        
        this.Providing<ICompositeViewEngine>();
        this.Providing<IModelTemplateTagResolver>();
        this.Providing<IHtmlHelper>();
        this.Providing<IContextModeResolver>();        
    }

    /// <summary>
    /// Encapsulated service provider
    /// </summary>
    private IServiceProvider _serviceProvider;

    /// <summary>
    /// Public accessor for service locator
    /// </summary>
    public IServiceProvider ServiceProvider
    {
        get
        {
            // If it already exists, just return it            
            if (this._serviceProvider != null)
            {
                return this._serviceProvider;
            }
            // Create new service provider
            this._serviceProvider = Substitute.For<IServiceProvider>();
            ServiceLocator.SetServiceProvider(this._serviceProvider);
            return this._serviceProvider;
        }
    }

    /// <summary>
    /// Add item to service locator
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <returns></returns>
    public T Providing<T>() where T : class
    {
        var obj = Substitute.For<T>();
        this.ServiceProvider.GetService<T>().Returns(obj);
        this.ServiceProvider.GetRequiredService<T>().Returns(obj);
        return obj;
    }

    /// <summary>
    /// Add item to service locator
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="instance"></param>
    /// <returns></returns>
    public T Providing<T>(T instance) where T : class
    {
        this.ServiceProvider.GetService<T>().Returns(instance);
        this.ServiceProvider.GetRequiredService<T>().Returns(instance);
        return instance;
    }    

    public void Dispose()
    {
        this.Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    { }
}

Reviewing whats going on here:

This fixture has a property of IServiceProvider. This is what underlies the ServiceLocator. The first time it is called, it checks if the underlying field _serviceProvider has value. If not, it creates a substitute for the service provider, and then assigns it to the ServiceLocator:

public IServiceProvider ServiceProvider
{
	get
	{
		// If it already exists, just return it            
		if (this._serviceProvider != null)
		{
			return this._serviceProvider;
		}
		// Create new service provider
		this._serviceProvider = Substitute.For<IServiceProvider>();
		ServiceLocator.SetServiceProvider(this._serviceProvider);
		return this._serviceProvider;
	}
}

This fixture offers two ways to add things to the service provider with the methods 'Providing'. The first method just takes a type as generic attribute:

public T Providing<T>() where T : class
{
	var obj = Substitute.For<T>();
	this.ServiceProvider.GetService<T>().Returns(obj);
	this.ServiceProvider.GetRequiredService<T>().Returns(obj);
	return obj;
}

Notice that it implements GetService and GetRequiredService. Items can be registered simply:

this.Providing<IContentLoader>(); 

This specific content loader will be available in any test class implementing this fixture.

The other version of providing allows you to register a specific implementation of a class or substitute:

public T Providing<T>(T instance) where T : class
{
	this.ServiceProvider.GetService<T>().Returns(instance);
	this.ServiceProvider.GetRequiredService<T>().Returns(instance);
	return instance;
}   

Notice that this also implements GetService and GetRequiredService. Also notice that using NSubstitute, we can make this return a specific implementation. 

var contentTypeRepository = Substitute.For<IContentTypeRepository>();
contentTypeRepository.List().Returns(new List<ContentType> { testBlockType, new() { Name = "TestPage", ID = 2 } });
contentTypeRepository.Load(1).Returns(testBlockType);
this.Providing(contentTypeRepository);

So using the ServiceLocator to get the content type repository will return this specific implementation. Lastly, in the constructor we register all the things we might need from the ServiceLocator in tests.

Usage

To use this fixture, we need to have our test class implement IAssemblyFixture, and to inject the ServiceLocatorFixture in the test class constructor.

public class MyClassTests : IAssemblyFixture<ServiceLocatorFixture>
{
    private readonly ServiceLocatorFixture _serviceLocatorFixture;

    public MyClassTests(ServiceLocatorFixture serviceLocatorFixture) => this._serviceLocatorFixture = serviceLocatorFixture;
}

And then we can start writing some tests. For a class that requires something from the DI container, we can get it from the fixture:

[Fact]
public void Build_NormalDefaults_ViewModelPopulated()
{
    var contentLoader = this._fixture.ServiceProvider.GetRequiredService<IContentLoader>();
    var logger = Substitute.For<ILogger<TestBlockViewModelBuilder>>();

    var builder = new TestBlockViewModelBuilder(contentLoader, logger);
    var viewModel = builder.Build(new TestBlock());

    builder.CurrentContent.Should().NotBeNull();
    viewModel.Should().NotBeNull();
    viewModel.CurrentBlock.Should().NotBeNull();
}

But we can also make use of Optimizely classes that rely on the ServiceLocator, even if we dont need direct access to the ServiceLocator from the test:

[Fact]
public void BuildStandardLoaderOptions_NoCulture_ReturnsOptions()
{
    var loaderOptionsService = new LoaderOptionsService();

    var options = loaderOptionsService.BuildStandardLoaderOptions();

    options.Should().NotBeNull();
}

Loader Options Service is a class we created to streamline creating LoaderOptions for calls to get content. If I look at the BuildStandardLoaderOptions method, if I do not provide a CultureInfo, it uses ContentLanguage.PreferredCulture, an Optimizely method for getting the current culture. This method relies on IContentLanguageAccessor. So for this test to work, I need to register IContentLanguageAccessor in the constructor of my fixture:

var contentLanguageAccessor = Substitute.For<IContentLanguageAccessor>();
contentLanguageAccessor.Language.Returns(new CultureInfo("en"));
this.Providing(contentLanguageAccessor);

For this project, we wanted the default language to be "en", so this will always return that. So, this test will work.

Test Lifetime

One thing to watch out for. When running unit tests in Visual Studio, running tests in Debug means each test is run in serial, one after the other. But if I just run tests all at once, they are on multiple threads. So, something done to something in the ServiceLocator could affect multiple tests. A symptom of this is that debugging all tests, the tests might pass, but if you just run all tests, they may fail because the ServiceLocator might return unexpected results.

In order to deal with this, you might  need to combine this service locator fixture with specific Substitutes in the test. In particular, if you test returns specific content items that you need to test against, you might need to do this.

Example:

In my ServiceLocator I have registered a IContentLoader. But, for a specific test, I need that repository to return some content specific for this test. So, even though my test class implements IAssemblyFixture, in my test I create a specific substitute of IContentLoader just for this test.

public class ContentLoaderExtensionsTests : TestBase, IAssemblyFixture<ServiceLocatorFixture>
{
    private readonly ServiceLocatorFixture _fixture;

    public ContentLoaderExtensionsTests(ServiceLocatorFixture fixture) => this._fixture = fixture;

    [Fact]
    public void GetFirstChild_WhenCalled_ReturnsFirstChild()
    {
        var contentLoader = Substitute.For<IContentLoader>();

        var page1 = new TestPage();
        page1.Property.Add("PageLink", new PropertyContentReference(1));
        page1.Property.Add("PageTypeID", new PropertyNumber(100));
        page1.Property.Add("PageStartPublish", new PropertyDate(DateTime.Now.AddDays(-1)));
        page1.Property.Add("PageStopPublish", new PropertyDate(DateTime.Now.AddDays(1)));

        var page2 = new TestPage();
        page2.Property.Add("PageLink", new PropertyContentReference(2));
        page2.Property.Add("PageTypeID", new PropertyNumber(100));
        page2.Property.Add("PageStartPublish", new PropertyDate(DateTime.Now.AddDays(-1)));
        page2.Property.Add("PageStopPublish", new PropertyDate(DateTime.Now.AddDays(1)));
        page2.Property.Add("ParentLink", new PropertyContentReference(1));

        var page3 = new TestPage();
        page3.Property.Add("PageLink", new PropertyContentReference(3));
        page3.Property.Add("PageTypeID", new PropertyNumber(100));
        page3.Property.Add("PageStartPublish", new PropertyDate(DateTime.Now.AddDays(-1)));
        page3.Property.Add("PageStopPublish", new PropertyDate(DateTime.Now.AddDays(1)));
        page3.Property.Add("ParentLink", new PropertyContentReference(1));

        var securable = page1 as IContentSecurable;
        var securityDescriptor = securable?.GetContentSecurityDescriptor();
        securityDescriptor?.AddEntry(new AccessControlEntry("test", AccessLevel.FullAccess, SecurityEntityType.User));

        var securable2 = page2 as IContentSecurable;
        var securityDescriptor2 = securable2?.GetContentSecurityDescriptor();
        securityDescriptor2?.AddEntry(new AccessControlEntry("test", AccessLevel.FullAccess, SecurityEntityType.User));

        var securable3 = page3 as IContentSecurable;
        var securityDescriptor3 = securable3?.GetContentSecurityDescriptor();
        securityDescriptor3?.AddEntry(new AccessControlEntry("test", AccessLevel.FullAccess, SecurityEntityType.User));

        var principal = Substitute.For<IPrincipal>();
        principal?.Identity?.Name.Returns("test");
        PrincipalInfo.CurrentPrincipal.Returns(principal);

        var templateResolver = this._fixture.ServiceProvider.GetRequiredService<ITemplateResolver>();

        var type = page1.GetOriginalType();
        const TemplateTypeCategories categories = TemplateTypeCategories.Request;
        var list = Enumerable.Empty<string>();
        templateResolver.ResolveAll(page1, type, categories, list)
            .Returns(new List<TemplateModel> { new() });

        var type2 = page2.GetOriginalType();
        templateResolver.ResolveAll(page2, type2, categories, list)
            .Returns(new List<TemplateModel> { new() });

        var type3 = page3.GetOriginalType();
        templateResolver.ResolveAll(page3, type3, categories, list)
            .Returns(new List<TemplateModel> { new() });

        var publishedStateAssessor = this._fixture.ServiceProvider.GetRequiredService<IPublishedStateAssessor>();
        publishedStateAssessor.IsPublished(Arg.Any<IContent>()).Returns(true);

        contentLoader.GetChildren<TestPage>(Arg.Any<ContentReference>(), Arg.Any<LoaderOptions>()).Returns(new List<TestPage> { page2, page3 });

        var child = contentLoader.GetFirstChild<TestPage>(page1.ContentLink);
        child.Should().NotBeNull();
        child.Should().BeOfType<TestPage>();
    }
}

You can see that the class implements the IAssemblyFixture, but in the test, we create a specific IContentLoader substitute:

var contentLoader = Substitute.For<IContentLoader>();

And then we explicitly set what it should return:

contentLoader.GetChildren<TestPage>(Arg.Any<ContentReference>(), Arg.Any<LoaderOptions>()).Returns(new List<TestPage> { page2, page3 });

And we are confident that GetChildren will only return these two pages in thuis specific test.

Summary

Writing unit tests for Optimizely is a challenge. Implementing a test replacement for ServiceLocator allows us to test a whole bunch of code that still relies on ServiceLocator in the Optimizely code base.

Feb 14, 2024

Comments

Mark Stott
Mark Stott Feb 15, 2024 11:08 AM

Thank you for sharing, It's always interesting to see how other people have approached this issue.

Working with ServiceLocator and unit tests is actually pretty simple once you just start mocking the IServiceProvider and set the static ServiceLocator instance.  This example uses Moq & Nunit, but could be converted for XUnit:

private Mock<IContentLoader> _mockContentLoader;
private Mock<IServiceProvider> _mockServiceProvider;

[SetUp]
public void SetUp()
{
    _mockContentLoader = new Mock<IContentLoader>();

    _mockServiceProvider = new Mock<IServiceProvider>();
    _mockServiceProvider.Setup(x => x.GetService(typeof(IContentLoader))).Returns(_mockContentLoader.Object);

    ServiceLocator.SetServiceProvider(_mockServiceProvider.Object);
}

Please login to comment.
Latest blogs
CMS + CMP + Graph integration

We have just released a new package https://nuget.optimizely.com/package/?id=EPiServer.Cms.WelcomeIntegration.Graph which changes the way CMS fetch...

Bartosz Sekula | Nov 5, 2024

Block type selection doesn't work

Imagine you're trying to create a new block in a specific content area. You click the "Create" link, expecting to see a CMS modal with a list of...

Damian Smutek | Nov 4, 2024 | Syndicated blog

.NET 8 FAQ

I have previously written about .NET compatibility in general and .NET 8 in particular, see blog posts here , here and here . With the end of suppo...

Magnus Rahl | Nov 4, 2024

Dynamic packages in Commerce Connect

In Optimizely Commerce Connect, you can group different items using packages and bundles. Package: A package has one or more versions of a product...

K Khan | Nov 1, 2024