Using GameDriver with the Unity Test Framework (UTF)

Learn how to leverage GameDriver alongside the Unity Test Framework (UTF)

With the 2.0 release in June 2021, GameDriver is compatible with the Unity Test Framework allowing for test execution as part of the Unity build process, including using Unity Cloud Builds or any other build platform that is supported for building Unity apps. This new feature provides additional flexibility in where and how you test your application but is subject to the requirements and limitations of the Unity Test Framework itself.

This support is provided under the CoApiClient in the gdio.unity_utr_api library. Documentation for this can be found in our GitHub repository alongside ApiClient and generally follows the same usage. However, since UTR tests are compiled and run at build-time, the structure of tests is somewhat different from that of a standalone GameDriver test.

ApiClient versus CoApi?

Before we dive into the nuts and bolts of the CoApi, it is important to understand when to use this mode of execution versus the standard GameDriver ApiClient. While the majority of commands and helper methods are supported in both modes, there are some differences to consider. The table below is intended to highlight some of these differences and help you decide which of these methods is right for your project.

Feature/Method

ApiClient

CoApiClient

Build and run tests against the Unity Editor or standalone build/device

Tests can be built as part of the Unity project, or standalone - no recompilation required. Tests are built and managed within the Unity project and executed by the Unity Test Runner or at build time. Test changes require recompilation of the project.

Control gameplay states

Connect (with AutoPlay options, standalone, multi-host, multi-instance support), Play, Pause, Launch standalone executable with arguments

N/A

Control user inputs

AxisPress

ButtonPress

KeyPress

Click

ClickObject

Tap/Touch

KeyPress

Click

ClickObject

Tap/Touch

Query runtime objects

GetObjectFieldValue<T>

GetObjectDistance

GetObjectPosition

WaitForObjectValue

etc...

GetObjectFieldValue<T>

GetObjectDistance

GetObjectPosition

WaitForObjectValue

etc...

Control runtime object states

SetObjectFieldValue

SetInputTextField

RotateObject

SetObjectFieldValue

SetInputTextField

RotateObject

Helper methods

CaptureScreenshot

GetLastFPS

Cache control

Collision Detection

LoadScene

CaptureScreenshot

GetLastFPS

Cache control

Collision Detection

LoadScene

WaitForFixedUpdate

Access methods and behaviours Access to any private/public methods attached to GameObjects via CallMethod Full access to project methods and MonoBehaviour classes

As you can see from this table, the majority of GameDriver features are identical between the ApiClient and CoApi, with only a few exceptions. The main difference between these two is where tests are built and run, and how internal methods are accessed. There is also significant value in being able to build, modify, and run tests without the need to recompile the target application. 

Unlike standalone tests written with NUnit and GameDriver, tests utilizing the Unity Test Framework make use of Coroutines which require the IEnumerable interface to control test flow, and Yields to return values. This allows test methods to iterate as needed before returning control back to the calling function. For more information on this approach, please refer to the Unity Test Framework documentation, specifically this section on UnitySetUp and UnityTearDown. Another good reference document for working with Iterators and Yields can be found on Microsoft’s documentation here, and we highly recommend this video for a practical working explanation.

Additionally, the CoApi does not currently support the newer VR/XR calls. This includes such calls as Vector2/3InputEventsQuaternionInputEventCreate/RemoveInputDevice, and IntegerInputEvent.

SmartAgent calls are also not currently supported, which includes ExecuteScript and Schedule/UnshceduleScript.

To summarize the use of this approach, it’s helpful to keep in mind all methods that return a value (whether it is a bool, int, string, etc..) need to use the IEnumerator interface and need to use the `yield return` syntax.

For the remainder of this document, we will refer to a sample project and test that can be found here on our GitHub. Following the examples in this project, you should be able to write similar tests for your Unity project.

Getting Started with the GameDriver CoApiClient

If you haven’t already installed GameDriver in your Unity project, please follow the steps outlined in the installation instructions. Next, follow the instructions for How to create a new test assembly in the Unity Test Framework documentation. Be sure to add the following GameDriver libraries under Assembly References to the Assembly Definition when on this step:

Next, create a test in the same directory as your Tests.asmdef file above. This can be done using the Create > Testing > C# Test Script menu option in the editor.

Next, open the new test script in your default development environment. For this example we will use Visual Studio 2019 for macOS. Once your test is open, you will need to add the following directives:

using System.Collections;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine.TestTools;
using UnityEngine.SceneManagement;
using System.Threading;
using System;
using gdio.common.objects;
using gdio.unity_utr_api;

Be sure to resolve any dependencies highlighted such as NUnit, which can be added to the project using the NuGet package manager. As with the instructions in the “Getting Started with GameDriver” guide, tests built to work with the Unity Test Framework require a specific structure in order to run consistently. Unlike tests run outside of the UTF, however, the setup here is quite minimal. Your test will need:

  • [OneTimeSetup] method which loads the scene containing the GameDriver agent

  • A client API connection method - this can be where you enable the input hooking

  • Your test(s)

  • [OneTimeTearDown] method which disconnects from the agent

For example, here is the OneTimeSetup method used in our sample test.

CoApiClient client = null;

[OneTimeSetUp]
public void SetupInitialScene()
{
// An initial scene must be loaded when using PlayMode
SceneManager.LoadScene("Splash", LoadSceneMode.Single);

client = new CoApiClient();

client.LoggedMessage += (s, e) =>
{
UnityEngine.Debug.Log(e.Message);
};

return;
}

Note that creating the CoApiClient instance is always done outside of the test method in order to make it available in scope to the other methods. Unlike tests built outside of the UTF, this setup does not need to consider the multiple connection paths and OS-specific information. Next, we will need to connect the CoApiClient to the agent:

[UnityTest, Order(1)]
public IEnumerator T001_ConnectToGame()
{
IEnumerator<bool> result = null;

yield return result = client.Connect();

Assert.IsTrue(result.Current);
}

Finally, we need a test to enable our input hooking:

[UnityTest, Order(2)]
public IEnumerator T002_EnableHooksTest()
{
IEnumerator<bool> result = null;

yield return result = client.EnableHooks(HookingObject.ALL);

Assert.IsTrue(result.Current);
}

Notice here that the UnityTest attribute is used by the UTF to recognize these test types, and an Order attribute determines the order in which the tests are run. Without Order, test execution sequence will follow lexicographical ordering. Also notice that these tests each perform one action and check the result, rather than combining multiple actions. More on that in the next section.

Now we’re ready to add some project-specific tests.

Test Structure and Best Practices

Tests written using this method tend to be a lot smaller in scope than those written outside of the UTF and editor. Structurally they should be simple, and accomplish 3 things in order to execute in a repeatable way. Those are:

  1. Test setup

  2. Test operation

  3. Validate results

While this structure is considered a best practice for all automated testing, it especially applies in this mode where combining multiple test steps into a single operation can make troubleshooting quite difficult. An example of this approach can be found below.

[UnityTest, Order(6)]
public IEnumerator T006_CallMethodGenericReturnIntTest()
{
// Set up the test
IEnumerator result;
SceneManager.LoadScene("UISample", LoadSceneMode.Single);

// Perform the test - in this example we’re calling a method attached to an object to perform a simple calculation

yield return result = client.CallMethod<int>("//*[@name='Canvas']/fn:component('HipProjectManager')", "DoMath", new object[] { 1, 2 });

// Debug message to make sure we did what we wanted
UnityEngine.Debug.Log($"CallMethod return int='{result.Current.ToString()}'");

// Test that the result is correct
Assert.IsTrue(result.Current.GetType().Equals(typeof(int)));
Assert.AreEqual(3, result.Current, "DoMath failed");
}

In the example above, we’re simply loading a scene, calling a method attached to an object with a few parameters, and then checking that the returned value is what we expected. In practice, this type of test is closer to a unit test, but the structure of the test is the same whether the goal of the test is a low-level validation like this, or a test to see what happens when a player character hits an enemy with a specific weapon. Let’s look at a more complex example:

[UnityTest, Order(10)]
public IEnumerator T010_KeyboardMovement()

{

// Set up the test
yield return client.LoadScene("MoveObjectScene");

IEnumerator result;
yield return result = client.WaitForEmptyInput();
UnityEngine.Debug.Log($"WaitForEmptyInput return string='{result.Current.ToString()}'");

// Get the initial position of the cube, and cast that to a new Vector3

IEnumerator before;
yield return before = client.GetObjectPosition("/*[@name='Cube']", CoordinateConversion.None);

UnityEngine.Vector3 cubeStart = (UnityEngine.Vector3)before.Current;

// Move the block down and left from the origin

yield return client.KeyPress(new UnityEngine.KeyCode[] { UnityEngine.KeyCode.DownArrow }, 100);

yield return client.Wait(1);

yield return client.KeyPress(new UnityEngine.KeyCode[] { UnityEngine.KeyCode.LeftArrow }, 100);
yield return client.Wait(1);

// Get the new position of the cube, and cast that to a new Vector3

IEnumerator after;
yield return after = client.GetObjectPosition("/*[@name='Cube']", CoordinateConversion.None);

UnityEngine.Vector3 cubeEnd = (UnityEngine.Vector3)after.Current;

// Check that the cube moved from the start

Assert.AreNotEqual(cubeStart.x, cubeEnd.x, "Cube didn't move!");

}

In this example, we’re moving a cube around the screen using some keyboard inputs, and we expect it to end up away from where it started. Note the separate IEnumerator objects used here, which is a best practice for comparing multiple return values. We could optimize this further by comparing the Current values of each IEnumerator object instead of converting those first, but we wanted to explicitly show what was being compared in this example.

As a rule of thumb, you will want to keep tests self-contained as much as possible. Meaning a test should be able to run without requiring a previous test to pass. Also, try to avoid testing multiple scenarios in a single test. The best practice here is to always test one "thing" in each test, and keep dependencies to a minimum. This isn't specific to testing with the Unity Test Framework + GameDriver, but rather for automated testing in general.

Running Tests

Tests built using the Unity Test Framework can be run using the Unity Test Runner, which is available in the editor under Window > General > Test Runner.

Tests can be run directly from this interface during development, which provides details of any failed tests. You can also run tests against a standalone build, which is configured via the File > Build Settings > Player Settings dialog.

For more information regarding the Unity Test Runner, please refer to the Unity Documentation.

If you have any questions or feedback on this guide, please contact us at support@gamedriver.io.