When I started working on my new project recently, I was thinking a lot about how I can unit test the code. I knew that if I left it for later and built some momentum with the game code, I would probably never take the time to come back and write tests. The challenge of writing unit tests for me is twofold. First and foremost, a game is different to many other types of software in that a good portion of the code is handling input, and visuals/graphics/UI. These are two notoriously “un-unit-testable” parts of a system. How do you, for example, write a test that checks that your grenade explosion looks right? (Do note that there are other kinds of tests that can be appropriate for such scenarios and at least stop regression, but not unit tests). The second challenge, is creating unit tests within Unity. In this post I’ll share some of my experiments and thoughts about unit testing with the Unity engine. This is certainly not a definitive guide on the subject and I’m sure I’ve made mistakes. I simply want to open up the discussion on the subject. I also want to thank Richard Fine with his help and feedback on this!
A Unit Testing Framework
I started looking around for a framework that I could use in MonoDev and Unity. There are in fact a couple of free solutions around like here (note: I work with C#).
Unity Editor Extension
I really needed to give a shout-out here to the Unity team for making it so easy extend the UI! I was pleasantly surprised by how quickly I could bake the test UI right into the editor menus and windows with simply implementing two methods, and this is the free version of Unity (check out this article for a more thorough description. Thanks Richard!). I am already thinking of all the other tools that I could add, including a data editor. One issue I did run into is that you cannot run MonoBehaviour code on a worker thread, and the calls to Repaint do not seem to repaint the UI immediately. As such, at the moment the test results only refresh after all tests have been run.
So What Can I Actually Test?
The million dollar question. The bottom line is, it’s impossible to test everything. And as mentioned earlier, graphics and visuals related code are not really well suited for unit testing. Here are a few conclusions I have arrived at so far which might be helpful when going throught this.
Separating Logic from Visuals
More specifically, having pure classes that do not derive from MonoBehaviour and ideally do not deal with GameObjects and other scene specific constructs. Your data access class(es) are an example of this. Or your AI related classes. There are more subtle cases where you can critically look at a MonoBehaviour and decide to refactor some of the logic inside the methods such as Update into a helper class that is more easily testable. Note that this doesn’t mean you cannot use any Unity types in such classes, however if they have dependency on the scene, other objects, components and their state, then things get a little tricky.
Speaking of data access, if you have been thinking about a data store for your game and sqlite is a viable option, do remember that that it supports an in-memory mode which is ideal for testing. Simply switch out the connection string during the test run with “:memory:” and you can bypass all the issues of dealing with files and speed up your tests.
Testing MonoBehaviours
Of course there is always logic in MonoBehaviours that you simply cannot take out and test in isolation. Now if you try to simply instantiate a MonoBehaviour in your script you will see this error in the console:
You are trying to create a MonoBehaviour using the ‘new’ keyword. This is not allowed. MonoBehaviours can only be added using AddComponent(). Alternatively, your script can inherit from ScriptableObject or no base class at all
A fair point. A MonoBehviour only really exists in the context of its parent object. If it’s not modifying anything about that object then it probably doesn’t need to be a MonoBehaviour. In order to get around this I put a simple utility class together, which looks something like this:
public class ScriptInstantiator { private List GameObjects { get; set; } public ScriptInstantiator() { GameObjects = new List(); } public T InstantiateScript<T>() where T : MonoBehaviour { GameObject gameObject; object prefab = Resources.Load("Prefabs/" + typeof(T).Name); // If there is no prefab with the same name, just use an empty object // if (prefab == null) { gameObject = new GameObject(); } else { gameObject = GameObject.Instantiate(Resources.Load("Prefabs/" + typeof(T).Name)) as GameObject; } gameObject.name = typeof(T).Name + " (Test)"; // Prefabs should already have the component T inst = gameObject.GetComponent<T>(); if (inst == null) { inst = gameObject.AddComponent<T>(); } // Call the start method to initialize the object // MethodInfo startMethod = typeof(T).GetMethod("Start"); if (startMethod != null) { startMethod.Invoke(inst, null); } GameObjects.Add(gameObject); return inst; } public void CleanUp() { foreach (GameObject gameObject in GameObjects) { // Destroy() does not work in edit mode GameObject.DestroyImmediate(gameObject); } GameObjects.Clear(); } } |
The InstantiateScript() method creates an appropriate prefab object for the script, or if one is not available just creates an empty object and the associated script instance. The Start() method is then called where available. If you are using other methods like Awake() that also needs to be called. Awake/Start/Update methods need to be public in this case so you can call them from your tests. I have to admit these are shaky waters because initialization of a MonoBehaviour is probably more complex and there may be situations where the code above is incomplete. But for the basic scenarios, this is fine. Another thing to note here is that I am loading the prefabs from the resources folder, and their name always matches that of the script. In a more complex project where the same script is used as a component of different prefabs, you may want to explicitly pass in the name of the prefab. Additionally there may be situations where you want to create a simplified prefab only for the purposes of testing. In those cases you should keep the test prefab in a folder outside of resources (e.g. Assets/TestPrefabs) to make sure it is removed from the production build.
The CleanUp method is called in you TearDown method to make sure the objects don’t stay around.
Here is an example test:
[Test] public void MovingEntitiesUpdatesConnector() { var source = ScriptInstantiator.InstantiateScript<Entity>(); var target = ScriptInstantiator.InstantiateScript<Entity>(); var connector = ScriptInstantiator.InstantiateScript<Connector>(); connector.SetSourceEntity(source); connector.SetTargetEntity(target, true); source.transform.position = new Vector3(-10.0f, 0.0f, 0.0f); target.transform.position = new Vector3(0.0f, 10.0f, 0.0f); connector.Update(); Assert.IsTrue(Vector3.Distance(connector.transform.position, source.transform.position) < 0.01f); Assert.IsTrue(Vector3.Distance(connector.EndPoint, target.transform.position) < 0.01f); } |
Not Every Test is a Good Test
One issue that Richard brought up during our discussion, was the trial-and-error nature of game development. Whilst most types of software are prone to design changes, I don’t think any of them involve the same level of going back and forth and tweaking features that games do. For that reason it is possible to write a bunch of tests that change very frequently and, in a way, become a burden. Of course there are any hard rules around this. Based on experience and just doing, we need to figure out what aspects of a piece of code can be tested and be expected not to change frequently, and what aspects are just too volatile. But we have to remember that all code can be subject to change and not be afraid of writing tests because we may need to change them.
It’s also good to remember that some unit tests are more useful later in the development cycle, at which point changes are less frequent. For example when a bug report comes in during the beta phase, you can write a test that exercises the bug and then write a fix. This way you are protecting your code against regression.
Again I have pointed to the source code on my blog here