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