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!