Learn how to automate testing for a 3D application built with Unity using GameDriver.
Download the 3D Game Kit from Unity
Once you have completed the 2D Game Kit Automation Tutorial and reviewed Testing the 2D Game Kit: the "correct" way, you may wonder how to apply these concepts to a 3D or VR project. This article will describe how to use similar concepts in a 3D application/game, where the "player" has 6-degrees of freedom of interaction.
This tutorial will demonstrate 3 tests, each with a unique objective:
- Movement
- UI Interactions
- Gameplay
To follow this tutorial, you will need to download the 3D Game Kit from the Unity Asset Store and import this asset into a new 3D project. The 3D Game Kit is a demo project from Unity, intended to teach the basics of implementing a 3rd-person 3D platformer-style game. It includes many of the assets and controls found in a typical game of this variety, such as puzzles, terrain, enemies, and levels, and is accompanied by an in-depth tutorial for modifying such a game or even implementing your own.
Once you have imported the project, download the latest version of GameDriver for your version of Unity, and add the agent to an otherwise empty object.
The GameDriver agent added to the initial "Start" scene.
Before we get into the definition of tests, be sure to follow the Getting Started guide to set up your base test project if you are unfamiliar with how to create a basic GameDriver test library.
Starting the Game
Before we get to specific tests, we need to start the game. You will note that this project has a very similar loading screen to the 2D Game Kit found in our previous tutorials. They are so similar, that we can essentially reuse the code from our 2D tests, with one small change. We want to check whether we're at the Start scene here before attempting to click the StartButton. Otherwise, we can assume we're on Level 1 and continue. If neither of these is true, the test will fail.
if (api.GetSceneName() == "Start")
{
api.WaitForObject("//*[@name='StartButton']");
api.ClickObject(MouseButtons.LEFT, "//*[@name='StartButton']", 30);
api.Wait(2000);
api.WaitForObject("//*[@name='Ellen']");
}
Assert.AreEqual("Level1", api.GetSceneName());
We could remove this assertion and add a step to load the desired scene within each test, but for the sake of this article, we will be focusing on the Level 1 scene.
Test 1 - Movement
The 3D Game Kit uses the legacy Input Manager for input, as noted in the Game Settings > Player Settings > Other dialog:
The implementation of these controls can be found in the EventSystem object of the initial Start scene:
A simple test here would be to ensure that user input moves the player character. To do this, we will capture the initial position of the Ellen character, perform some movements, and then compare the new position to that of the original.
[Test, Order(0)]
public void TestMovementInputs()
{
// Wait for the Player to become active
api.WaitForObject("//*[@name='Ellen']");
// Get the initial position of the player and output to the log
Vector3 ellenPos = api.GetObjectPosition("//*[@name='Ellen']");
Console.WriteLine($"Original position is:" + ellenPos.ToString());
// Move the Player along both axis, positive and negative
var fps = (ulong)api.GetLastFPS();
api.AxisPress("Horizontal", 1f, fps * 2);
api.Wait(1000);
api.AxisPress("Vertical", 1f, fps * 2);
api.Wait(1000);
api.AxisPress("Horizontal", -1f, fps * 2);
api.Wait(1000);
api.AxisPress("Vertical", -1f, fps * 2);
api.Wait(1000);
// Check the final position of the player, compare to the initial, and output to log
Vector3 newPos = api.GetObjectPosition("//*[@name='Ellen']");
Console.WriteLine($"New position is:" + newPos.ToString());
Assert.AreNotEqual(ellenPos, newPos, "Ellen didn't move!");
}
We're using the same "GetLastFPS" trick here that was discussed in the 2D Game Kit tutorial to somewhat normalize the duration of the inputs so they should be similar across machines of different performance levels. Alternatively, we could hard code the number of frames for each input, such as AxisPress("Horizontal", 1f, 30). Note when using the AxisPress command, there is a positive and negative for each, which is defined by the second argument - '1f' for positive, and '-1f' for negative. The game engine will accept arguments other than +/- 1f, but this is not supported by the engine nor is it recommended.
Here is the Movement Test in action:
Test 2 - Camera Movement
Much like the first test, we want to ensure we can move the camera. This simple test also uses the AxisPress inputs to perform the same action a user would using the Mouse and has much the same structure as the previous test.
[Test, Order(1)]
public void TestCameraMovement()
{
// Wait for the player character to become active
api.WaitForObject("//*[@name='Ellen']");
// Check the initial position of the camera
Vector3 initialCameraPos = api.GetObjectPosition("//MainCamera[@name='CameraBrain']");
// Move the camera along both axis
var fps = (ulong)api.GetLastFPS();
api.AxisPress("CameraX", 1f, fps * 2);
api.AxisPress("CameraY", 1f, fps * 2);
api.Wait(5000);
// Check the new position of the camera, and compare to the initial
Vector3 newCameraPos = api.GetObjectPosition("//MainCamera[@name='CameraBrain']");
Assert.AreNotEqual(newCameraPos, initialCameraPos, "Camera didn't move!");
}
The expected output here is that the camera visibly rotates around the Ellen character and that the position of the camera is changed.
Test 3 - Menu Activation
This short test will check that the menu pops up correctly when we hit Pause. This test can easily be extended to perform additional UI activities such as navigating the menu options by clicking each button, and then testing whether the subsequent options are appearing as expected. It is recommended to separate these tests, to avoid flakiness (one test depending on another).
[Test, Order(2)]
public void TestMenu()
{
// It shouldn't matter where we are, the menu will appear
api.ButtonPress("Pause", 30, 30);
api.Wait(1000);
// Check that the menu appeared
Assert.IsTrue(api.GetObjectFieldValue<bool>("//*[@name='PauseCanvas']", "active"), "Menu didn't appear!");
api.ButtonPress("Pause", 30, 30);
api.Wait(3000);
}
Note that the above test checks whether an object becomes active after pressing the Pause button, verifying that the active field is true.
Test 4 - Enable Melee Attacks
The next few tests will check certain aspects of gameplay, starting with enabling the player character to perform melee attacks. In the game, this is done by moving the player to the staff found early in the level. However, to avoid flakiness we will simply move the player to the position of the staff directly, then check whether the appropriate field has changed.
[Test, Order(3)]
public void GetWeaponToEnableAttacks()
{
// If the weapon is active, go get it
if (api.GetObjectFieldValue<bool>("(//*[@name='Staff'])[1]/@active") == true)
{
// Move to the staff to enable melee attacks
api.SetObjectFieldValue($"//*[@name='Ellen']/fn:component('UnityEngine.Transform')", "position", api.GetObjectPosition("(//*[@name='Staff'])[1]", CoordinateConversion.None));
api.Wait(1000);
}
// Check that we can attack now
Assert.IsTrue(api.GetObjectFieldValue<bool>("/Untagged[@name='Ellen']/fn:component('Gamekit3D.PlayerController')/@canAttack"), "Melee not enabled!");
}
This is done by first checking whether the Staff is active in the scene, then setting the Transform.position property of the Ellen character to that of the Staff, then checking that the canAttack property of the PlayerController component is set to true.
Test 5 - Kill Every Enemy
This next test was fun to make and is equally fun to watch. The goal of this test is to check that we can "kill" the enemies scattered throughout the level, by locating each one and positioning the player near enough to perform a melee attack. To make the mode easier to read, we wrote a couple of helper functions which we will describe first.
First, we have the helper function "CloseToObject" which takes the HPath of an object and returns a Vector3 position "near" that object. This is done in 3 steps for readability, but could easily have been written in 2. The -1f used for the x and z coordinates was determined using a little trial and error. Any more, and the melee attacks wouldn't hit the enemy, and any less would potentially put the player inside the target object.
Vector3 CloseToObject(string HPath)
{
Vector3 initialPos = api.GetObjectPosition(HPath);
Vector3 returnPos = new Vector3(initialPos.x - 1f, initialPos.y, initialPos.z - 1f);
return returnPos;
}
Second, we have the SetObjectPosition helper function, which simply places an object at a specified Vector3 position. This is useful in many tests and can reduce the amount of duplicate code you need to write.
public void SetObjectPosition(string HPath, Vector3 pos)
{
api.SetObjectFieldValue($"{HPath}/fn:component('UnityEngine.Transform')", "position", pos);
}
Now that we have a few helpers, let's look at the main test.
First, we need to make sure melee attacks are enabled or the entire test will fail. Instead of moving the player to the staff as we did in Test 4, we are first going to check whether the canAttack field is set to true then set that value directly using the CallMethod command if needed. This way, even if Test 4 fails we can continue to run this test.
// If the melee attack isn't enabled, enable it
if (api.GetObjectFieldValue<bool>("/Untagged[@name='Ellen']/fn:component('Gamekit3D.PlayerController')/@canAttack") == false)
{
// Enable melee attack by calling the method directly
api.CallMethod("/Untagged[@name='Ellen']/fn:component('Gamekit3D.PlayerController')", "SetCanAttack", new object[] { true });
}
Next, we look for any Chomper objects in the scene, then set the position of our Ellen character using our CloseToObject helper, followed by calling the built-in Transform.LookAt method of the player to turn to face the Chomper, and finally hit the Fire1 button to kill the enemy.
try
{
while (api.WaitForObject("//*[@name='Chomper']", 5) != false)
{
Vector3 dest = CloseToObject("//*[@name='Chomper']");
Vector3 target = api.GetObjectPosition("//*[@name='Chomper']", CoordinateConversion.None);
SetObjectPosition("//*[@name='Ellen']", dest);
api.Wait(100);
// LookAt the target object
api.CallMethod("//*[@name='Ellen']/fn:component('UnityEngine.Transform')", "LookAt", new Vector3[] { target });
api.Wait(300);
api.ButtonPress("Fire1", 30, 30);
api.Wait(500);
}
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
Assert.IsFalse(api.WaitForObject("//*[@name='Chomper']", 5), "We missed one!");
This is all wrapped in a Try/Catch block simply because the NUnit test would fail if the initial WaitForObject in our while loop were to return false. To counter this, our final test is using the Assert.IsFalse assertion to make sure we didn't miss anything.
Here is what it looks like when we put it all together:
Conclusion
This article demonstrates some useful ways to test the functionality of a 3D project. We tested game functionality and gameplay using simple, self-contained test code. You can download the full test project here.
Using these methods, you could easily write additional tests to solve the puzzle (hint: touch the 3 stepping stones) in Level1 which opens the door to a boss enemy, and even defeat that enemy by striking it from behind - which might be a little tricky to solve since it would involve finding the position and orientation of that enemy, then set the position of the Ellen character behind that enemy but close enough to attack (remember our helper functions?) before pressing the attack button. This is an exercise for the reader.
Until next time, happy testing!