Unity UI test automation: how to write tests for your game interface
The Unity Test Framework provides a basic way to write automated tests for your game. While it's fairly easy to test simple game objects with Unity, making sure the user interface works as expected is another story.
It's because the UI requires more than unit tests, since it interacts with the rest of the game. Writing integration tests is required instead. This is a slightly harder task, and this post will show you how to do that.
In this tutorial we will take a simple match-3 game and write Unity tests to ensure the UI works properly. To follow the tutorial it is assumed you know how to use the Unity Test Runner. If you're not familiar with it, we recommend checking out our Tutorial on how to get started with the Unity Test Framework.
Table of Contents
The sample project
For this tutorial, we will use a sample project available on Github. Download the project by clicking Code > Download Zip.
Open the project with Unity. Open the Scenes/Game.unity
scene.
Launch the scene and play the game to have a sense of how it works. It's a simple match-3 game, you click on candies to swap them and make them disappear. There is a limited number of moves and the score goes up with the number of matches.
In the rest of the post, we will write a series of integration tests so we can check the game works as expected.
Checking if the game starts
The first of our automated tests will ensure the menu can start the game. Here is our plan of action:
- Load the Menu scene
- Locate the Play button
- Simulate a click on it
- Assert the current scene has changed to the Game scene
In the Unity project, go to Match 3 Starter/Tests and create a new C# Test Script. Rename it MenuSuite
.
Open the file and replace the content with the following code:
using System.Collections;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
namespace Tests
{
public class MenuSuite: InputTestFixture
{
}
}
Here we're using the InputTestFixture
class to simulate inputs. If you never used it previously, we recommended you to read our Unity Test Framework tutorial covering it.
We need to load the Menu scene, and to do so we will create a Setup
method:
public override void Setup()
{
base.Setup();
SceneManager.LoadScene("Match 3 Starter/Scenes/Menu");
}
This will load the scene every time before running a test.
Create a new UnityTest
:
[UnityTest]
public IEnumerator TestGameStart()
{
yield return null;
}
The next step is to locate the Play button. If you look in the Menu scene hierarchy you can find the button under MenuCanvas
. Since it is the only button named PlayButton
we can use the Find
method:
[UnityTest]
public IEnumerator TestGameStart()
{
GameObject playButton = GameObject.Find("MenuCanvas/PlayButton");
yield return null;
}
Now that we have the play button, we need to click on it. To achieve that, we need a virtual mouse, move it to the button's location and simulate a click.
Let's add a mouse to our class:
public class MenuSuite: InputTestFixture
{
Mouse mouse;
public override void Setup()
{
base.Setup();
SceneManager.LoadScene("Match 3 Starter/Scenes/Menu");
mouse = InputSystem.AddDevice<Mouse>();
}
We will create a new method, named ClickUI
, that clicks on a given GameObject
:
public void ClickUI(GameObject uiElement)
{
Camera camera = GameObject.Find("Main Camera").GetComponent<Camera>();
Vector3 screenPos = camera.WorldToScreenPoint(uiElement.transform.position);
Set(mouse.position, screenPos);
Click(mouse.leftButton);
}
This method finds the camera in the scene and transposes the GameObject
's position into screen coordinates. We then set the mouse to these coordinates, and simulate a click event.
Important: transposing coordinates works here because the camera and the canvas are rendered with the exact same resolution. If your game has different settings, then more calculations are needed to get the proper coordinates of the object on the screen.
Now that we have this method, we can use it in our code:
[UnityTest]
public IEnumerator TestGameStart()
{
GameObject playButton = GameObject.Find("MenuCanvas/PlayButton");
ClickUI(playButton);
yield return new WaitForSeconds(2f);
}
If you run this, you will see the Menu being loaded, the Play button being clicked and then the scene switches to the game. However, we didn't use any assertion so let's add some:
[UnityTest]
public IEnumerator TestGameStart()
{
GameObject playButton = GameObject.Find("MenuCanvas/PlayButton");
string sceneName = SceneManager.GetActiveScene().name;
Assert.That(sceneName, Is.EqualTo("Menu"));
ClickUI(playButton);
yield return new WaitForSeconds(2f);
sceneName = SceneManager.GetActiveScene().name;
Assert.That(sceneName, Is.EqualTo("Game"));
}
Now we have made sure the original scene was the menu and then switched to the game.
Congratulation! You've successfully written a UI test automation.
The move counter
For our second automated test, we will check that for every candy swap, the number of moves goes down. Here is our plan of action:
- Load the Game scene
- Locate the moves counter
- Swap 2 candies
- Assert the moves counter went down
In Unity, go to Match 3 Starter/Tests and create a new test script. Rename it GameSuite
. Open the file and replace the content with the following code:
using System.Collections;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Controls;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
namespace Tests
{
public class GameSuite: InputTestFixture
{
Mouse mouse;
public void ClickUI(GameObject uiElement)
{
Camera camera = GameObject.Find("Main Camera").GetComponent<Camera>();
Vector3 screenPos = camera.WorldToScreenPoint(uiElement.transform.position);
Set(mouse.position, screenPos);
Click(mouse.leftButton);
}
public override void Setup()
{
base.Setup();
SceneManager.LoadScene("Match 3 Starter/Scenes/Game");
mouse = InputSystem.AddDevice<Mouse>();
}
}
}
Note that we're copying the ClickUI
method since we will use it again. Instead of duplicating code, it would be better to create an intermediary class with all the utility methods and inherit from it. But let's keep it simple for the tutorial.
Let's create a new automated test named TestMoveCounterDecrease
. The property for the number of moves is hold by GUIManager.instance.MoveCounter
. Let's set its value to make sure the outcome is predictable, independently of the default value. Then we can check if this change is reflected in the UI:
[UnityTest]
public IEnumerator TestMoveCounterDecrease()
{
GUIManager.instance.MoveCounter = 10;
GameObject moveCounterTxt = GameObject.Find("MoveCounterImage/MoveCounterTxt");
string movesLeft = moveCounterTxt.GetComponent<Text>().text;
Assert.That(movesLeft, Is.EqualTo("10"));
yield return null;
}
Now we're sure the number of moves that are left is 10, we can swap candies, and see if it goes down. To do so we need to retrieve the relevant tiles (candies) from the scene. There is an arbitrary number of them, and their position is also changing according to it. Fortunately, the game gives us access to the grid via the property BoardManager.instance.tiles
. We simply have to give the ones we want to ClickUI
:
[UnityTest]
public IEnumerator TestMoveCounterDecrease()
{
GUIManager.instance.MoveCounter = 10;
GameObject moveCounterTxt = GameObject.Find("MoveCounterImage/MoveCounterTxt");
string movesLeft = moveCounterTxt.GetComponent<Text>().text;
Assert.That(movesLeft, Is.EqualTo("10"));
ClickUI(BoardManager.instance.tiles[0, 0]);
yield return new WaitForSeconds(1f);
ClickUI(BoardManager.instance.tiles[1, 0]);
yield return new WaitForSeconds(2f);
movesLeft = moveCounterTxt.GetComponent<Text>().text;
Assert.That(movesLeft, Is.EqualTo("9"));
}
Run the test and observe how it swaps the top left candies, then pass successfully.
The game over menu
When there are no moves left, the game is over. There is an overlay showing up with the score, and the ability to play again or go back to the menu.
To check the replay button we will build upon the previous tests:
- Load the Game scene
- Swap candies a few times
- Once the game is over, click on the play button.
- Assert the overlay is gone and the board has changed
[UnityTest]
public IEnumerator TestGameOverPlayAgain()
{
GUIManager.instance.MoveCounter = 3;
BoardManager oldBoard = BoardManager.instance;
for (int i = 0; i < 3; i++) {
ClickUI(BoardManager.instance.tiles[0, 0]);
yield return new WaitForSeconds(1f);
ClickUI(BoardManager.instance.tiles[1, 0]);
yield return new WaitForSeconds(1f);
}
GameObject playButton = GameObject.Find("GameOverPanel/PlayButton");
ClickUI(playButton);
yield return new WaitForSeconds(2f);
string sceneName = SceneManager.GetActiveScene().name;
Assert.That(sceneName, Is.EqualTo("Game"));
Assert.That(GUIManager.instance.gameOverPanel.activeSelf, Is.EqualTo(false));
Assert.That(BoardManager.instance, Is.Not.EqualTo(oldBoard));
}
Now, let's check if the menu button works as well. The code is almost identical:
[UnityTest]
public IEnumerator TestGameOverBackToMenu()
{
GUIManager.instance.MoveCounter = 3;
BoardManager oldBoard = BoardManager.instance;
for (int i = 0; i < 3; i++) {
ClickUI(BoardManager.instance.tiles[0, 0]);
yield return new WaitForSeconds(1f);
ClickUI(BoardManager.instance.tiles[1, 0]);
yield return new WaitForSeconds(1f);
}
GameObject playButton = GameObject.Find("GameOverPanel/MenuButton");
ClickUI(playButton);
yield return new WaitForSeconds(2f);
string sceneName = SceneManager.GetActiveScene().name;
Assert.That(sceneName, Is.EqualTo("Menu"));
}
Testing a simple match
So far we've only checked the integration of different menus, text labels, and scene transitions. To go further we can test the core gameplay, which is matching candies together. Here is our plan of action:
- Load the Game scene
- Force the BoardManager to render a given grid of candies chosen for the test
- Swap the relevant candies
- Assert the score has gone up
The important part here is to provide a given grid of candies designed for the test. Once again, the Unity project has been built well enough to accept that.
Let's create a new automated test, and construct a grid of letters. Each letter will correspond to a color: B for blue, R for red, etc:
[UnityTest]
public IEnumerator TestScoreSimpleMatch()
{
string[,] grid = new string[3,3]{
{"Y", "B", "M"},
{"Y", "R", "G"},
{"P", "Y", "M"}
}; // Note that the grid's x and y dimensions are inverted here.
BoardManager.instance.InitializeBoard(grid);
yield return new WaitForSeconds(2f);
}
If you run it as is, you will see a new 3x3 board of candies corresponding to the given colors. Now our task is to select the yellow candy at the bottom and swap it with the purple one on its left:
[UnityTest]
public IEnumerator TestScoreSimpleMatch()
{
string[,] grid = new string[3,3]{
{"Y", "B", "M"},
{"Y", "R", "G"},
{"P", "Y", "M"}
}; // Note that the grid's x and y dimensions are inverted here.
BoardManager.instance.InitializeBoard(grid);
yield return new WaitForSeconds(1f);
ClickUI(BoardManager.instance.tiles[1, 2]);
yield return new WaitForSeconds(1f);
ClickUI(BoardManager.instance.tiles[0, 2]);
yield return new WaitForSeconds(2f);
}
Finally, we add the relevant assertions to make sure the score increased:
[UnityTest]
public IEnumerator TestScoreSimpleMatch()
{
GameObject scoreTxt = GameObject.Find("ScorePanel/ScoreTxt");
string[,] grid = new string[3,3]{
{"Y", "B", "M"},
{"Y", "R", "G"},
{"P", "Y", "M"}
}; // Note that the grid's x and y dimensions are inverted here.
BoardManager.instance.InitializeBoard(grid);
string score = scoreTxt.GetComponent<Text>().text;
Assert.That(score, Is.EqualTo("0"));
yield return new WaitForSeconds(1f);
ClickUI(BoardManager.instance.tiles[1, 2]);
yield return new WaitForSeconds(1f);
ClickUI(BoardManager.instance.tiles[0, 2]);
yield return new WaitForSeconds(2f);
score = scoreTxt.GetComponent<Text>().text;
Assert.That(score, Is.EqualTo("150"));
}
Bonus: more efficient waits
You might have noticed we used waits with hard-coded value during the tutorial. For simple tests like those, waiting for a specific number of seconds might be good enough. But more efficient waits would allow us to check if a condition is fulfilled, making the test more flexible and robust.
If you're interested about learning how to do that, you can refer to our previous tutorial on efficient waits.
Going further with UI test automation
In this tutorial we've learn how to automatically test Unity UI objects, scene transitions, and the use of input simulation. Here are some advice if you want to go further with UI test automation:
- Design your game objects for them to be tested. As we saw with
BoardManager
, the class has relevant public properties and methods for the convenience of testing. If you have a complex object, it is good to expose some of its parts, in order for tests to be easier to write. - Pay attention to the name of the objects you're looking for. Two objects can have the same name in different parts of the hierarchy. Use their parents or even tags (via the
FindWithTag
method) to narrow it down. - Don't hesitate to write your own utility classes in order to not repeat yourself, and make testing more concise.