This series aims to introduce Test-Driven Development (TDD) to Unity C# developers and serve as a practice exercise for myself in working with TDD.
First, I strongly recommend exploring the work of Dave Farley, whose teachings and techniques have greatly influenced this series. His YouTube channel, Continuous Delivery, is an excellent resource for learning TDD concepts and their practical applications.
https://www.youtube.com/@ContinuousDelivery
One effective way to understand TDD is by practicing with programming katas. These simple problems help us focus on the TDD workflow rather than the problem itself.
https://tddmanifesto.com/exercises/
In this first post, we’ll set up a quick environment for practicing katas in C#. Let’s get started!
Simple and quick setup for C# with NUnit
This approach sets up a testing environment for katas without requiring a Unity project.
Create a solution:
dotnet new sln -n TDDKata
Create the main project and add it to the solution:
dotnet new classlib -n TDDKata
dotnet sln add TDDKata/TDDKata.csproj
Create a test project, add it to the solution, and link the main project:
dotnet new nunit -n TDDKata.Tests
dotnet sln add TDDKata.Tests/TDDKata.Tests.csproj
dotnet add TDDKata.Tests/TDDKata.Tests.csproj reference TDDKata/TDDKata.csproj
(Optional) Create a console project, add it to the solution,and link the main project:
dotnet new console -n TDDKata.Console
dotnet sln add TDDKata.Console/TDDKata.Console.csproj
dotnet add TDDKata.Console/TDDKata.Console.csproj reference TDDKata/TDDKata.csproj
Running the Tests
The test can be run with:
dotnet test
Build the project:
dotnet build
Run the console project:
dotnet run --project TDDKata.Console
TDD cycle
The TDD cycle involves three steps:
1 — Write a Test
Writing the test is often the hardest part, but it provides valuable insights. For example:
- If a test is difficult to write, the code may be too coupled or doing too much.
- If a test isn’t meaningful, the code may lack purpose.
2 — Implement a Minimal Solution
Write the simplest implementation to make the test pass. Modern AI tools can often assist with this step.
3 — Refactor the Code
Refactor for quality by following principles like SOLID :
- S ingle Responsibility
- O pen/Closed
- L iskov Substitution
- I nterface Segregation
- D ependency Inversion
This step ensures the code is maintainable, performant, and secure.
Kata 1 — FizzBuzz
Now that we have a simple setup, let’s begin with the first kata. The initial requirement is:
- Requirement 1: Write a “FizzBuzz” method that accepts a number as input and returns it as a string.
Let’s assume no other requirements exist for now and focus on writing a test for this case:
[TestCase(1, "1")]
[TestCase(-1, "-1")]
public void GivenNumber_ReturnsStringVersion(int given, string expected)
{
string result = Kata1.FizzBuzz(given);
Assert.That(result, Is.EqualTo(expected));
}
With a failing test in place, the next step is to fix it:
class TDDKata1 {
public static string FizzBuzzSimple(int number) {
return $"{number}";
}
}
Once the test passes, we can consider refactoring. Refactoring is a critical step that can take various forms, depending on the project’s goals. In this series, we’ll evaluate our code against the SOLID principles:
- S: Single Responsibility Principle — Does the code handle a single responsibility?
- O: Open/Closed Principle — Is the code open for extension but closed for modification?
- L: Liskov Substitution Principle — Can derived classes substitute their base class while respecting its intent?
- I: Interface Segregation Principle — Should we split large contracts into more specific ones?
- D: Dependency Inversion Principle — Do high-level modules depend on abstractions rather than low-level details? At this stage, there isn’t much to refactor since the requirements are minimal, and the code is straightforward.
Now we receive a new requirment:
- Requirement 2 — For multiples of three return “Fizz” instead of the number
Great! First, let’s write a test to describe this new requirement:
[TestCase(3, "Fizz")]
[TestCase(9, "Fizz")]
public void GivenMultipleOfThree_ThenItReturnsFizz(int given, string expected)
{
string result = Kata1.FizzBuzz(3);
Assert.That(result, Is.EqualTo(expected));
}
The test fails, so let’s implement it in the simplest way we can think of:
class TDDKata1 {
public static string FizzBuzzSimple(int number) {
if (number % 3 == 0) {
return "Fizz";
}
return $"{number}";
}
}
The test now passes! We enter the refactor cycle again. This time, things are more interesting because we have more requirements to consider.
When reflecting on the Open/Closed Principle, one question likely comes to mind: What happens if new rules are added? Are we going to continue increasing the complexity of the FizzBuzz method? That would definitely break the principle. So, how can we solve this?
This is the perfect opportunity to brainstorm design patterns or research possible solutions. A simple and effective solution here is to implement a Strategy Pattern, allowing us to add new rules without modifying the existing code.
Our tests provide a solid foundation, enabling us to refactor confidently without breaking the current implementation.
namespace TDDKata1;
public interface IFizzBuzzStrategy
{
bool CanHandle(int number);
string Handle(int number);
}
public class DefaultStrategy : IFizzBuzzStrategy
{
public bool CanHandle(int number) => true;
public string Handle(int number) => $"{number}";
}
public class MultipleOfThreeStrategy : IFizzBuzzStrategy
{
public bool CanHandle(int number) => number % 3 == 0;
public string Handle(int number) => "Fizz";
}
public class Kata1
{
private static List<IFizzBuzzStrategy> _strategies =
[
new MultipleOfThreeStrategy(),
];
private static IFizzBuzzStrategy DefaultStrategy = new DefaultStrategy();
public static string FizzBuzzSimple(int number)
{
return $"{number}";
}
public static string FizzBuzz(int number)
{
foreach (IFizzBuzzStrategy strategy in _strategies)
{
if (strategy.CanHandle(number))
{
return strategy.Handle(number);
}
}
return DefaultStrategy.Handle(number);
}
}
This is much more flexible! And the tests are passing, so we can move on.
*Requirement 3 — For multiples of five, return “Buzz.”
Let’s describe this with a test:
[TestCase(5, "Buzz")]
[TestCase(10, "Buzz")]
public void GivenMultipleOfFive_ThenItRetunrsBuzz(int given, string expected)
{
string result = Kata1.FizzBuzz(given);
Assert.That(result, Is.EqualTo(expected));
}
This is great, our previous refactor set’s us in a great position to very quickly implement this. We just need to add a new strategy
public class MultipleOfFiveStrategy : IFizzBuzzStrategy
{
public bool CanHandle(int number) => number % 5 == 0;
public string Handle(int number) => "Buzz";
}
Refactor time. For the moment, nothing much to add, our current setup seems to handle for the moment all possible cases.
Requirement 4 — For numbers that are multiples of both three and five, return “FizzBuzz.”
Let’s write a test for this:
[TestCase(15, "FizzBuzz")]
[TestCase(30, "FizzBuzz")]
public void GivenMultipleOfThreeAndFive_ThenItReturnsFizzBuzz(int given, string expected)
{
string result = Kata1.FizzBuzz(given);
Assert.That(result, Is.EqualTo(expected));
}
Once again, our setup works perfectly. We just need to add this new strategy:
public class MultipleOfThreeAndFiveStrategy : IFizzBuzzStrategy
{
public bool CanHandle(int number) => number % 3 == 0 && number % 5 == 0;
public string Handle(int number) => "FizzBuzz";
}
And with that, the kata is complete. We can imagine how more complex requirements might force us to invest more time in flexible refactors, but our current structure makes it much easier to manage. It gives us confidence that added complexity will still reproduce the simpler, correct behavior from earlier stages.
Top comments (0)