I will use Unity and its packages Test Framework, a Unity integration of the NUnit framework, and Code Coverage analysis tool. Other C# .NET framework could be used instead, but Unity was the handiest for me to use. The whole project is on GitHub.
I create a new Unity project (3D). Both packages Test Framework and Code Coverage are already installed as they are part of the already installed Engineering feature set. The package doc detail how to setup tests for a project. My project structure starts simple:
As the folder structure implies I'll do EditMode tests for the Runtime production code. My main reasons to prefer EditMode (over PlayMode) tests are:
The doc EditMode vs. PlayMode tests explains the difference further.
Let's recap these requirements from the user perspective
Focusing only on the domain logic I filter and reword into one requirement:
The domain model focus will be the Health
class (not created yet), thus I create the test file and write the first test and see it fail (as the code does not compile).
// HealthTest.cs
using NUnit.Framework;
public class HealthTest
{
public class Constructor
{
[Test]
public void Points_HasStartingValue()
{
var health = new Health();
Assert.That(health.points, Is.EqualTo(12));
}
}
}
I create and name a nested class after the entry point of the test (entry point is defined in What is a good Unit Test section in Part 1), thus is named Constructor
in this case. I got the idea from post TDD and modeling a chess game (author Erik Dietrich) that references Structuring Unit Tests (author Phil Haack).
I'm using the Constraint Model of Assertions..
I write the minimal production code to compile successfully and intentionally make the test fail to verify that the test is not passing when it should not (testing the test).
// Health.cs
public class Health
{
public int points;
}
Going forward I will usually skip demonstrating both RED steps and only demonstrate one and simply call it RED, But I do both steps when coding as it is important to test the test.
I make the test pass with this very simple implementation:
// Health.cs
public class Health
{
public int points = 12;
}
It comes to mind that it's good to be able to tweak the starting points at compile time for game design balancing, thus I refactor the value to be passed as param into the constructor.
I rename the member variable and thus the test case to current points. I name the constructor param startingPoints.
// HealthTest.cs
// inside nested Constructor class.
[Test]
public void CurrentPoints_HasStartingValue()
{
int startingPoints = 12;
var health = new Health(startingPoints);
Assert.That(health.CurrentPoints, Is.EqualTo(startingPoints));
}
I encapsulate current points by providing a 'getter'. I want to encapsulate the production code so that the only exposure comes from the requirements that the TDD cycles drives (more exposure could be required in certain cases but should be kept to the bare minimum).
// Health.cs
public int CurrentPoints { get; private set; }
public Health(int startingPoints)
{
CurrentPoints = startingPoints;
}
It comes to mind that invalid values can be passed into the constructor, as our avatar should never start with less than 1 points (starting up being dead is a 'no show'). I deem it appropriate that the production code throws an exception when the value is invalid and I write a test that expects this, and it expectedly fails as the throwing has not been implemented.
Going forward I'll present only the code that's the focus of the current TDD step.
I deem I can use an existing system exception for this case. I choose the value 0
for the StartingPoints param because it's on the edge of the invalidation (as 1
is valid).
// HealthTest.cs
// inside nested Constructor class.
[Test]
public void ThrowsError_WhenStartingPointsIsInvalid()
{
var exception = Assert.Throws(Is.TypeOf<ArgumentOutOfRangeException>(), delegate
{
new Health(0);
});
Assert.That(exception.Message, Does.Match("invalid").IgnoreCase);
}
I assert that the message contains "invalid" (ignoring the case), intentionally not being more specific so the assert does not fail later because of potential rewording improvements.
After the entry point has been stated (as I've already done) the rest of the test naming should be one of the following:
CurrentPoints_HasStartingValue
ThrowsEx_WhenStartingPointsIsInvalid
Exit point and Unit of Work are defined in What is a good Unit Test section in Part 1. Underscores are skipped if not allowed.
//Health.cs
public int CurrentPoints { get; private set; }
public Health(int startingPoints)
{
if (startingPoints < 1)
{
throw new ArgumentOutOfRangeException(nameof(startingPoints), "Invalid value");
}
CurrentPoints = startingPoints;
}
I'm confident that the implementation of the if
conditional handles all potential input values properly. However I just did the bare minimum for the exception message and I will refactor it in the next step:
//Health.cs
public Health(int startingPoints)
{
const int lowestValidValue = 1;
if (startingPoints < lowestValidValue)
{
var message = $"Value {startingPoints} is invalid, it should be equal or higher than {lowestValidValue}";
throw new ArgumentOutOfRangeException(nameof(startingPoints), message);
}
CurrentPoints = startingPoints;
}
I define lowestValidValue
and use it both in the condition and the message. This makes the message informative and makes sure the conditional and message will always be in sync. I verify the message displays as expected (with debug breakpoint):
Value 0 is invalid, it should be equal or higher than 1.
I'm confident that this implementation works for any passed in param value. But I'll test a few more values to verify it and demonstrate how to do it in a maintainable way.
I refactor the tests and add test cases where the value is close to the valid/invalid edge.
To achieve this in a maintainable way I use Parameterized Tests with inline TestCase attribute.
// HealthTest.cs
// inside nested class Constructor
[TestCase(12)]
[TestCase(1)]
public void CurrentPoints_HasStartingValue(int startingPoints)
{
var health = new Health(startingPoints);
Assert.That(health.CurrentPoints, Is.EqualTo(startingPoints));
}
[TestCase(0)]
[TestCase(-1)]
public void ThrowsError_WhenStartingPointsIsInvalid(int startingPoints)
{
var exception = Assert.Throws(Is.TypeOf<ArgumentOutOfRangeException>(),
delegate
{
new Health(startingPoints);
});
Assert.That(exception.Message, Does.Match("invalid").IgnoreCase);
}
Now total 4 test cases are run and the test runner looks like this: