Testing in Unity

8 min read

In this blog post I would like to show you the testing framework I created in Unity during the development of Coral Cove. I will walk you through how it is set up and demonstrate why it can be a good idea to integrate automatic testing early in your game project.

Overview

Coral Cove is a top-down match 3-like underwater that has been in development for about a year now. In it, you try to build the largest reef without running out of corals to place.

As of now, we have a total of 1059 tests which consist mainly of play mode, as well as some edit mode tests.

test list
Test runner window showing the list of play mode tests.

These tests are responsible for testing core systems of our game, including gameplay functionality, UI integrity, game state serialization and deserialization, achievements and the game features the player can unlock.

Why Should You Even Write Tests?

Testing in software development is a crucial part to ensure that established systems are still functional after a change in the source code.

In Coral Cove we had to rewrite large portions of our code base to enable us to implement more features down the line. Unknowingly introducing bugs is a common issue while coding. Therefore, a good portion of your time when implementing a larger system will go into debugging and fixing your code. This is exactly where tests can help you save precious time that you probably want to spend on developing new gameplay features instead.

Testing is one of the few things when failure can be a good thing. When accidentally making breaking changes to a part of your code that is covered by a test, it will fail and show you exactly why and what features you broke. With this information you are able fix it more quickly and also stop new bugs from entering your code base.

One example where this was helpful for us in our game, was when a change in the gameplay code prohibited the player from placing on a certain tile in the tutorial. Since in theory the change had nothing to do with the tutorial level, we didn’t manually test this part of the game and were only made aware of the bug through a failing test. We also implemented several tests that ensure that the player can unlock all achievements in the game. Testing these probably saves us the most time, since manually unlocking all achievements every time a game feature is added or changed would be very tedious and time consuming. Overall, even with just a few tests in place, finding the source of a bug and fixing it became only a matter of minutes once a test failed.

Some other tests we had in our game include UI tests, especially for the card stack in the game. These tests validate the visual state and the data behind it. This proved to be very helpful when I did performance optimizations in the UI to ensure that it still worked correctly afterwards.

card stack
Card stack UI in the game.

Everyone in your development team working with a code base that is covered by tests, will probably appreciate the safety they get when making changes, because they can see in an instant if anything breaks. Tests further provide a way to do quality assurance for your game, by ensuring that your players are not getting frustrated by encountering game breaking bugs while playing your game.

You can even go as far as writing tests before writing the actual implementation of your desired game feature. In test driven development (TDD) you get the benefit of having a test ready since day one. Some of our tests were implemented with this style of development. Personally, I think TDD can be fun, but like every method of developing, it is not the solution to all problems. When developing games or game prototypes, features often undergo a rapid iteration process which will eventually render your tests outdated, forcing you to update them again and again to keep them valid.

Inside the Machine

The base package that we use is the Unity Test Framework. It provides a version of NUnit which allows you to write and run tests. Getting started is as simple as opening the package manager in Unity and installing the package. Although this provides you with a good starting point already, we additionally use other packages and libraries to make writing tests as a developer easier (most of the time).

Zenject

We decided to use Zenject as a dependency injection framework early on in our development. Dependency injection can have several advantages over the infamous Singleton pattern, however I won’t go into details here, as it would blow up the scope of this blog post. When it comes to writing tests, several challenges will arise since the underlying game code that is being tested is depending on Zenject to work properly. For one, the way it operates during and between test instances is quite different, as it adds another layer to the whole testing framework.

[TestFixture]
public class SceneTests : SceneTestsBase
{
    // ...
}

On the surface it doesn’t differ much from a typical play mode test class. The only difference is that the base class handles the scene context initialization for Zenject.

Most problems arise when we try to test scenarios that span multiple scenes. For example when writing a test that starts in the main menu and proceeds to the main scene where the actual gameplay is happening. For that it was helpful to have a single Game object that acts as a facade to interact with the systems behind it. Since the object’s context is bound to the current scene it needs to reinitialize itself after a scene change has occurred.

The Game instance can then easily be instantiated for every test in the beginning. It contains access to all available controllers and systems, as well as helper methods to quickly set up scenarios that are used several times in all tests.

[UnityTest]
public IEnumerator Game_QuestIsClearedByPlacingCoral_QuestLevelIsReset()
{
    var game = new Game(this);
    yield return game.SetupInitialScene(SceneNames.Main);

    Tile questTile = game.GridManager.GetTile(new Vector2Int(1, 0));
    game.TileAttributeCreator.MakeQuest(questTile, 2);

    yield return Wait();

    game.CardDistributor.AddCards(2);
    game.PlaceNextPlaceableOnTile(questTile);

    questTile.State.QuestLevel.Should().Be(0);

    yield return Wait(3); // Wait for animation to complete

    questTile.VisualState.QuestLevel.Should().Be(0);
}

Fluent Assertions

Another library we use is Fluent Assertions. This library is purely syntactic sugar and only makes writing and reading the assertions in a test a little bit easier.

// Default assertion methods
Assert.Equals(questTile.State.QuestLevel, 0);

// Fluent assertions methods
questTile.State.QuestLevel.Should().Be(0);

As the name suggests, it provides a fluent interface for assertions. It also outputs easier to read error messages when an assertion fails.

Time Control

When you want to wait for a specific amount of time in your gameplay code, you are likely going to use coroutines in Unity. We extensively use a delay package in our game that wraps the boilerplate code of coroutines and simplifies it into a single line of code. We use it a lot to trigger actions after animations have finished playing or for enhancing the user experience when navigating through multiple UI views. This means that most of our play mode tests require some sort of time control where we need to pause the test and wait for an action to complete in the game. We wrote our own helper functions that take care of this.

// We can wait for e.g. 5 seconds
yield return Wait(5);

// Or we can wait until a certain condition is met
// With an additional timeout if the condition would never evaluate to true
yield return WaitUntil(() => hasReceivedUpgrades, 30f);

Automation

Having tests is great, but they are useless if not run. We use NUKE for configuring and executing our build pipeline. After the preparation and compile stage is finished, both edit and play mode tests are run. The test results are parsed and checked for any failed tests at the end. This makes it very easy to validate that a release candidate of our game passed all tests before we upload it to e.g. Steam.

Testing Pitfalls

"There are only two hard things in Computer Science: cache invalidation and naming things."

Naming your tests is a crucial part of writing them. Personally, I like to follow the naming guidelines from here. It boils down to having three parts for the name of your tests:

  1. The name of the method that is being tested
  2. The scenario for the test
  3. The expected result at the end of the test

A lot of our tests are play mode tests. This means that most of the time we don’t actually test a single method, as it is common in unit tests. I then often replace the first part with a more general action that is related to the scenario that is being tested. So for example if we want to test the save game feature, where the game is saved to disk and it is possible to save without any errors we could come up with the following name:

public IEnumerator SaveGame_GameIsSaved_GameIsSavedWithoutErrors()
{
  // ...
}

Don’t be afraid to be specific when naming your tests. Your test names can get very long, like one of our tests:

public IEnumerator SaveGame_FeaturesAreDisabledThenGameIsSavedThenFeaturesAreEnabledThenGameIsLoaded_AllFeaturesAreDisabledAfterLoadingSaveFile()
{
  // ...
}

The benefit you get from naming your tests as explicitly as possible is that, once one of your tests fails, you do not have to dig through the whole code of the test to get an idea of what might be the cause of failure.

However, if your test is not specific enough and asserts too many things at once, it might be a good idea to split it into smaller, more explicit tests. This can again help with identifying a problem faster, since atomic tests fail closer to the source of the problem.

All things considered, setting up a testing framework and writing tests takes a lot of time for sure. Nonetheless, they can save you a lot of time by finding bugs early or even preventing new ones from being introduced unnoticeably.