DEV Community

Cover image for Testing Turtle Tom - Making a Turtle Run on His Own
Matt Laser
Matt Laser

Posted on

Testing Turtle Tom - Making a Turtle Run on His Own

Testing sees a lot of conversation in the software development world, and it should - it's one of the best ways to assure the code you wrote yesterday is going to work in the same way tomorrow. That said, it's much easier to talk about writing tests than it is to actually write them, as time, budget, or complexity constraints often cut testing efforts off at the knees.

All of this becomes more difficult when it comes to side projects - after my day job and other responsibilities, I'm much more likely to go have a beer and play video games than I am to sit down and crank out additional code, let alone write a robust set of tests.


I've been working on a game called Turtle Tom's Terrific Tropical Time Trials, a 3D platformer that's being built using the Unity game engine. With my actual job as an Android developer, the nature of C# scripting and 3D modeling is always a bit of an adjustment.

When I'm working on an Android app, I can expect an orderly execution of lifecycle methods and callbacks with a small set of "first class" architecture components. In Unity, dozens of scripts are executing hundreds of times per second, so that the game state can be updated on a frame-by-frame basis.

As I'm developing, I wind up introducing some type of obscure bug every single time I make a change. While I've done my best to separate the concerns of my code to a reasonable degree, it's often unavoidable to keep from affecting one aspect of my code when changing another, specifically around the player's movement code. To add to the complexity, the 3D models and assets are configured in the Unity GUI, while the code is edited as you'd expect, using a text editor. A small change in the GUI can have disastrous effects on the code, and vice versa.

Unity Editor view of Turtle Tom

After creating and fixing dozens of bugs in what is supposed to be my free time, I decided to bite the bullet and add some tests. I'd thought about this before - it wouldn't be a huge ask to add unit tests to my more important functions. However, the behavior of those functions often wasn't an issue, as logic & state errors have been either easy to track down, or not the core of my bugs. I've been running into more ephemeral problem - the game doesn't look or feel like it should.

It's a big pain in the ass to switch from keyboard to controller, execute every possible movement in my game, and then validate each one. Context switching from developing to playing is time consuming, and each manual execution is error prone. After repeating this pattern for months, I was finally lead to a question I didn't expect to be asking: How could I make my game play itself?


While a self-playing game is pretty daunting concept, I realized that I had already inadvertently assembled a lot of the building blocks that would make it possible.

I'm a big fan of creating "wrappers" for just about any piece of code that touches a component or system that I don't have control over, traditionally because it makes mocking & unit testing this functionality possible. Network requests, input, storage access - wherever possible I wrap these accesses with code of my personal design. As such, this is exactly the approach I've taken when it comes to receiving player input in Turtle Tom.

Unity provides simple, global access to their input events via static calls to the Input class, and are typically used like so:

// Executes every frame
void Update() 
{
    if (Input.GetButtonDown("Jump")) {
        Jump();
    }
}

This basic approach introduces a few considerations:

  • The need to know & keep track of the "Jump" string at each callsite
  • Assurance the right function (e.g. GetButton/GetButtonDown/GetButtonUp) is called
  • Multiple callers probably need access to the same event information every frame

Enter InputWrapper. This was one of the first classes I added to the project, and its functionality is pretty straightforward: Every frame, gather all the input data the application cares about, and make it available for usage.

To support this approach, I've created a struct (WrappedInput) that only contains values for each aspect of the input I care about.

public struct WrappedInput
{
    public float move_H;
    public float move_V;
    public float look_H;
    public float look_V;
    public bool retract;
    public bool jump_DOWN;
    public bool jump_UP;
    public bool jump_HELD;
    public bool dive_DOWN;
    public bool pause_DOWN;
    public bool slide_HELD;
}

Each frame, the respective inputs are queried by the InputWrapper, and assigned as values in the struct.

void Update()
{
    var wrapped = new WrappedInput();

    wrapped.move_H = Input.GetAxis(INPUT_NAME_MOVE_HORIZONTAL);
    wrapped.move_V = Input.GetAxis(INPUT_NAME_MOVE_VERTICAL);
    wrapped.look_H = Input.GetAxis(INPUT_NAME_LOOK_HORIZONTAL);
    wrapped.look_V = Input.GetAxis(INPUT_NAME_LOOK_VERTICAL);
    wrapped.retract = Input.GetButtonDown(INPUT_NAME_RETRACT_TOGGLE);
    wrapped.jump_DOWN = Input.GetButtonDown(INPUT_NAME_JUMP);
    wrapped.jump_UP = Input.GetButtonUp(INPUT_NAME_JUMP);
    wrapped.jump_HELD = Input.GetButton(INPUT_NAME_JUMP);
    wrapped.dive_DOWN = Input.GetButtonDown(INPUT_NAME_DIVE);
    wrapped.pause_DOWN = Input.GetButtonDown(INPUT_NAME_PAUSE);
    wrapped.slide_HELD = Input.GetAxis(INPUT_NAME_SLIDE) > 0.25f;

    input = isFaked ? fakeInput : wrapped;

    isFaked = false;
}

From here, we can easily (and semantically) reference each value from the rest of our scripts. The example "Jump" code from above then becomes:

void Update() 
{
    if (inputWrapper.wrapped.jump_DOWN) {
        Jump();
    }
}

This opens up a world of possibilities! Now that all of the inputs are being captured in their own class, I can do things like buffer input, calculate the mean movement direction over several frames, and fake input whenever I want.

Notice the ternary expression in the sample above: input = isFaked ? fakeInput : wrapped;. These variables are driven by another member function of InputWrapper called FakeInput that simply receives an instance of WrappedInput, and sets a flag.

public void FakeInput(WrappedInput fake)
{
    isFaked = true;
    fakeInput = fake;
}

Using this approach, InputWrapper can accept arbitrary input, and every single class that's referencing it will immediately start accepting the "fake" actions without a hiccup. This facilitates a number of new features, as mentioned above, but I'm currently focused on testing. Thankfully, the act of testing is just about as straightforward as the rest, thanks to the InputWrapper.

In my test scene, a test manager script simply instantiates a new instance of my player character for each corresponding instance of a CharacterTest type appearing in the scene. Each of these scripts contains a coroutine definition that executes the desired inputs, and then runs a callback function to signal that it has finished executing.

Here's a simple test that makes Turtle Tom run forward by creating a faked forward input on the left thumbstick of an Xbox controller:

using System.Collections;
using System;
using UnityEngine;

public class CT_Walk_Forward : CharacterTest
{
    public override IEnumerator RunFakeInput(Action done)
    {
        var fake = new WrappedInput();
        fake.move_V = 1;

        var duration = 1;
        var end = Time.time + duration;

        while (Time.time < end)
        {
            wrapper.FakeInput(fake);
            yield return null;
        }

        done();
    }
}

From this point, further testing will be as simple as adding a test short script, continuing until I have every animation & movement ability covered. After that, I can spot check any changes in a few seconds, and instantly reveal any problems that will might be introduced in future development. Even during my first pass, I've identified several inconsistencies that need fixing after a recent refactor.

Testing turtles frolicking across a field


This is a bit of a long winded way to say it, but I suppose my larger point here is that adhering to good software development principles will always help you (or your organization) out in the long run. Even if you can't write the tests you'd like in the moment (deadlines, managment pressure, beer to drink...), keep in mind the type of code you'd want to test in the future. It might just save you some headaches. 🍻

Top comments (1)

Collapse
 
thiccnamekian profile image
LootBoxThot

this dev is hot