Welcome to the second part of my TDD Platformer series, if you’ve just jumped here, you should start at the intro.

Testing Input

You can test input at many different levels, and each has a trade-off of coverage vs. complexity. At the highly-complex, high-coverage end, you can fake input coming in to your app by sending it messages similar to what the operating system would send, or even set up something that actually pushes buttons. This has the advantage of testing the entire input system, however it makes any test that uses it massively complex (and likely very flaky).

Instead I intend to use a system similar to the one I use for world state, there will be an ‘input state’ class that has all the information I need, and the PlayerMovement will react to that. The unit tests will just be able to construct this in whatever manner they wish.

The InputState abstraction of input will also be slightly high-level, for example it will have a ‘jump’ value rather than an ‘x button’ value. This means that other things can use the InputState (for example, we could hook it up to an AI, or maybe have an AI take over the player at certain points). At some point, there will be a HumanInput class that will handle the work of turning the gamepad or keyboard input into an InputState. This code is likely to not be tested (or not tested much). This is probably ok, because the code will be fairly small and simple (and easy to manual test if need be) and it will be very noticeable if it goes wrong (I always like to focus my testing on the places where bugs will be subtle and hard to find).

Moving Sideways

So let’s start writing some code, I’m going to begin with moving horizontally. I’ve not sat and planned this code out, so I suspect it will change a lot as I make up and satisfy new requirements. Thankfully the tests make it easy to keep changing code.

This is the first test:

1
2
3
4
5
6
7
8
9
[Test]
public void ShouldMoveLeftWhenHorizontalInputIsPositive()
{
    var movement = new PlayerMovement {HorizontalInput = 1.0f};

    movement.Update();

    movement.CurrentVelocity.x.Should().BeApproximately(movement.MaxHorizontalSpeed, 0.1f);
}

I’m using the ‘BeApproximately’ check here because I’m dealing with floating point values, though I admit that 0.1 is probably too big a delta (and we shouldn’t have any floating point issues with the numbers I’m using).

The HorizontalInput field is normalized, so a value of 1.0f should move the character left at their maximum speed, and a value of -1.0f should move full speed right.

I decided that I need test for checking half-speed input values too, so I alter the test to use the NUnit TestCase annotation (which will run the same code once for each set of inputs you provide), this gives me the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[TestCase(1.0f, 10.0f, 10.0f, TestName = "Full Speed Left")]
[TestCase(0.5f, 10.0f, 5.0f, TestName = "Half Speed Left")]
[TestCase(-0.5f, 10.0f, -5.0f, TestName = "Half Speed Right")]
[TestCase(-1.0f, 10.0f, -10.0f, TestName = "Full Speed Right")]
public void ShouldRespondToHorizontalInput(float input, float maxSpeed, float expectedResult)
{
    var movement = new PlayerMovement
    {
        HorizontalInput = input,
        MaxHorizontalSpeed = maxSpeed
    };

    movement.Update();

    movement.CurrentVelocity.x.Should().BeApproximately(expectedResult, 0.1f);
}

Once I’d made these tests pass with simple code, the PlayerMovement::Update method was in serious need of refactoring, this is what it looked like after:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void UpdateCurrentVelocity()
{
    var newVelocity = CurrentVelocity;
    if (IsOnGround)
    {
        newVelocity.y = 0.0f;
    }
    else
    {
        newVelocity.y += Gravity;
    }

    newVelocity.x = MaxHorizontalSpeed * HorizontalInput;
    CurrentVelocity = newVelocity;
}

I renamed it to UpdateCurrentVelocity, which to be honest, I should have done a long time ago, and made it so that we take a copy of the CurrentVelocity to allow us to modify the components of the vector separately. So far, so simple, but it will get more complex soon enough.

Accelerating

I wasn’t happy with the character being able to instantly change from moving full right to moving full left. I wanted to implement some form of acceleration. This was my first attempt at writing the test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[TestCase(1.0f, 10.0f, 0.5f, 5.0f, 10.0f, TestName = "Full Speed Left")]
[TestCase(0.5f, 10.0f, 0.5f, 2.5f, 5.0f, TestName = "Half Speed Left")]
[TestCase(-0.5f, 10.0f, 0.5f, -2.5f, -5.0f, TestName = "Half Speed Right")]
[TestCase(-1.0f, 10.0f, 0.5f, -5.0f, -10.0f, TestName = "Full Speed Right")]
public void ShouldRespondToHorizontalInput(float input, float maxSpeed, float acceleration, float expectedResultAfterFirstUpdate, float expectedResultAfterSecondUpdate)
{
    var movement = new PlayerMovement
    {
        HorizontalInput = input,
        MaxHorizontalSpeed = maxSpeed,
        HorizontalAcceleration = acceleration
    };

    movement.UpdateCurrentVelocity();
    movement.CurrentVelocity.x.Should().BeApproximately(expectedResultAfterFirstUpdate, 0.1f, "that is the value expected after the first update");

    movement.UpdateCurrentVelocity();
    movement.CurrentVelocity.x.Should().BeApproximately(expectedResultAfterSecondUpdate, 0.1f, "that is the value expected after the second update");
}

As you can see I hijacked the ShouldRespondToHorizontalInput() test and gave it a load of parameters, I also ended up calling UpdateCurrentVelocity twice, to check that the acceleration was happening. The commit message for this change pretty succinctly sums up my thoughts:

First-pass of updating the tests for acceleration. The test case now has 5 params, 2 of which are expected results, this is probably much to complex. Nice assert messages though.

It’s probably no surprise that the next commit reverts that change.

Time for the second-pass. But first I decided to make the UpdateCurrentVelocity method take a deltaTime parameter (which again, I really should have done previously). At the moment, none of the tests care about what this value is, so I defined a ‘someDeltaTime’ value in the test class (which is set to 0.25f). In my test code, the prefix ‘some’ means that the test shouldn’t alter its output based on the value of that variable.

So now we can have another crack at acceleration. This time we add an ‘accelerationTime’ value, which is the amount of time it should take for the character to go from zero to full velocity in a direction. The test for that behaviour looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Test]
public void ShouldAccelerateUpToMaxVelocity()
{
    const float maxHorizontalSpeed = 10.0f;
    var movement = new PlayerMovement
    {
        HorizontalInput = 1.0f,
        MaxHorizontalSpeed = maxHorizontalSpeed,
        AccelerationTime = 1.0f
    };

    movement.UpdateCurrentVelocity(1.0f);

    movement.CurrentVelocity.x.Should().BeApproximately(maxHorizontalSpeed, 0.1f);
}

The simplest code to make that pass caused the ShouldRespondToHorizontalInput() tests fail (because of a divide by zero), so for now we handle the case of zero AccelerationTime specially. Next I needed to make sure that the acceleration still obeyed the MaximumHorizontalSpeed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Test]
public void ShouldNotAccelerateAboveMaxVelocity()
{
    const float maxHorizontalSpeed = 10.0f;
    var movement = new PlayerMovement
    {
        HorizontalInput = 1.0f,
        MaxHorizontalSpeed = maxHorizontalSpeed,
        AccelerationTime = 1.0f
    };

    movement.UpdateCurrentVelocity(100.0f);

    movement.CurrentVelocity.x.Should().BeApproximately(maxHorizontalSpeed, 0.1f);
}

And now the PlayerMovement::UpdateCurrentVelocity looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public void UpdateCurrentVelocity(float deltaTime)
{
    var newVelocity = CurrentVelocity;
    if (IsOnGround)
    {
        newVelocity.y = 0.0f;
    }
    else
    {
        newVelocity.y += (Gravity * deltaTime);
    }

    var desiredXVelocity = MaxHorizontalSpeed * HorizontalInput;
    var useInstantAcceleration = AccelerationTime == 0.0f;
    if (useInstantAcceleration)
    {
        newVelocity.x = desiredXVelocity;
    }
    else
    {
        var xVelocityDifference = desiredXVelocity - CurrentVelocity.x;
        newVelocity.x = xVelocityDifference * Mathf.Min(deltaTime / AccelerationTime, 1.0f);
    }
    CurrentVelocity = newVelocity;
}

After this I renamed the above two tests to ShouldAccelerateUpToMaxHorizontalVelocity() and ShouldNotAccelerateAboveMaxHorizontalVelocity(), which better describes what they do. It’s at this point that I notice that the test have an issue, the test for not accelerating above max velocity also covers that we do accelerate to that velocity (making the first test pointless) and thanks to the first test only being interested in the max velocities, I don’t have any coverage of other cases. So I expand the first test with a few TestCases:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[Test]
[TestCase(0.0f, 1.0f, 1.0f, 10.0f, TestName = "When stopped, after accelerating full left for AccelerationTime, we should be moving full left")]
[TestCase(0.0f, 0.5f, 1.0f, 5.0f, TestName = "When stopped, after accelerating half left for AccelerationTime, we should be moving half left")]
[TestCase(0.0f, 1.0f, 0.5f, 5.0f, TestName = "When stopped, after accelerating full left for half AccelerationTime, we should be moving half left")]
[TestCase(-10.0f, 1.0f, 1.0f, 0.0f, TestName = "When moving full right, after accelerating full left for AccelerationTime, we should be stopped")]
[TestCase(-10.0f, 1.0f, 2.0f, 10.0f, TestName = "When moving full right, after accelerating full left for double AccelerationTime, we should be moving full left")]
public void ShouldAccelerateHorizontally(float startXVel, float input, float deltaTime, float expectedXVel)
{
    var movement = new PlayerMovement
    {
        HorizontalInput = input,
        MaxHorizontalSpeed = 10.0f,
        AccelerationTime = 1.0f, 
        CurrentVelocity = new Vector3(startXVel, 0.0f, 0.0f)
    };

    movement.UpdateCurrentVelocity(deltaTime);

    movement.CurrentVelocity.x.Should().BeApproximately(expectedXVel, 0.1f);
}

This test has 4 parameters and that might be a bit to complex, the really long names also suggest that what the test is doing is hard to describe, but I think it’s ok for now (although Unity does truncate the names). I run this and the final two test cases fail, it seems we have an issue with accelerating whilst we’re already moving (and if you look at the code in UpdateCurrentVelocity it’s not hard to see why), fixing the code gives this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public void UpdateCurrentVelocity(float deltaTime)
{
    var newVelocity = CurrentVelocity;
    if (IsOnGround)
    {
        newVelocity.y = 0.0f;
    }
    else
    {
        newVelocity.y += (Gravity * deltaTime);
    }

    var desiredXVelocity = MaxHorizontalSpeed * HorizontalInput;
    var useInstantAcceleration = AccelerationTime == 0.0f;
    if (useInstantAcceleration)
    {
        newVelocity.x = desiredXVelocity;
    }
    else
    {
        var desiredAcceleration = desiredXVelocity - CurrentVelocity.x;
        var maxAccelerationPerSecond = MaxHorizontalSpeed / AccelerationTime;
        var acceleration = desiredAcceleration;
        if (Mathf.Abs(acceleration) > Mathf.Abs(maxAccelerationPerSecond))
        {
            acceleration = maxAccelerationPerSecond * Mathf.Sign(acceleration);
        }
        newVelocity.x = Mathf.Clamp(newVelocity.x + (acceleration * deltaTime), -MaxHorizontalSpeed, MaxHorizontalSpeed);
    }
    CurrentVelocity = newVelocity;
}

Already getting nice and complex.

I decided to add some cases to the accelerate test to cover accelerating right, just in case we did something unfortunate with the maths (for example, missed out those Abs or Sign calls), I also add TestCases to the ShouldNotAccelerateAboveMaxHorizontalVelocity() to handle left and right acceleration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
[Test]
[TestCase(0.0f, 1.0f, 1.0f, 10.0f, TestName = "When stopped, after accelerating full left for AccelerationTime, we should be moving full left")]
[TestCase(0.0f, 0.5f, 1.0f, 5.0f, TestName = "When stopped, after accelerating half left for AccelerationTime, we should be moving half left")]
[TestCase(0.0f, 1.0f, 0.5f, 5.0f, TestName = "When stopped, after accelerating full left for half AccelerationTime, we should be moving half left")]
[TestCase(-10.0f, 1.0f, 1.0f, 0.0f, TestName = "When moving full right, after accelerating full left for AccelerationTime, we should be stopped")]
[TestCase(-10.0f, 1.0f, 2.0f, 10.0f, TestName = "When moving full right, after accelerating full left for double AccelerationTime, we should be moving full left")]
[TestCase(0.0f, -1.0f, 1.0f, -10.0f, TestName = "When stopped, after accelerating full right for AccelerationTime, we should be moving full right")]
[TestCase(0.0f, -0.5f, 1.0f, -5.0f, TestName = "When stopped, after accelerating half right for AccelerationTime, we should be moving half right")]
[TestCase(0.0f, -1.0f, 0.5f, -5.0f, TestName = "When stopped, after accelerating full right for half AccelerationTime, we should be moving half right")]
[TestCase(10.0f, -1.0f, 1.0f, -0.0f, TestName = "When moving full left, after accelerating full right for AccelerationTime, we should be stopped")]
[TestCase(10.0f, -1.0f, 2.0f, -10.0f, TestName = "When moving full left, after accelerating full right for double AccelerationTime, we should be moving full right")]
public void ShouldAccelerateHorizontally(float startXVel, float input, float deltaTime, float expectedXVel)
{
    var movement = new PlayerMovement
    {
        HorizontalInput = input,
        MaxHorizontalSpeed = 10.0f,
        AccelerationTime = 1.0f, 
        CurrentVelocity = new Vector3(startXVel, 0.0f, 0.0f)
    };

    movement.UpdateCurrentVelocity(deltaTime);

    movement.CurrentVelocity.x.Should().BeApproximately(expectedXVel, 0.1f);
}

[Test]
[TestCase(1.0f, 10.0f, TestName = "Full left acceleration")]
[TestCase(-1.0f, -10.0f, TestName = "Full right acceleration")]
public void ShouldNotAccelerateAboveMaxHorizontalVelocity(float input, float expectedXVel)
{
    var movement = new PlayerMovement
    {
        HorizontalInput = input,
        MaxHorizontalSpeed = 10.0f,
        AccelerationTime = 1.0f
    };

    movement.UpdateCurrentVelocity(10.0f);

    movement.CurrentVelocity.x.Should().BeApproximately(expectedXVel, 0.1f);
}

That amount of test-cases is definitely stretching the bounds of what I consider reasonable. I’d expect anyone reading that code to consider that to be a code smell, and if they had any issues with that test it should be refactored into something simpler.

I think I want to take a look at jumping next, so look out for Part 4: The surprisingly complex world of jumping.