Talo logoDocsBlog
Back to blog

Testing your game code with the Unity Test Framework

4 min read
Testing your game code with the Unity Test Framework

What is unit testing + why should I do it?

The best way of thinking about unit tests is that they should be designed to test small pieces of your systems in isolation. In Unity, this might be a single script or a helper function.

Unit tests tend to be faster to write since you're focused on one specific piece rather than how individual pieces fit together.

While there's always a time and place for larger end-to-end tests, typically when building a game you'll want to prioritise creating quick tests for complicated logic - most of the time just to sanity-check it.

What is the Unity Test Framework?

The Unity Test Framework is a convenient test runner built into Unity and based off the popular NUnit framework. Unity Test Framework abstracts away the complicated logic required to build and load your game before tests, allowing you to create Play-mode tests and Edit-mode tests. In this post we'll be focusing on Play-mode tests.

It is available as a package that you can install through the Unity Package Manager.

In this post we'll go over how to use the Unity Test Framework to write good tests. You'll need to setup the test runner and assemblies as described in the Unity docs to get started.

Your first test

For this post, we'll be looking at real-world tests we created for Talo to illustrate some of these examples. When you create your first test, you'll have a simple script that looks like this:

public class MyFirstTest
{
    // A UnityTest behaves like a coroutine in Play Mode. In Edit Mode you can use
    // `yield return null;` to skip a frame.
    [UnityTest]
    public IEnumerator MyFirstTestWithEnumeratorPasses()
    {
        // Use the Assert class to test conditions.
        // Use yield to skip a frame.
        yield return null;
    }
}

As you can see, Unity uses Coroutines to run tests. Coroutines are great because they allow you to pause execution and wait for actions to happen before resuming.

The [UnityTest] attribute decorates the function to allow us to use these coroutines to skip frames or enter Play-mode.

Lets create a test for the GetProp function from the following script:

public class LiveConfig
{
    private Prop[] props;

    public LiveConfig(Prop[] props)
    {
        this.props = props;
    }

    public T GetProp<T>(string key, T fallback = default(T))
    {
        try
        {
            Prop prop = props.First((prop) => prop.key == key);
            return ((T)Convert.ChangeType(prop.value, typeof(T)));
        }
        catch (Exception)
        {
            return fallback;
        }
    }
}

This function simply uses LINQ to search an array of key/value pairs (Props) and find the first one with a matching key. It then converts the value of that Prop to match the generic type. If we don't find a Prop with that key, we should return the fallback instead.

Testing the GetProp function

Appropriately, our first test should make sure that the function returns the expected result. Here's what that test would look like:

[UnityTest]
public IEnumerator GetProp_WithALiveConfigThatHasValues_ReturnsCorrectValue()
{
    var config = new LiveConfig(new[] { new Prop(("gameName", "Crawle")), new Prop(("halloweenEventEnabled", "True")) });

    Assert.AreEqual("Crawle", config.GetProp("gameName", "No name"));

    yield return null;
}

The first thing to note is the structure of the function name. NUnit tests typically have a standardised name made of up of 1) the name of the function you're testing, 2) the condition you're testing and 3) the expected outcome.

We're testing that when the GetProp function correctly finds the Prop with the key we're searching for when the LiveConfig has an array of Props. We're doing this because the LINQ First function throws when searching an empty array.

By using the Assert class, we can compare any number of different outcomes. In this case we're doing a simple equality check. Always remember to yield return null at the end of your test, to mark the coroutine as complete.

Luckily for us this function is really small and therefore easy to test. We wrote a few more tests to handle the cases for empty arrays, missing keys, etc. You can find all of those in our GitHub repo.

Handling pre-test setup

In the complicated world of game development it's pretty difficult to write totally isolated code. You'll usually have a number of dependencies that need to be in place before you can properly test what you need to test.

NUnit solves this problem by introducing the [OneTimeSetUp] attribute that runs the method it decorates before your tests.

The code below highlights a common scenario where you need some sort of "Manager script" to be ready before executing in the scripts that it manages:

public class UpdateSaveTest
{
    private TaloManager tm;

    [OneTimeSetUp]
    public void SetUp()
    {
        tm = new GameObject().AddComponent<TaloManager>();
        tm.settings = ScriptableObject.CreateInstance<TaloSettings>();
    }

    [UnityTest]
    public IEnumerator UpdateSave_InOfflineMode_UpdatesTheSaveName()
    {
        var api = new SavesAPI(tm);
        Talo._saves = api;

        api._allSaves.Add(new GameSave() { id = -1, name = "Offline Save" });
        api.WriteOfflineSavesContent(new OfflineSavesContent(api._allSaves.ToArray()));

        _ = api.UpdateSave(-1, "New Name");

        Assert.AreEqual(1, api.GetOfflineSavesContent().saves.Length);
        Assert.AreEqual("New Name", api.GetOfflineSavesContent().saves[0].name);

        yield return null;
    }
}

If you've used Talo (and if you haven't - you should definitely check it out), you know that it requires a settings asset. This asset is passed to the TaloManager which passes these settings down to individual APIs.

Here, we're testing the SavesAPI. We need the SavesAPI to have a manager and that's what we're setting up in the SetUp function before our test runs.

NUnit has a number of other convenient attributes like [SetUp] that runs before each test and [TearDown] that runs after each test to keep your test environment clean and avoid flaky tests.

Quick tips for writing unit tests

Different people have different thoughts on what makes a good unit test. Sometimes testing feels like uncharted territory in the world of game development, but here are some thoughts about how to go about it:

  1. Pick your battles. Some things just work so trying to test everything will inevitably just slow you down. For example, test a weird interaction between two items over a basic 2D movement script.
  2. Keep your tests small. In Unity, that usually means testing functions within scripts but maybe not the entire script.
  3. Mock as much as possible. Don't re-write code you've already written just for a test, try and find clever workarounds to prevent writing duplicate code.

Closing thoughts

Unit tests bring a level of confidence you otherwise wouldn't have in a piece of code, however they take time to create and need to be maintained going forward.

This post only scratches the surface on helpful attributes and assertions provided by NUnit and the Unity Test Framework. You can test specific platforms, move from Edit-mode to Play-more and more. Check out the Unity Test Framework docs for additional use-cases.

Testing has sometimes felt a bit abstract in game development and we've only just started the process of adding tests to the Talo Unity SDK which you can see in our repo.


TudorWritten by Tudor

Build your game faster with Talo

Integrate leaderboards, stats, event tracking and more. Using Talo, you can view and manage your players directly from the dashboard. Sign up for free to get started.

Get started

More from the Talo Blog

3 min read

Tracking player events and actions in your Unity game

Want to know what your players are doing in your game? Use Talo to easily track player events. Read more

TudorWritten by Tudor
3 min read

How to start tracking player stats in your Unity game

Find out how to track stats like the number of levels players have completed on an individual basis or total across your game. Read more

TudorWritten by Tudor