Le Cube ◇ Mass Psychosis

Code blog

FMOD
Batch Rename in FMOD Studio
ReaScripts for Cockos REAPER
Test-Focused Development
TDD-ing Avatar Health in C# and C++
TDD-ing Chess in C#
Lifting

FMOD

Batch Rename in FMOD Studio

by Egill Antonsson
published on 2025 Apr 08

Using Unity

I will use Unity and its packages Test Framework, a Unity integration of the NUnit framework, and Code Coverage analysis tool. Other C# .NET framework could be used instead, but Unity was the handiest for me to use. The whole project is on GitHub.

Setup

I create a new Unity project (3D). Both packages Test Framework and Code Coverage are already installed as they are part of the already installed Engineering feature set. The package doc detail how to setup tests for a project. My project structure starts simple:

  • Test code: Scripts/Tests/EditMode/[ClassName]Test.cs
  • Production code: Scripts/Runtime/[ClassName].cs

As the folder structure implies I'll do EditMode tests for the Runtime production code. My main reasons to prefer EditMode (over PlayMode) tests are:

  • They run faster as there is no PlayMode startup cost (in line with What is a good Unit Test section in Part 1)
  • Tests for the domain logic should not need the PlayMode test feature to run as Coroutine to wait or step through frames."

The doc EditMode vs. PlayMode tests explains the difference further.

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.cs
using NUnit.Framework;

public class HealthTest
{
	public class Constructor
	{
		[Test]
		public void Points_HasStartingValue()
		{
			var health = new Health();
			Assert.That(health.points, Is.EqualTo(12));
		}
	}
}

I create and name a nested class 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. I got the idea from post TDD and modeling a chess game (author Erik Dietrich) that references Structuring Unit Tests (author Phil Haack).

I'm using the Constraint Model of Assertions..

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.cs
public 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.cs
public 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.cs
// inside nested Constructor class.
[Test]
public void CurrentPoints_HasStartingValue()
{
	int startingPoints = 12;
	var health = new Health(startingPoints);
	Assert.That(health.CurrentPoints, Is.EqualTo(startingPoints));
}

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.cs
public int CurrentPoints { get; private set; }

public Health(int startingPoints)
{
	CurrentPoints = startingPoints;
}

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.cs
// inside nested Constructor class.
[Test]
public void ThrowsError_WhenStartingPointsIsInvalid()
{
	var exception = Assert.Throws(Is.TypeOf<ArgumentOutOfRangeException>(), delegate
		{
			new Health(0);
		});
	Assert.That(exception.Message, Does.Match("invalid").IgnoreCase);
}

I assert that the message contains "invalid" (ignoring the case), intentionally not being more specific so the assert does not fail later because of potential rewording improvements.

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.cs
public int CurrentPoints { get;  private set; }

public Health(int startingPoints)
{
	if (startingPoints < 1)
	{
		throw new ArgumentOutOfRangeException(nameof(startingPoints), "Invalid value");
	}

	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.cs
public Health(int startingPoints)
{
	const int lowestValidValue = 1;
	if (startingPoints < lowestValidValue)
	{
		var message = $"Value {startingPoints} is invalid, it should be equal or higher than {lowestValidValue}";
		throw new ArgumentOutOfRangeException(nameof(startingPoints), message);
	}

	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.

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 Parameterized Tests with inline TestCase attribute.

// HealthTest.cs
// inside nested class Constructor
[TestCase(12)]
[TestCase(1)]
public void CurrentPoints_HasStartingValue(int startingPoints)
{
	var health = new Health(startingPoints);
	Assert.That(health.CurrentPoints, Is.EqualTo(startingPoints));
}

[TestCase(0)]
[TestCase(-1)]
public void ThrowsError_WhenStartingPointsIsInvalid(int startingPoints)
{
	var exception = Assert.Throws(Is.TypeOf<ArgumentOutOfRangeException>(),
		delegate
		{
			new Health(startingPoints);
		});
	Assert.That(exception.Message, Does.Match("invalid").IgnoreCase);
}

Now total 4 test cases are run and the test runner looks like this:

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