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 its own objective:

  1. Movement
  2. UI Interactions
  3. 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, simply 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 Level1 and simply 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 an additional step to load the desired scene within each individual test, but for the sake of this article, we will be focusing on the Level1 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 make sure that user input actually moves the player character. To do this, we will capture the initial position of the Ellen character, perform some movements, then compare the new position to that of the original.

[Test, Order(1)]
public void TestMovementInputs()
{
    api.Wait(3000);
    api.WaitForObject("//*[@name='Ellen']");
    Vector3 ellenPos = api.GetObjectPosition("//*[@name='Ellen']");
    Console.WriteLine($"Original position is:" + ellenPos.ToString());

    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);

    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 are able to 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(2)]
public void TestCameraMovement()
{
    api.Wait(3000);
    api.WaitForObject("//*[@name='Ellen']");

    Vector3 initialCameraPos = api.GetObjectPosition("//MainCamera[@name='CameraBrain']");

    var fps = (ulong)api.GetLastFPS();

    api.AxisPress("CameraX", 1f, fps * 2);
    api.AxisPress("CameraY", 1f, fps * 2);
    api.Wait(5000);

    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, 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(3)]
public void TestMenu()
{
    //Shouldn't matter where we are, the menu will appear
    api.ButtonPress("Pause", 30, 30);
    api.Wait(1000);

    Assert.IsTrue(api.GetObjectFieldValue<bool>("//*[@name='PauseCanvas']", "active"), "Menu didn't appear!");

    api.ButtonPress("Pause", 30, 30);
    api.Wait(3000);
}

Note the above test is checking whether an object becomes active after pressing the Pause button, by verifying the active field is set to 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.


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, Order(4)]
public void GetWeaponToEnableAttacks()
{
    // If the weapon is actuve, 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!");
}


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.

There is a lot going on here, so let's walk through it in stages. 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
api.CallMethod("/Untagged[@name='Ellen']/fn:component('Gamekit3D.PlayerController')", "SetCanAttack", new object[] { true });
}

Next, we're looking 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 ourselves to face the Chomper, and finally hit the Fire1 button to kill the enemy.


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.

    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!");

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.


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!