Mastering Unit Testing in Unity 3D

Kristopher C. Tobiasson
7 min readJun 8, 2023

--

For a professional Unity developer, unit testing is a crucial skill. We all want to validate the quality and correctness of our code and unit testing allows us to do just that. However, it isn’t always clear how to test in certain situations in Unity. For example, MonoBehaviours are not directly mock-able and this can sometimes cause confusion at first encounter. Well, I’m going to shed some light on issues like that in this article as I’ll explain some ways in which we can test just about anything you can imagine in the Unity editor with some useful tricks.

First of all we need to get setup for unit testing in Unity. So we’ll need the following packages installed:

  • Test Framework package

I use Zenject as a DI framework in this example. Whether you choose to use Zenject, any other DI framework or no DI at all it shouldn’t matter.

Now you want to create an assembly definition and call it anything that makes sense to you. I called it Tests. Set the following settings in the assembly definition:

So now we need a unit to test. I’m going to keep things as short and sweet as possible by just setting up a test for a very simple class, but the principles used here can be expanded upon and used in very complex situations. One thing to keep very clear is that we only care about testing the class in question and we should try to mock everything else as much as possible or we can get into a dependency nightmare where you have to create dependencies of dependencies of depend…. I think you get the idea.

I thought it would be straightforward enough to create a basic cooling system for a computer. This is the class that we are going to test:

using UnityEngine;
using UnityEngine.UI;
using Zenject;

namespace Tests
{
public class CoolingSystem : MonoBehaviour
{
[SerializeField]
private Image coolingIndicatorLight;
private IFan fan;
private ITemperatureControllable cpuTempControl;

[Inject]
private void Inject(
IFan fan,
[Inject(Id = "cpuTempControl")]
ITemperatureControllable cpuTempControl)
{
this.fan = fan;
this.cpuTempControl = cpuTempControl;
}

private void Start()
{
coolingIndicatorLight.color = GetIntensityColor(IntensityLevel.None);
}

private void Update()
{
CheckHeatIntensity();
}

private void CheckHeatIntensity()
{
var heatIntensity = cpuTempControl.GetHeatIntensity();

if (heatIntensity != fan.FanSpeedIntensity)
{
fan.UpdateFanSpeed(heatIntensity);
coolingIndicatorLight.color = GetIntensityColor(heatIntensity);
}
if (heatIntensity == IntensityLevel.Outrageous)
BlowUp();
}

private Color GetIntensityColor(IntensityLevel intensity)
{
return intensity switch
{
IntensityLevel.None => Color.clear,
IntensityLevel.Minimum => Color.blue,
IntensityLevel.Normal => Color.green,
IntensityLevel.Maximum => Color.red,
IntensityLevel.Outrageous => Color.magenta,

_ => throw new System.ArgumentOutOfRangeException()
};
}

private void BlowUp() => Destroy(this);
}
}

As you can see, there are just 3 dependencies (the coolingIndicatorLight Image, and the fan and cpuTempControl injected with the Zenject Inject attribute). We can mock the 2 interfaces with NSubstitute, but we can’t mock the Image since it inherits from MonoBehaviour. No problem, we can use Reflection for dealing with the Image as a dependency and we can also use Reflection for accessing and Invoking the GetIntensityColor method.

Here are the 2 interface dependencies and the IntensityLevel enum just in case it wasn’t already clear how the looked:

namespace Tests
{
public interface IFan
{
IntensityLevel FanSpeedIntensity { get; }
void UpdateFanSpeed(IntensityLevel fanSpeed);
}
}
namespace Tests
{
public interface ITemperatureControllable
{
IntensityLevel GetHeatIntensity();
}
}
namespace Tests
{
public enum IntensityLevel
{
None,
Minimum,
Normal,
Maximum,
Outrageous
}
}

We don’t care about the concrete versions of those interface dependencies because they are outside of the scope of the “unit” that we are testing against. So, since we only care about the CoolingSystem class, what are the unit tests that we should perform? Well, starting from the top and working our way down, we have the following unit tests:

  1. Test that the “coolingIndicatorLight.color” is properly set in the Start() method is a great start.
  2. Test all the possible combinations of IntensityLevels of the fan and cpuTempControl to make sure the UpdateFanSpeed method is called with the correct IntensityLevel as it should when the intensity levels don’t match.
  3. Test that a cpuTempControl heat of IntensityLevel.Outrageous destroys the CoolingSystem object.
using System.Collections;
using System.Reflection;
using NSubstitute;
using NUnit.Framework;
using UnityEngine.TestTools;
using UnityEngine;
using UnityEngine.UI;
using Zenject;

namespace Tests
{
public class CoolingSystemTests
{
private DiContainer container;
private Image coolingIndicatorLight;
private IFan fan;
private ITemperatureControllable cpuTempControl;
private CoolingSystem coolingSystem;

[SetUp]
public void SetUp()
{
container = new DiContainer();

fan = Substitute.For<IFan>();
cpuTempControl = Substitute.For<ITemperatureControllable>();

container.Bind<IFan>().FromInstance(fan).AsSingle();
container.Bind<ITemperatureControllable>().WithId("cpuTempControl").FromInstance(cpuTempControl).AsSingle();

var gameObject = new GameObject();
gameObject.SetActive(false);
coolingSystem = gameObject.AddComponent<CoolingSystem>();
container.Inject(coolingSystem);
SetupIndicatorLightImage();
}

[UnityTest]
public IEnumerator Start_IndicatorLightColor_Test()
{
coolingSystem.gameObject.SetActive(true);

yield return null;

var intensityColor = GetIntensityColor(IntensityLevel.None);

Assert.AreEqual(coolingIndicatorLight.color, intensityColor);
}

private static IntensityLevel[] intensityLevels = new [] {
IntensityLevel.None,
IntensityLevel.Minimum,
IntensityLevel.Normal,
IntensityLevel.Maximum,
IntensityLevel.Outrageous
};

[UnityTest]
public IEnumerator UpdateFanSpeed_Test(
[ValueSource(nameof(intensityLevels))] IntensityLevel heatIntensity,
[ValueSource(nameof(intensityLevels))] IntensityLevel fanIntensity)
{
cpuTempControl.GetHeatIntensity().Returns(heatIntensity);
fan.FanSpeedIntensity.Returns(fanIntensity);

coolingSystem.gameObject.SetActive(true);

yield return null;

cpuTempControl.Received(1).GetHeatIntensity();

if (heatIntensity != fanIntensity)
{
var intensityColor = GetIntensityColor(heatIntensity);

fan.Received(1).UpdateFanSpeed(heatIntensity);
Assert.AreEqual(coolingIndicatorLight.color, intensityColor);
}
else
fan.DidNotReceive().UpdateFanSpeed(Arg.Any<IntensityLevel>());
}

[UnityTest]
public IEnumerator IntensityLevel_OutrageousDestroysCoolingSystem_Test()
{
cpuTempControl.GetHeatIntensity().Returns(IntensityLevel.Outrageous);
fan.FanSpeedIntensity.Returns(IntensityLevel.None);

coolingSystem.gameObject.SetActive(true);

yield return null;
yield return null;

Assert.IsTrue(coolingSystem == null);
}

private Color GetIntensityColor(IntensityLevel intensityLevel) =>
(Color) GetPrivateMethod(coolingSystem, "GetIntensityColor")
.Invoke(coolingSystem, new object[] {intensityLevel});

private void SetupIndicatorLightImage()
{
coolingIndicatorLight = coolingSystem.gameObject.AddComponent<Image>();
SetPrivateField(coolingSystem, "coolingIndicatorLight", coolingIndicatorLight);
}

public MethodInfo GetPrivateMethod(object someObject, string methodName) =>
someObject.GetType()
.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance);

private void SetPrivateField(object someObject, string fieldName, object value) =>
someObject.GetType()
.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance)?
.SetValue(someObject, value);
}
}

Now let’s go through that code:

  1. We start by defining variables that we are going to use in each test at the top.
  2. We have our SetUp method after our SetUpAttribute to tell Unity to run this common setup functionality before each unit test.
  3. Inside the SetUp function we create a mocked version of our interface dependencies and we bind them to our DiContainer including the expected Id for the ITemperatureControllable instance.
  4. Continuing with the setup, we create a GameObject which will contain our CoolingSystem class, but before we add the CoolingSystem and inject it into the DiContainer, we set it inactive to ensure that Awake/OnEnable don’t run on it before injection (even though we don’t actually use those methods, injecting before anything else is a good habit to get into).
  5. To finish off our setup, we create/resolve our final dependency, the Image. We do this by setting the private field with Reflection. That was a piece of cake!
  6. Now all of our dependencies are correctly resolved and we have done everything by code without the need for setting up additional “test” Prefabs in our project which I like to stay away from if I can.
  7. Now we tell Unity about our test by supplying the UnityTestAttribute before the function. I called our first test “Start_IndicatorLightColor_Test”.
  8. In this first test we are simply testing to make sure that the correct colour is set on the coolingIndicatorLight Image. We get a bit cheeky here by using Reflection to Invoke the private GetIntensityColor method to get the expected Color and test it against the Image’s color property. You may be asking yourself, “aren’t we supposed to be testing that class and assuming that it might not work as expected? If so, why are we invoking a private function inside the class?” You got me!! But my rebuttal is that the function is very simple and you could unit test the private function itself with Reflection to set your mind at ease. AND I wouldn’t have been able to show you that trick otherwise so don’t be so hard on me.
  9. Our next test, “UpdateFanSpeed_Test”, is actually multiple tests in one (25 to be exact, you can confirm in the Test Runner window) as it tests every combination of the input parameters based on the ValueSourceAttribute IntensityLevel array that we pass for each parameter.
  10. In this second test we are setting the mocked interface properties via the Returns method to match the input parameters and if heatIntensity and fanIntensity are not equal, we check to make sure that the UpdateFanSpeed method is called with the expected IntensityLevel and we ensure that the coolingIndicatorLight.color is set properly as before. If heatIntensity and fanIntensity are equal we check to make sure that the UpdateFanSpeed method was not called at all.
  11. Moving on to our final test, “IntensityLevelOutrageousDestroysCoolingSystem_Test”, we check to make sure the coolingSystem gets destroyed. Everything is quite similar to the previous test other than we add an additional yield statement to give the object an additional frame to become null.

You might notice that I turn the coolingSystem.gameObject back to active just before the first yield statement in each test and that is because I like to wait for all things to be setup before allowing the component I’m testing against to start running any code other than the Inject method.

I hope this article helps give you an idea of some useful tricks you can use while unit testing in Unity.

One thing that I didn’t cover here is to clean up the test scene between unit tests. It is a good idea to do a proper tear down by using the UnityTearDownAttribute and destroying all of the objects that you created for your unit test (not the ones that Unity uses for the test runner though).

In summary, you can test just about anything you need with the help from NSubstitute and Reflection. Sometimes you need to get a bit creative, but where there is a will, there is a way!!

Now you have no excuse to not keep that test coverage up!!

--

--

Kristopher C. Tobiasson
Kristopher C. Tobiasson

Written by Kristopher C. Tobiasson

Professional Freelance Unity 3D / Full Stack NodeJS Web Developer | Founder of Everdevs.com | Diehard Dad

Responses (1)