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 3 - Implementation begins - C++

by Egill Antonsson
published on 2023 Jun 08

Using Visual Studio

I'll use Visual Studio (VS) and the whole project is on GitHub (including the VS project files). I'm using the free VS Community edition which is sufficient for this blog series, but it's worth mentioning that the paid Enterprise edition includes the productivity feature Live Unit Testing which provides real-time feedback directly in the editor, which is convenient for the TDD cycles, other test related features e.g. Code Coverage. Using the Community edition I'll need to manually trigger the test run (and Code Coverage will not be the focus of this blog series).

Using Google Test

I'll use Google Test framework as VS includes and integrates it, and doing a quick web browser comparison and research (not using Google) makes it likely to provide similar features as C# NUnit framework (I currently have more experience in C# and NUnit and other similar frameworks).

Setup

I create a new (C++) Windows Desktop Application project naming it AvatarHealth and this also creates the solution for the project. For the unit tests I add within the solution a new (C++) Google Test project naming it AvatarHealth-Test (detailed in VS doc section), and configure it like the screenshot below:

Test Runner: first test fail
VS new project: Google Test config

To export the functions that you want to test, add the output .obj or .lib files to the dependencies of the test project detailed in this VS doc section.

The start state requirements

Let's recap these requirements from the user perspective

  • The Life Gauge measures Link's (the avatar) current amount of health
  • Health is visually represented in the form of Hearts, and they are in fractions of 4
  • The Life Gauge starts with 3 Hearts
The complete requirement list from the user perspective is in Part 2

Focusing only on the domain logic I filter and reword into one requirement:

  • Avatar Health starts with 12 Health Points (equals to the visual representation of 3 Hearts * 4 Fractions)

Health Points starting value

RED (does not compile)

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.cpp
#include "pch.h"
#include "../AvatarHealth/Health.h"

TEST(Constructor, PointsHasStartingValue)
{
	Health health;
	EXPECT_EQ(health.points, 12);
}

I name the first param in the TEST macro 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. The rest of the test case naming goes into the second param (Google Test docs states that it should not contain underscores). You can see how this lays out in the Test Explorer in the screenshots below.

RED (test fails)

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.h
#pragma once

class Health
{
public:
	int points;
};
The test runner after first test fail
The test runner after first test fail

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.

GREEN

I make the test pass with this very simple implementation:

// Health.h
class Health
{
public:
	int points = 12;
};

REFACTOR

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.cpp
TEST(Constructor, CurrentPointsHasStartingValue)
{
	auto startingPoints = 12;
	Health health = Health(startingPoints);
	EXPECT_EQ(health.GetCurrentPoints(), startingPoints);
}

I create the Health.cpp file and write the implementation in it, and remove the 'green step' quick implementation (the hardcoded 12 value) from the Health.h header file. Going forward the header file will only contain definitions (that resolve 'red does not compile' steps) and temporary quick implementations for resolving some 'green' steps, thus the implementation will be in the .cpp file.

// Health.h
#pragma once

class Health
{
public:
	Health(int startingPoints);
	int GetCurrentPoints();

private:
	int currentPoints;
};

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.cpp
#include "Health.h"

Health::Health(int startingPoints)
{
	Health::currentPoints = startingPoints;
}

int Health::GetCurrentPoints()
{
	return currentPoints;
}

Handle invalid StartingPoints values

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.

RED

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.cpp
TEST(Constructor, ThrowsExWhenStartingPointsIsInvalid)
{
	EXPECT_THROW(
		{
			Health health = Health(0);
		}, std::out_of_range);
}

My test naming convention

After the entry point has been stated (as I've already done) the rest of the test naming should be one of the following:

  • [ Exit point ] _ [ will be in state (after 'happy success path') ]
    • e.g. CurrentPoints_HasStartingValue
  • [ Exit point ] _ When [ Scenario (other than 'happy success path') ]
  • [ Expected behavior of Unit of Work ] _ When [ Scenario ]
    • e.g. 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.

GREEN

//Health.cpp
Health::Health(int startingPoints)
{
	if (startingPoints < 1)
	{
		throw std::out_of_range(std::string("Invalid value"));
	}

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

REFACTOR

//Health.cpp
#include <format>

using namespace std;

Health::Health(int startingPoints)
{
	auto lowestValidValue = 1;
	if (startingPoints < lowestValidValue)
	{
		auto msg = format("Value {} is invalid, it should be equal or higher than {}", startingPoints, lowestValidValue);
		throw out_of_range(msg);
	}

	Health::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.

Test Runner: before parameterized refactor
Test Runner: before parameterized refactor

Testing more values for verification

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.

REFACTOR

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 the TEST_P macro and define the needed structure for it:

// HealthTest.cpp
using namespace std;
using ::testing::TestWithParam;
using ::testing::Values;

class CurrentPointsHasStartingValues : public TestWithParam<int> { };
INSTANTIATE_TEST_CASE_P(Constructor, CurrentPointsHasStartingValues,
	Values(1, 12)
);
TEST_P(CurrentPointsHasStartingValues, Value)
{
	auto param = GetParam();
	Health health = Health(param);
	EXPECT_EQ(health.GetCurrentPoints(), param);
}

class ThrowsExWhenStartingPoints : public TestWithParam<int> { };
INSTANTIATE_TEST_CASE_P(Constructor, ThrowsExWhenStartingPoints,
	Values(0, -1)
);
TEST_P(ThrowsExWhenStartingPoints, Value)
{
	EXPECT_THROW(
		{
			Health health = Health(GetParam());
		}, out_of_range);
}

I try to map the naming to follow my naming convention and to display according in the test runner that now runs 4 tests in total:

The test runner after parameterized refactor
The test runner after parameterized refactor