Welcome to the second part of my TDD Platformer series, if you’ve just jumped here, you should start at the intro.
Integration Tests
In the nomenclature of the Unity Test Framework, an Integration Test is a test that is made by creating GameObjects with certain behaviours in a scene (the scene has to have a name that ends with ‘Tests’), there is a root ‘test’ GameObject, and all the child GOs will be part of the test (so if you have multiple tests involving your player prefab, you’ll end up with multiple instances of them in the scene, however, the test framework will only enable one of them at the time), these tests run with the entire engine ticking away, so you can test basically anything. See the docs for more details.
First Integration Test
So first off, I want to make a test that checks that the PlayerMovementBehvaiour takes the velocity the PlayerMovement has calculated, and passes it off to the rigidbody (I’m using a rigidbody so I can make use of the Unity collision detection/response, but I’ll set the velocity directly so I have more control than I would do if I was just applying accelerations)
To do this I create a new test (it’s easiest to do this from the Integration Test window) and name it ‘ShouldApplyVelocity’, under this I stick a copy of my Player prefab. I then add an AssertionComponent to the root test node and set it to check that in the Update, after 2 frames, the velocity.y property of the Rigidbody component that’s attached to the Play prefab is less than the constant value 0.0f. We run this and it fails.
The scenegraph for the test looks like this:
And the components on the ShouldApplyVelocity node look like this:
We can make this pass by adding the following the the PlayerMovementBehaviour:
1 2 3 4 5 | void FixedUpdate() { PlayerMovement.Update(); rigidbody.velocity = PlayerMovement.CurrentVelocity; } |
Whilst this test currently passes, it has a problem. It currently relies on the ‘falling due to gravity’ effect that we wrote in the previous post (because it’s that code that changes the currentVelocity property that we’re applying to the rigidbody), but that implicit relationship isn’t captured anywhere, so we could get confusing breakages at some point in the future if we ever changed that behaviour (if we changed it so that gravity moved left, for example). For now this is ok, there isn’t really anything else we can do, but we should think about re-visiting this test once we have input working, as relying on that to change the PlayerMovement velocity is probably more stable.
Testing If We’re On The Ground
The second test we need to write is the test to check that the PlayerMovementBehaviour correctly sets the IsOnGround property.
This test is somewhat more complex because we need to check two seperate things (that IsOnGround is set to true when on ground, and then back to false when off ground). We could write this as two seperate tests but since they’d share a lot of the same setup (and because we want to minimise the number of integration tests) I’ll just do it as a single test.
Because I’m testing 2 different things I’ll write this mostly in code, unfortunately this means creating a new MonoBehaviour that will be compiled in to the game, so we might want to make our build system delete these before producing a final build at a later date.
The test 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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | public class AssertShouldSetIsOnGround : MonoBehaviour { public PlayerMovementBehaviour Player; public Collider Ground; private int numFixedUpdates = 0; void Awake() { TeleportPlayerToGround(); } void FixedUpdate() { if (numFixedUpdates == 2) { if (!Player.PlayerMovement.IsOnGround) { IntegrationTest.Fail(Player.gameObject, "IsOnGround was not true when on ground"); } MovePlayerOffGround(); } else if (numFixedUpdates == 4) { if (!Player.PlayerMovement.IsOnGround) { IntegrationTest.Pass(Player.gameObject); } else { IntegrationTest.Fail(Player.gameObject, "IsOnGround was not set to false after moving off ground"); } } numFixedUpdates++; } private void MovePlayerOffGround() { Player.transform.Translate(Vector3.up * 0.1f); } private void TeleportPlayerToGround() { var topOfGround = Ground.bounds.max.y; var bottomOfPlayer = Player.GetComponentInChildren<Collider>().bounds.min.y; var centerOfPlayer = Player.transform.position.y; var newPlayerPos = new Vector3(0.0f, topOfGround + (centerOfPlayer - bottomOfPlayer), 0.0f); Player.transform.position = newPlayerPos; } } |
We start off by teleporting the player to the ground. Then 2 FixedUpdates later we (we wait to ensure that the PlayerMovementBehaviour code has had a chance to run) we check that the IsOnGround property is set to true, if not we fail the test there and then. If that test passes, we move the player above the ground, wait another 2 FixedUpdates, and then check that IsOnGround is false.
To make this test pass we alter the PlayerMovementBehaviour so that it 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 26 27 28 29 30 31 32 33 34 35 36 37 | [RequireComponent(typeof(Rigidbody))] public class PlayerMovementBehaviour : MonoBehaviour { public PlayerMovement PlayerMovement; private Rigidbody rigidbody; private float distToFeet; private const float GroundCheckExtra = 0.01f; void Awake() { rigidbody = GetComponent<Rigidbody>(); distToFeet = GetComponentInChildren<Collider>().bounds.extents.y; } void FixedUpdate() { GatherWorldState(); UpdateAndApplyVelocity(); } private void GatherWorldState() { PlayerMovement.IsOnGround = IsOnGround(); } private bool IsOnGround() { return Physics.Raycast(transform.position, -Vector3.up, distToFeet + GroundCheckExtra); } private void UpdateAndApplyVelocity() { PlayerMovement.Update(); rigidbody.velocity = PlayerMovement.CurrentVelocity; } } |
The check to see if we’re on the ground is just a simple raycast straight down, we might need to change this to something more interesting later, but for now it will do.
That’s it for the integration tests we need to write so far, next up we should write some unit tests that check that input is processed properly, so that will be the next part.