Unit Testing MonoGame's Content Pipeline

Previously, I wrote an article that showed how to add support for custom game data by extending MonoGame’s content pipeline. Writing a pipeline extension is one thing, but if we wish to be responsible and smart developers, it may make sense to also write some unit testing code to ensure everything works.

That can prove challenging, as MonoGame’s various subsystems are laden with rather sticky dependencies that can be hard to satisfy within a barebones unit testing environment.

This article seeks to spare you, the reader, some of the pain you’re likely to experience when writing unit tests for a pipeline extension. So, read on, and let’s get to testing.

Two Major Aspects to Test

From a high-level perspective, there are two separate pieces to the content pipeline testing puzzle (each with its own challenges and problems) that we need to solve.

The first area of concern is pipeline content importation. It is the more straightforward of the two. Tests must ensure that assets are serialized correctly and that content types are handled properly by their respective importer and processor classes.

The second area of concern is pipeline content loading via the ContentManager class. This is what produces the model class instances for our data, so it is pretty important. Unfortunately, ContentManager has dependencies that are hard to satisfy in a unit test.

Hard, or at least unclear at first, but not impossible! Let’s see, then, how we can go about testing these two separate areas of concern easily.

Testing Pipeline Content Importation

Testing the code that writes game assets to the content pipeline is as simple as instantiating our various ContentImporter and ContentProcessor classes and calling their public methods of note.

Sample Content Importation Test Code
public class TileMapPipelineTests
{
    private readonly TileMapImporter _importer = new();
    private readonly TileMapProcessor _processor = new();
    private readonly TestContentImporterContext _importerContext = new();
    private readonly TestContentProcessorContext _processorContext = new();

    [Fact]
    public void ImportProcess_Csv_ReturnsValid() 
        => ValidateTileMap("GrassCsvFormat.tmx");

    [Fact]
    public void ImportProcess_Zlib_ReturnsValid() 
        => ValidateTileMap("GrassZlibFormat.tmx");

    [Fact]
    public void ImportProcess_Gzip_ReturnsValid()
        => ValidateTileMap("GrassGzipFormat.tmx");

    [Fact]
    public void ImportProcess_UncompressedBase64_ReturnsValid()
        => ValidateTileMap("GrassUncompressedBase64Format.tmx");

    private void ValidateTileMap(string assetName)
    {
        TileMapContent content = _importer.Import($"Content\\Tiles\\{assetName}", _importerContext);

        Assert.NotNull(content);
        Assert.NotNull(content.Asset);
        Assert.NotEmpty(content.Asset.Layers);

        content = _processor.Process(content, _processorContext);

        var tileLayer = content.Asset.Layers.First() as TileLayerAsset;

        Assert.NotNull(tileLayer);
        Assert.NotEmpty(tileLayer.Tiles);
    }
}

Derivations of ContentImporter and ContentProcessor types are easy to work with, as they’ll have default constructors and core routines that are easily invoked. A path to the game asset is pretty much all you need.

Content Contexts

The only pieces of required data that aren’t immediately and readily available are the ContentImporterContext and ContentProcessorContext instances required by the core routines of our importer and processor, respectively.

These types are abstract classes, and while you can make use of the implementations provided by MonoGame’s libraries, it’s going to be a lot easier if you just provide your own stub implementations.

Additionally, if you’re using some kind of third-party mocking framework, you can just use that instead.

Content Importer Context Stub
/// <summary>
/// Provides a content processor context stub to use for unit testing content importers.
/// </summary>
internal sealed class TestContentImporterContext : ContentImporterContext
{
    /// <inheritdoc />
    public override void AddDependency(string filename)
    { }

    /// <inheritdoc />
    public override string IntermediateDirectory
        => "Content";

    /// <inheritdoc />
    public override ContentBuildLogger Logger
        => new PipelineBuildLogger();

    /// <inheritdoc />
    public override string OutputDirectory 
        => "Content";
}
Content Processor Context Stub
/// <summary>
/// Provides a content processor context stub to use for unit testing content processors.
/// </summary>
internal sealed class TestContentProcessorContext : ContentProcessorContext
{
    /// <inheritdoc />
    public override void AddDependency(string filename)
    { }

    /// <inheritdoc />
    public override void AddOutputFile(string filename)
    { }

    /// <inheritdoc />
    public override TOutput BuildAndLoadAsset<TInput, TOutput>(
        ExternalReference<TInput> sourceAsset,
        string processorName,
        OpaqueDataDictionary processorParameters,
        string importerName)
    {
        return (TOutput) FormatterServices.GetUninitializedObject(typeof(TOutput));
    }

    /// <inheritdoc />
    public override ExternalReference<TOutput> BuildAsset<TInput, TOutput>(
        ExternalReference<TInput> sourceAsset,
        string processorName,
        OpaqueDataDictionary processorParameters,
        string importerName,
        string assetName)
    {
        return new ExternalReference<TOutput>();
    }

    /// <inheritdoc />
    public override TOutput Convert<TInput, TOutput>(
        TInput input, string processorName, OpaqueDataDictionary processorParameters)
    {
        return (TOutput) FormatterServices.GetUninitializedObject(typeof(TOutput));
    }

    /// <inheritdoc />
    public override string BuildConfiguration
        => string.Empty;

    /// <inheritdoc />
    public override string IntermediateDirectory
        => string.Empty;

    /// <inheritdoc />
    public override ContentBuildLogger Logger
        => new PipelineBuildLogger();

    /// <inheritdoc />
    public override ContentIdentity SourceIdentity
        => new();

    /// <inheritdoc />
    public override string OutputDirectory
        => "Content";

    /// <inheritdoc />
    public override string OutputFilename
        => string.Empty;

    /// <inheritdoc />
    public override OpaqueDataDictionary Parameters
        => new();

    /// <inheritdoc />
    public override TargetPlatform TargetPlatform 
        => TargetPlatform.DesktopGL;

    /// <inheritdoc />
    public override GraphicsProfile TargetProfile
        => GraphicsProfile.HiDef;
}

The PipelineLogger class used above can be found in the MonoGame.Framework.Content.Pipeline.Builder namespace.

Testing Pipeline Content Loading

This area of concern is the more critical of the two, as it will be testing code “at the end of the road”, as far as the whole content pipeline process goes.

These tests need to be able to load a previously encoded game asset into a respective model class instance so that we can validate its data.

Loading content (encoded in an .xnb file) from the content pipeline requires the use of the ContentManager class. If you’ve worked with MonoGame at all, you’ve used this class; however, you’ve probably only done so with an actual Game module.

ContentManager has a bunch of dependencies that prevent it from being immediately and easily used from the confines of a unit test. In particular: it requires an initialized graphics device. Not good if we’re testing in a headless environment!

Sample Content Loading Test Code
public class TileMapTests : IClassFixture<ContentManagerFixture>
{
    private readonly Microsoft.Xna.Framework.Content.ContentManager _content;

    public TileMapTests(ContentManagerFixture contentFixture) 
        => _content = contentFixture.Content;

    [Theory]
    [InlineData("GrassFourTiles")]
    [InlineData("GrassLeftDownOrder")]
    [InlineData("GrassLargeBlue")]
    [InlineData("GrassTilesAndImage")]
    [InlineData("GrassTwoTileLayers")]
    public void Load_NotNull(string mapName)
    {
        TileMap map = _content.Load<TileMap>($"Tiles\\{mapName}");
        
        Assert.NotNull(map);
    }
    // ...and all other unit tests.
}

This is the ideal unit test: one which allows us to see what’s returned by ContentManager. The initialization of the ContentManager instance seen above is handled by our ContentManagerFixture xUnit-specific class, and we shall go over how it does this shortly.

It’s not very difficult to initialize a ContentManager type, as the parameters are easily provided. While that’s great, what isn’t is the fact that it is most certainly going to crash and burn during runtime.

Let’s go over how to avoid that.

The MonoGame.Framework.WindowsDX Framework Must Be Used

My game and game library code uses the MonoGame.Framework.DesktopGL assembly so the code can be all nice and platform-neutral. Attempting to use ContentManager from this particular library, however, will most certainly cause errors during runtime.

The reasons for this are implementation-specific and (from what I’ve gathered) the content manager from MonoGame.Framework.DesktopGL simply cannot work without an actual and real graphics device being present.

I was able to get some testing code to compile and run fine on my local machine using OpenGL via some tricks; however, unit tests would fail when run as GitHub actions since those are run in a headless environment with no graphics device present.

A Means to an End

The MonoGame DirectX framework is much more forgiving when it comes to “faked” graphics devices, so you have to use that. And, since we’re simply using it as a means to make unit testing content pipeline output possible, this really shouldn’t conflict with the design goals of most projects that are intended to be platform-neutral.

That is unless the custom game asset type being added through your pipeline extension is affected significantly by the particular graphics API in use. If that’s the case: you might be out of luck. You’ll need to hack the MonoGame.Framework.DesktopGL that you’re using.

Otherwise, we can use the DirectX-targeting assembly’s ContentManager to test code that’s targeting OpenGL with no issue.

Our Fake Graphics Device Service

To create a ContentManager instance, we need to provide it with a ServiceContainer configured to honor requests for the IGraphicsDeviceService type. We need to provide an implementation of this interface.

GraphicsDeviceService.cs
/// <summary>
/// Provides a service for graphics devices used in unit tests.
/// </summary>
internal sealed class GraphicsDeviceService : IGraphicsDeviceService
{
    /// <summary>
    /// Initializes a new instance of the <see cref="GraphicsDeviceService"/> class.
    /// </summary>
    public GraphicsDeviceService()
    {
        GraphicsDevice = new GraphicsDevice(GraphicsAdapter.DefaultAdapter,
                                            GraphicsProfile.Reach,
                                            new PresentationParameters());
    }

    /// <inheritdoc />
    public event EventHandler<EventArgs>? DeviceCreated
    {
        add { }
        remove { }
    }

    /// <inheritdoc />
    public event EventHandler<EventArgs>? DeviceDisposing
    {
        add { }
        remove { }
    }

    /// <inheritdoc />
    public event EventHandler<EventArgs>? DeviceReset
    {
        add { }
        remove { }
    }

    /// <inheritdoc />
    public event EventHandler<EventArgs>? DeviceResetting
    {
        add { }
        remove { }
    }

    /// <inheritdoc />
    public GraphicsDevice GraphicsDevice 
    { get; }
}

Very simple. Much simpler than if we’re trying to work with the OpenGL library’s ContentManager, which won’t work anyway in headless environments.

The Content Manager Fixture

Let’s look at the class that actually initializes our content manager to be used by our various pipeline unit test classes.

ContentManagerFixture.cs
/// <summary>
/// Provides a shared context between test instances requiring the use of a content manager.
/// </summary>
public sealed class ContentManagerFixture : IDisposable
{
    public ContentManagerFixture()
    {
        var services = new ServiceContainer();
        var graphicsService = new GraphicsDeviceService();

        services.AddService(typeof(IGraphicsDeviceService), graphicsService);

        Content = new Microsoft.Xna.Framework.Content.ContentManager(services, "Content");
    }

    /// <summary>
    /// Gets an initialized content manager to use for testing.
    /// </summary>
    public Microsoft.Xna.Framework.Content.ContentManager Content
    { get; }

    /// <inheritdoc />
    public void Dispose()
    {
        Content.Dispose();
    }
}

And voilà! We can now write unit tests for our custom pipeline extension code.

Hope this helped!