Let's recap the rest of the Taking Damage and Dying requirements from the user perspective:
Let's reword this focusing on the domain model and using its terminology:
The code shown below is focused on the current cycle step, thus only showing the relevant lines .
The complete code and the Unity project is on GitHub.
I start cycling the first dying requirement.
The domain model focus will be a new property named IsDead.
I write a new test case and it fails as IsDead is not defined.
// HealthTest.cs
// inside nested class Constructor
[Test]
public void IsDead_IsFalse()
{
var health = new Health(12);
Assert.That(health.IsDead, Is.False);
}
I make IsDead simply return false to pass the test.
// Health.cs
public bool IsDead => false;
I encapsulated the property into a 'getter' via Expression-Bodied Members syntax.
I know that simply returning false for IsDead will not hold as the generic solution that satisfies all cases,
thus I'll now do a new cycle to achieve that.
Still Focusing on the first dying requirement, but also having the 'protected from instant kill' case in mind,
I write a new test case that reduces the CurrentPoints to 0 after two TakeDamage invocations,
as I know that that case should always kill the Avatar.
// HealthTest.cs
// inside nested class TakeDamage
[Test]
public void IsDead_AfterTwoInvocations()
{
var health = new Health(12);
health.TakeDamage(11);
Assert.That(health.IsDead, Is.False);
health.TakeDamage(1);
Assert.That(health.IsDead, Is.True);
}
The _After in the name suits well for this scenario (better than _When).
The test still checks only one exit point, although it asserts it twice.
As the test is readable, focused and meaningful,
I'm still in line IMO with What is a good Unit Test section in Part 1).
The test fails as currently the Avatar is immortal!
Of course that's no good, so let's fix that and make the test pass.
I change the implementation of the IsDead property that makes the test (and the previous test) pass.
// Health.cs
public bool IsDead => CurrentPoints < 1;
I'm confident that now I have the generic solution for this property,
Thus I'll start a new cycle for the next requirement.
Now focusing on the latter requirement (listed at start of this post),
I now take further note of the concept 'full Health Points' in the requirement,
and realize it is a definition with it's own simple requirement
Let's cycle this, and then get back to the dying part.
FullPoints start with same value as startingPoints The domain model focus will be a new property named FullPoints.
// HealthTest.cs
// inside nested class Constructor
[TestCase(12)]
[TestCase(1)]
public void FullPoints_HasStartingValue(int startingPoints)
{
var health = new Health(startingPoints);
Assert.That(health.FullPoints, Is.EqualTo(startingPoints));
}
The test is very similar to the existing test for exit point CurrentPoints
but the key difference is that it's testing a different exit point (see Good Unit Test definition in part 1).
// Health.cs
public class Health
{
public int CurrentPoints { get; private set; }
public int FullPoints { get; private set; }
public Health(int startingPoints)
{
ValidatePoints(startingPoints, 1); // method not shown
FullPoints = CurrentPoints = startingPoints;
}
}
I set FullPoints to the startingPoints parameter value (same as CurrentPoints).
I also encapsulate CurrentPoints into a 'getter, private setter'.
This FullPoints implementation satisfies the current requirements,
but likely will be extended on later with the Replenishing requirements.
Thus now I'll start a new cycle to fulfill the 'Protected from instant kill' requirement.
// HealthTest.cs
// inside nested class TakeDamage
[TestCase(1, 4, 4)]
[TestCase(1, 4, 5)]
[TestCase(1, 4, 24)]
[TestCase(-21, 4, 25)]
public void CurrentPoints_WhenStartingPoints_ThenDamagePoints(
int currentPoints,
int startingPoints,
int damagePoints)
{
var health = new Health(startingPoints);
health.TakeDamage(damagePoints);
Assert.That(health.CurrentPoints, Is.EqualTo(currentPoints));
}
I assert on the output CurrentPoints as the 'protected from instant kill' case should leave the value at 1.
The last TestCase tests the 'exception' case:
when the damage is so much that it kills the Avatar regardless.
Elaborating further, the scenario starting with _When
can continue with _Then as needed.
The whole scenario 'plays out' before the assertion / exit point check.
(_Then indicates occurring later in the internal scenario order).
Now the value of TDD starts to shine
as my first attempts to make test pass failed and/or failed existing ones.
I suggest you try it out yourself before reading my GREEN solution below.
Have you tried it out ?
Great !
Then you can compare with my solution below :)
// Health.cs
public void TakeDamage(int damagePoints)
{
ValidatePoints(damagePoints, 1); //method not shown
if (CurrentPoints < FullPoints
|| CurrentPoints > damagePoints
|| damagePoints > CurrentPoints + 20)
{
CurrentPoints -= damagePoints;
}
else
{
CurrentPoints = 1;
}
}
I got all tests passing with this solution, but it could be cleaned up,
thus onward to the refactoring step.
// Health.cs
public void TakeDamage(int damagePoints)
{
ValidatePoints(damagePoints, 1); //method not shown
if (CurrentPoints == FullPoints
&& damagePoints >= FullPoints
&& damagePoints <= FullPoints + 20)
{
CurrentPoints = 1;
return;
}
CurrentPoints -= damagePoints;
}
I 'flipped' the if check, which then checks for the 'protection' case,
and returns early (instead of having else).
This makes the code more readable and streamlined, IMO.
I don't like that the number 20 has no explanation to it ('magic' number),
so I'll do an iteration on it.
// Health.cs
public const int MaxNegativePointsForInstantKillProtection = -20;
public void TakeDamage(int damagePoints)
{
ValidatePoints(damagePoints, 1); //method not shown
if (CurrentPoints == FullPoints
&& damagePoints >= FullPoints
&& damagePoints <= FullPoints - MaxNegativePointsForInstantKillProtection)
{
CurrentPoints = 1;
return;
}
CurrentPoints -= damagePoints;
}
I replaced the 'magic' number with a constant member
and gave it a meaningful name that explains the number.
A negative value aligns with the naming of the const, so I 'flip' the value.