Goal of the implementation
As Deane Barker pointed out on the Gadgetopia blog it’s important for a CMS to have clean and strict API separations. One reason for this is testability.
I agree with Deane that EPiServer provides us with such clean and strict API separations (for the most part). I believe this is what allowed me to start EPiServer in a console application, much like Magnus Stråhle did earlier with EPiServer CMS 5.
The next step was to implement this in an MSTest test project in Visual Studio 2010 (which probably makes Joel Abrahamsson cry out in agony) in order to be able to unit test an EPiServer website without an actual website.
Start EPiServer outside of a web application for unit testing
There’s something oddly satisfying about spinning up EPiServer in a console application, but it probably does more good to start EPiServer in a stand-alone test project. That way we can simply start our test project, hit Run All Tests and watch the unit tests run – and hopefully pass! :)
The first of the following unit tests ensures the DataFactory is properly initialized by retrieving the start page and the second one checks a known language element in the language file to ensure we can retrieve translations properly using LanguageManager:
How to launch EPiServer without a web context
For our unit tests we essentially start an existing EPiServer website, but not as a web application. The high-level description of how to do that looks like this:
- Copy everything from Web.config and episerver.config into the App.config file
- Initialize the EPiServer ClassFactory and runtime cache
- Set up a mock hosting environment that doesn’t require a web context
- Initiate the EPiServer initialization process (which takes care of initializing all those InitializableModule classes)
- Initialize EPiServer data access
Contents of the test project
A plain EPiServer test project doesn’t contain a whole lot:
Since we want to include tests for EPiServer Template Foundation we need to include the following in our test project:
- All EPiServer binaries
- Page Type Builder binaries (required by ETF)
- DotLess binaries (required ETF)
- EPiServer Template Foundation binaries
Of course you could settle for the EPiServer binaries if you want to unit test an EPiServer website without Page Type Builder, but that’s not nearly as much fun! :)
We include a file called translations.xml which is the language file we’ll use for unit tests involving the LanguageManager. We could use the language files of the EPiServer website we’re testing, but that doesn’t make as much sense in our case.
The Environment folder contains some support classes needed for our tests, such as to actually start EPiServer, load embedded resources (such as our language file), and the mock hosting environment we use to rid ourselves of the web context dependency.
How to (actually) start an EPiServer website without a web context
Enough with the schematics and lengthy introduction. Here’s how I did it in the test project:
Unit test base class
In order to have each EPiServer unit test setup properly I created an abstract base class called EPiServerUnitTest:
[TestClass]
public abstract class EPiServerUnitTest
{
[AssemblyInitialize]
public static void Initialize(TestContext context)
{
EPiServerInitializer.Initialize(context);
}
public TestContext TestContext { get; set; }
}
As you can see we setup our test project through a static method called Initialize which has been decorated with the AssemblyInitialize attribute. This method is run only once to set up the assembly (EPiServer in this case) for testing.
The EPiServerInitializer class
As you could see in our base class we invoked EPiServerInitializer.Initialize() to setup the assembly (ie EPiServer).
Let’s go through what actually happens inside the Initialize method:
First we read the configuration from the App.config file:
Settings.InitializeAllSettings(ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None));
Next we use that configuration to initialize EPiServer’s Settings class, using the wildcard host mapping:
SiteMappingConfiguration.Instance=new SiteMappingConfiguration();
Settings.Instance = Settings.MapHostToSettings("*", true);
After that we initialize the ClassFactory and configure the runtime cache:
ClassFactory.Instance = new Implementation.DefaultBaseLibraryFactory(String.Empty);
ClassFactory.RegisterClass(typeof(IRuntimeCache), typeof(Implementation.DefaultRuntimeCache));
Once that is done we can setup our mock hosting environment:
GenericHostingEnvironment.Instance = new EPiServerHostingEnvironment();
We also need to set the base directory to the location of our test deployment and the instance name (to avoid an exception when performance counters are set up):
Global.BaseDirectory = context.TestDeploymentDir;
Global.InstanceName = "EPiServer Unit Test";
Next we configure data access:
DataAccessBase.Initialize(
ConfigurationManager.ConnectionStrings[Settings.Instance.ConnectionStringName],
TimeSpan.Zero,
0,
TimeSpan.Zero);
And now we’re finally ready to spin up EPiServer:
InitializationModule.FrameworkInitialization(HostType.Service);
Since we want to use our embedded language file we create a lang folder in the test deployment folder and essentially copy our embedded language file to that folder:
// Create lang folder
var langFolder = Directory.CreateDirectory(context.TestDeploymentDir).CreateSubdirectory("lang");
// Create a language file based on the embedded resource
using (var langFile = File.CreateText(langFolder.FullName + "\\translations.xml"))
{
langFile.Write(EmbeddedResourceLoader.LoadTextFile("EmbeddedResources.translations.xml"));
langFile.Close();
}
And that’s it! We can now run a unit test like the following to ensure we can read pages from our EPiServer site using DataFactory – without a web context:
[TestMethod]
public void TestDataFactoryInitialized()
{
try
{
if(PageReference.IsNullOrEmpty(PageReference.StartPage) ||
PageReference.RootPage==PageReference.StartPage)
{
Assert.Inconclusive("Unable to verify pages can be accessed through DataFactory because a start page has not been specified, or the start page points to the system root page");
}
var startPage = DataFactory.Instance.GetPage(PageReference.StartPage);
Assert.IsNotNull(startPage, "DataFactory returned null when retrieving the start page");
Assert.IsInstanceOfType(startPage,typeof(PageData), "Retrieved start page is not of type PageData");
}
catch (Exception ex)
{
Assert.Fail("DataFactory is not properly initialized: {0}", ex.Message);
}
}
And when we execute the test…
…it actually passes! :)
The EPiServerInitializer class
Here’s the code from above in its entirety:
using System;
using System.Configuration;
using System.IO;
using EPiServer.BaseLibrary;
using EPiServer.Configuration;
using EPiServer.DataAccess;
using EPiServer.Framework.Initialization;
using EPiServer.Web.Hosting;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace EPiServer.Tests.Environment
{
public static class EPiServerInitializer
{
/// <summary>
/// Initializes EPiServer
/// </summary>
/// <param name="context">The test context for which EPiServer is initialized</param>
public static void Initialize(TestContext context)
{
// Initialize settings from App.config
try
{
Settings.InitializeAllSettings(ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None));
}
catch (ConfigurationErrorsException ex)
{
throw new EPiServerTestException("The application configuration file does not seem to contain required EPiServer configuration");
}
// Map host settings
try
{
SiteMappingConfiguration.Instance=new SiteMappingConfiguration();
Settings.Instance = Settings.MapHostToSettings("*", true);
}
catch (Exception ex)
{
throw new EPiServerTestException("Unable to map host settings", ex);
}
// Initialize ClassFactory
try
{
ClassFactory.Instance = new Implementation.DefaultBaseLibraryFactory(String.Empty);
ClassFactory.RegisterClass(typeof(IRuntimeCache), typeof(Implementation.DefaultRuntimeCache));
}
catch (Exception ex)
{
throw new EPiServerTestException("Unable to initialize ClassFactory", ex);
}
// Initialize hosting environment
try
{
// Use a custom hosting environment designed to run without a web context
GenericHostingEnvironment.Instance = new EPiServerHostingEnvironment();
}
catch (Exception ex)
{
throw new EPiServerTestException("Could not initialize VPP", ex);
}
// Configure Global settings
try
{
Global.BaseDirectory = context.TestDeploymentDir;
Global.InstanceName = "EPiServer Unit Test";
}
catch (Exception ex)
{
throw new EPiServerTestException("Unable to configure Global class", ex);
}
// Configure data access
try
{
DataAccessBase.Initialize(
ConfigurationManager.ConnectionStrings[Settings.Instance.ConnectionStringName],
TimeSpan.Zero,
0,
TimeSpan.Zero);
}
catch (Exception ex)
{
throw new EPiServerTestException("Unable to set up database access", ex);
}
// Copy embedded language file to test deployment directory
try
{
// Create lang folder
var langFolder = Directory.CreateDirectory(Global.BaseDirectory).CreateSubdirectory("lang");
// Create a language file based on the embedded resource
using (var langFile = File.CreateText(langFolder.FullName + "\\translations.xml"))
{
langFile.Write(EmbeddedResourceLoader.LoadTextFile("EmbeddedResources.translations.xml"));
langFile.Close();
}
}
catch (Exception ex)
{
throw new EPiServerTestException("Unable to copy language file to test deployment directory", ex);
}
// Copy embedded language file to test deployment directory
try
{
// Create lang folder
var langFolder = Directory.CreateDirectory(context.TestDeploymentDir).CreateSubdirectory("lang");
// Create a language file based on the embedded resource
using (var langFile = File.CreateText(langFolder.FullName + "\\translations.xml"))
{
langFile.Write(EmbeddedResourceLoader.LoadTextFile("EmbeddedResources.translations.xml"));
langFile.Close();
}
}
catch (Exception ex)
{
throw new EPiServerTestException("Unable to copy language file to test deployment directory", ex);
}
// Start EPiServer initialization
try
{
InitializationModule.FrameworkInitialization(HostType.Service);
}
catch (Exception ex)
{
throw new EPiServerTestException("EPiServer framework could not be initialized", ex);
}
}
}
}
The EPiServerHostingEnvironment class
I borrowed the EPiServerHostingEnvironment implementation from Magnus Stråle’s command line interface implementation:
using System;
using System.IO;
using System.Reflection;
using System.Web;
using System.Web.Hosting;
using EPiServer.Web.Hosting;
namespace EPiServer.Tests.Environment
{
/// <summary>
/// A minimal hosting environment intended to run without a web context
/// </summary>
public class EPiServerHostingEnvironment : IHostingEnvironment
{
VirtualPathProvider _provider = null;
public void RegisterVirtualPathProvider(VirtualPathProvider virtualPathProvider)
{
// Sets up the provider chain
typeof(VirtualPathProvider).GetField("_previous", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(virtualPathProvider, _provider);
_provider = virtualPathProvider;
}
public VirtualPathProvider VirtualPathProvider
{
get { return _provider; }
}
public string MapPath(string virtualPath)
{
return Path.Combine(ApplicationPhysicalPath, VirtualPathUtility.ToAbsolute(virtualPath, ApplicationVirtualPath).Replace('/', '\\'));
}
public string ApplicationID
{
get { return String.Empty; }
}
public string ApplicationPhysicalPath
{
get { return Global.BaseDirectory; }
}
public string ApplicationVirtualPath
{
get { return "/"; }
}
}
}
Where to go from here
This solution isn’t well tested in itself, so I wouldn’t be surprised if the initialization needs to be complemented in some way to enable all EPiServer features. If there are others using a similar approach (or a better one?) for unit testing EPiServer websites I’d love to hear about it!
Update: Make sure you have all the required assemblies in the deployment directory, for example by referencing all EPiServer and third-party assemblies and setting Copy Local to true: