Unit Testing Code That Uses the Unity Engine: Part 2
Last week’s article showed a technique that you can use to abstract the Unity engine so that you can test code that uses it. Today’s article presents another technique that allows you to remove this abstraction layer so your game code is faster and more natural. Read on to learn how!
To briefly recap, the technique discussed in last week’s article involves introducing an abstraction layer between your game code and the Unity engine. Instead of directly using WWW
, your game code would use an IWww
interface implemented by a UnityWww
class full of one-liner passthroughs to a real WWW
it holds as a private field.
This approach works, but it’s a lot of boilerplate code, complicates the Unity API by introducing a parallel version of it, and adds a virtual function call every time you use the Unity API. It can lead to some extremely strange situations, like wrapping Vector3
because it contains calls into the Unity engine’s native code. So today’s article is all about an alternative technique that helps out with some of these problems.
When we put C# files into our project’s Assets directory, they’re compiled by Unity’s Mono compiler into a DLL: Library/ScriptAssemblies/Assembly-CSharp.dll
. That DLL has a reference to the UnityEngine.dll
that ships with Unity to provide the API for our scripts, including classes like WWW
. Normally the Unity engine loads up our Assembly-CSharp.dll
and does the linking to Unity’s UnityEngine.dll
so it’s ready for us to use the Unity engine’s API. When we’re unit testing, we don’t want to use the real UnityEngine.dll
. That’s why we introduced the abstraction layer last week.
Instead of an abstraction layer, we can instead swap out UnityEngine.dll
itself. When unit testing, we could use an alternative version of UnityEngine.dll
that’s implemented in such a way that WWW
doesn’t really make web calls, and so forth for other classes like Input
and MonoBehaviour
. That’s basically what we did last week with NSubstitute when we wrote code like this:
var www = Substitute.For<IWww>(); var done = false; www.isDone.Returns(x => done); www.bytes.Returns(new byte[]{ 1, 2, 3, 4, 5 });
We were providing an alternative implementation of WWW
that did something specific for our tests. Importantly, it didn’t make any web calls.
It turns out that making our own UnityEngine.dll
is quite easy! All you have to do is make a new DLL project in MonoDevelop or Visual Studio. I wrote a guide last year with the basic steps. Just name the DLL UnityEngine.dll
and start filling it with types that match the Unity API. For example, you could start with an empty version of WWW
:
public class WWW : IDisposable { public WWW(string url) { } public bool isDone { get; set; } public byte[] bytes { get; set; } public void Dispose() { } }
Now that you have a fake UnityEngine.dll
, your game code (Assembly-CSharp.dll
) and unit tests (Assembly-CSharp-Editor.dll
) can use it instead of Unity’s real version. To do this, it’s again helpful to package your game code and unit test code as DLLs. In Visual Studio or MonoDevelop, your solution should look like this:
MyGame // solution |-UnityEngine // fake UnityEngine.dll |-WWW.cs // implement the Unity API |-Runtime |-References |-UnityEngine // reference the UnityEngine project |-WebCallRunner.cs // your game code |-UnitTests |-References |-UnityEngine // reference the UnityEngine project |-TestWebCallRunner.cs // your unit tests
To run the unit tests from MonoDevelop, use the Run > Run Unit Tests
menu option. Visual Studio users will need to install either the “Visual Studio Test Adapter” or “dotCover” from the “Resharper” suite and follow the relevant instructions.
To use the game code for real in the Unity engine, make sure the DLL your IDE built is in your Unity project’s Assets directory. The easiest way to achieve that is to build it directly there and make Unity ignore the fake UnityEngine.dll. Here are some steps for MonoDevelop:
- Open MonoDevelop
- Right-click the runtime game code project and click Options
- Click Build > Output on the left side
- Change the output path to your Unity project’s Assets directory
- OK, build project
- Open the Unity editor
- Click “UnityEngine” in the Project pane
- Uncheck “Any Platform” and then all the platforms, click Apply
Now that you’ve got the project set up, let’s look at how to make the fake UnityEngine.dll
more useful. We started with an empty WWW
implementation and that’s enough to get our WebCallRunner
class from the last article to compile. It’s not a very useful class though since there isn’t a very easy way to write our test. Let’s revisit how the test looked when we were using NSubstitute to fake an implementation of IWww
:
using System; using NSubstitute; using NUnit.Framework; [TestFixture] public class TestWebCallRunner { [Test] public void RunYieldsUntilWwwIsDoneThenDispatchesEventAndDisposesWww() { var www = Substitute.For<IWww>(); var done = false; www.isDone.Returns(x => done); www.bytes.Returns(new byte[]{ 1, 2, 3, 4, 5 }); var webCall = new WebCallRunner(www); var dispatchedBytes = default(byte[]); webCall.OnDone += b => dispatchedBytes = b; var enumerator = webCall.Run(); // not done, so keeps going Assert.That(enumerator.MoveNext(), Is.True); Assert.That(dispatchedBytes, Is.Null); www.DidNotReceive().Dispose(); // still not done, so keeps going Assert.That(enumerator.MoveNext(), Is.True); Assert.That(dispatchedBytes, Is.Null); www.DidNotReceive().Dispose(); done = true; // done, so enumerator stops, callback is called, IWww is disposed Assert.That(enumerator.MoveNext(), Is.False); Assert.That(dispatchedBytes, Is.EqualTo(new byte[]{ 1, 2, 3, 4, 5 })); www.Received(1).Dispose(); } }
Let’s break down what features we’d like our unit tests to have access to so that we can write good tests:
- Have a lambda called when a Unity API function is called
- Set the return value of Unity API functions
- Check how many times Unity API functions were called
- Check the parameters passed to Unity API functions, including constructors
NSubstitute handles all of that except recording the calls to constructors. Thankfully, it’s pretty easy to write a class that emulates all this functionality and include it in our fake UnityEngine.dll
using an alternate namespace:
using System; using System.Collections.Generic; namespace TestUnityEngine { /// <summary> /// Records calls to a Unity API and calls a delegate each time /// </summary> /// <typeparam name="TParam">Type of parameter passed to the Unity API<typeparam> /// <typeparam name="TReturn">Type of return value the Unity API returns<typeparam> /// <author>Jackson Dunstan, http://JacksonDunstan.com/articles/3706</author> /// <license>MIT</license> public class ApiCalls<TParam, TReturn> { /// <summary> /// History of calls to this API /// </summary> public List<TParam> History = new List<TParam>(); /// <summary> /// If not null, called by <see cref="Call"/> with its parameter and used as the return val /// </summary> public Func<TParam, TReturn> OnCall; /// <summary> /// Record a call in <see cref="History"/> and return the default value for /// <see cref="TReturn"/> if <see cref="OnCall"/> is null or call <see cref="OnCall"/> /// with <see cref="param"/> and return its return value. /// </summary> /// <param name="param">Parameter to record in <see cref="History"/> and pass to /// <see cref="OnCall"/> if it's not null.</param> public TReturn Call(TParam param) { History.Add(param); return OnCall == null ? default(TReturn) : OnCall(param); } } }
For properties and functions that take no parameters or return void
, it can be helpful to have a dummy type:
namespace TestUnityEngine { /// <summary> /// An empty type to represent 'void' /// </summary> /// <author>Jackson Dunstan, http://JacksonDunstan.com/articles/3706</author> /// <license>MIT</license> public struct VoidType { /// <summary> /// An instance of this type. It is essentially a global constant like Math.PI. /// </summary> public static VoidType Val; } }
Now we can rewrite the WWW
class to use ApiCalls
like so:
using System; using TestUnityEngine; namespace UnityEngine { public sealed class WWW : IDisposable { public ApiCalls<string, VoidType> UrlConstructorCalls = new ApiCalls<string, VoidType>(); public ApiCalls<VoidType, byte[]> BytesGetterCalls = new ApiCalls<VoidType, byte[]>(); public ApiCalls<VoidType, bool> IsDoneGetterCalls = new ApiCalls<VoidType, bool>(); public ApiCalls<VoidType, VoidType> DisposeCalls = new ApiCalls<VoidType, VoidType>(); public WWW(string url) { UrlConstructorCalls.Call(url); } public byte[] bytes { get { return BytesGetterCalls.Call(VoidType.Val); } private set { } } public bool isDone { get { return IsDoneGetterCalls.Call(VoidType.Val); } private set { } } public void Dispose() { DisposeCalls.Call(VoidType.Val); } } }
The new WWW
implementation just forwards along every call to a public ApiCalls
instance. This allows ApiCalls
to act like a substitute created by NSubstitute. The unit test now looks like this:
using System; using NUnit.Framework; using UnityEngine; using Runtime; namespace TestUnityEngineExamples { [TestFixture] public class TestWebCallRunner { [Test] public void RunYieldsUntilWwwIsDoneThenDispatchesEventAndDisposesWww() { var www = new WWW("url"); var done = false; www.IsDoneGetterCalls.OnCall += v => done; www.BytesGetterCalls.OnCall += v => new byte[]{ 1, 2, 3, 4, 5 }; var webCall = new WebCallRunner(www); var dispatchedBytes = default(byte[]); webCall.OnDone += b => dispatchedBytes = b; var enumerator = webCall.Run(); // not done, so keeps going Assert.That(enumerator.MoveNext(), Is.True); Assert.That(dispatchedBytes, Is.Null); Assert.That(www.DisposeCalls.History, Is.Empty); // still not done, so keeps going Assert.That(enumerator.MoveNext(), Is.True); Assert.That(dispatchedBytes, Is.Null); Assert.That(www.DisposeCalls.History, Is.Empty); done = true; // done, so enumerator stops, callback is called, WWW is disposed Assert.That(enumerator.MoveNext(), Is.False); Assert.That(dispatchedBytes, Is.EqualTo(new byte[]{ 1, 2, 3, 4, 5 })); Assert.That(www.DisposeCalls.History.Count, Is.EqualTo(1)); } } }
Notice how similar the unit test looks to the version that was using NSubstitute! There are only a few differences to point out:
// Get an instance of a mock/fake/substitute class var www = Substitute.For<IWww>(); // NSubstitute var www = new WWW("url"); // ApiCalls // Have a lambda called when a Unity API function is called www.isDone.Returns(x => { /* do something */ }); // NSubstitute www.IsDoneGetterCalls.OnCall += v => { /* do something */ }; // ApiCalls // Set the return value of Unity API functions www.isDone.Returns(x => done); // NSubstitute www.IsDoneGetterCalls.OnCall += v => done; // ApiCalls // Check how many times Unity API functions were called www.Received(1).Dispose(); // NSubstitute Assert.That(www.DisposeCalls.History.Count, Is.EqualTo(1)); // ApiCalls // Check the parameters passed to Unity API functions, including constructors Received.InOrder(() => { www.Foo("a"); www.Foo("b"); www.Foo("c"); }; // NSubstitute (constructors not possible) Assert.That(www.FooCalls.History, Is.EqualTo("a", "b", "c")); // ApiCalls
All in all, writing unit tests using ApiCalls
is about the same as using NSubstitute. It’s also really easy to set up the Unity classes to use ApiCalls
since a one-liner is all that’s required.
That’s all there is to this technique. To compare it with the previous technique, here’s a handy table:
Abstraction Layer | Fake UnityEngine.dll | |
---|---|---|
Requires middleman API | Yes | No |
Requires factories | Yes | No |
Boilerplate code | 4 files per type: interface, class/struct, factory interface, factory class/struct | 1 file per type: class/struct in UnityEngine.dll |
Code coverage | All but abstraction layer | All but UnityEngine.dll |
CPU overhead | 1 virtual function call per API call | None |
Project complexity | Abstraction layer noise | Solution with three DLL projects |
This technique wins pretty handily against the abstraction technique in most categories, but loses in terms of project complexity. Nothing can really compete with the simplicity of just putting .cs files in your Assets directory, and that’s a downside with this approach. However, if you want to gain the upsides from the comparison table it may be the approach for you.
What do you think of these two techniques? Do you have a third way to do it? Let me know in the comments!