Le Cube ◇ Mass Psychosis

The Blog

ReaScripts for Cockos REAPER
FMOD
Test-Focused Development
TDD-ing Avatar Health in C# and C++
Part 1&2 - The Avatar Health assignment
Part 3 - Implementation begins - C#
Part 3 - Implementation begins - C++
Part 4 - Taking Damage
Part 5 - The Dying part
Part 6 - The Replenishing part
Part 7 - The Increasing and Max part
Part 8 - Adding the Config
TDD-ing Chess in C#
Lifting

TDD-ing Avatar Health in C# and C++

Part 5 - The Dying part

by Egill Antonsson
published on 2022 Feb 01
updated on 2022 May 14

The Dying requirements

Let's recap the rest of the Taking Damage and Dying requirements from the user perspective:

  • When Link loses all of his Hearts the game ends
  • Link is protected from any damage that would instantly kill him as long as his Life Gauge is full
    and will instead be left alive with Hearth 1 fraction remaining
    • However, if the damage is more than 5 Hearts in addition to his full Life Gauge, he will still die
The complete requirement list from the user perspective is in Part 2

Let's reword this focusing on the domain model and using its terminology:

  • When Avatar loses all of his Health Points he dies (and game ends)
  • The Avatar is protected from any damage that would instantly kill him as long as his Health Points are full
    and will instead be left alive with 1 Health Points remaining
    • However, if the damage is more than 20 points in addition to his full Health Points, he will still die

The code

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.

Death cycling

I start cycling the first dying requirement.
The domain model focus will be a new property named IsDead.

RED

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);
}

GREEN

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.

RED

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.

GREEN

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.

Protected from instant kill

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.

  • Link starts with Full Health -> FullPoints start with same value as startingPoints

Full Health Points cycle

The domain model focus will be a new property named FullPoints.

RED

// 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).

GREEN

// 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.

Protected from instant kill cycling

RED

// 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.

Test code naming / structure convention

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).

Experience the value of TDD

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 :)

GREEN

// 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.

REFACTOR

// 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.

REFACTOR (2nd iteration)

// 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.