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.