Continuous Delivery and TDD Series — Kata 2
Following the introduction in Kata 1, we now move on to Kata 2.
During the implementation of this Kata we will experience some key ideas:
- Tests provide valuable feedback signals about code design.
- Unlike manual tests, automated tests can be version-controlled and continuously improved, creating a positive feedback loop.
- Test-driven development (TDD) is a fantastic way to guide LLM-generated code.
Ok, so let’s get started. Our goal is to create a simple calculator that takes a string as input and returns an integer. The calculator method is defined with a signature similar to:
int Add(string numbers)
Requirement 1
- Handle Up to Two Numbers Initially, the method should accept up to two numbers separated by commas and return their sum. For example, the valid inputs are an empty string, “1”, or “1,2”. When provided an empty string, the method should return 0.
It is essential to capture this requirement through tests. Ideally, each test should assert a single behavior so that when a test fails, it points directly to the issue without having to dig through complex code. Think of each small test as a precise measurement, giving us clear insights into what might be wrong.
We start with the simplest case — an empty string:
[Test]
public void GivenAnEmptyString_ItReturnsZero()
{
var calculator = new SimpleCalculator();
var result = calculator.Add("");
Assert.AreEqual(0, result);
}
A the most simple implementation we can come up with:
namespace TDDKata;
public class SimpleCalculator
{
public int Add(string input) {
return 0;
}
}
Nothing much to refactor, let’s move on to the next test:
[Test]
public void GivenASingleNumber_ItReturnsThatNumberAsInt()
{
var calculator = new SimpleCalculator();
var result = calculator.Add("3");
Assert.AreEqual(3, result);
}
Time to figure out how to implent this. Ok, so it seems that in C# we have an int.tryParse, we can use that, and actually also get the behaviour of the previous test case:
public int Add(string input)
{
bool success = int.TryParse(input, out var result);
return success ? result : 0;
}
Tests passing and nothing to refactor, we can now move on to a test where the input consists of two numbers separated by a comma, expecting their sum as the output.
[Test]
public void GivenAStringOfTwoNumberSeparatedByComas_ThenItReturnsTheSumAsInt()
{
var calculator = new SimpleCalculator();
var result = calculator.Add("1, 2");
Assert.AreEqual(3, result);
}
Something like this seems to work:
public static int Add(string input)
{
var numbers = input.Split(',');
foreach (var number in numbers)
{
bool success = int.TryParse(number.Trim(), out var result);
if (!success)
{
return 0;
}
}
return numbers.Sum(int.Parse);
}
}
And with these three test we have capture the initial first requirement!
As complexity grows, we will little by little feel the power of having our simple tests in our back, allowing us to restructure our code as much as we want.
Requirement 2
- Allow the add method to handle an unknown number of arguments
Our initial implementation already allows for an unknown number of arguments. Let’s just make sure that we are capturing this behaviour in our tets. To make our tests more maintainable, we can use a parameterized test approach. This method reduces repetition by ensuring that only the inputs and expected results change between tests.
public class TestSimpleCalculator
{
[TestCase("", 0)]
[TestCase("3", 3)]
[TestCase("1, 2", 3)]
[TestCase("1, 2, 3, 4, 5", 15)]
public void GivenAStringInput_ItReturnsTheExpectedResult(string input, int expected)
{
var result = SimpleCalculator.Add(input);
Assert.AreEqual(expected, result);
}
}
All test passing, I don’t see any obvious potential refactor gains, so let’s continue.
Requirement 3
- Allow the add method to handle newlines as separators, instead of comas. Example: “1,2\n3” should return “6”
Our current setup allows to add this test fairly easily
public class TestSimpleCalculator
{
[TestCase("", 0)]
[TestCase("3", 3)]
[TestCase("0, 1", 1)]
[TestCase("1, 2", 3)]
[TestCase("1, 2, 3, 4, 5", 15)]
[TestCase("1, 2 \n 5", 8)]
public void GivenAStringInput_ItReturnsTheExpectedResult(string input, int expected)
{
var result = SimpleCalculator.Add(input);
Assert.That(result, Is.EqualTo(expected));
}
}
Test failing, time for a simple implementation:
public static int Add(string input)
{
var numbers = input.Split([',', '\n']);
foreach (var number in numbers)
{
bool success = int.TryParse(number.Trim(), out var result);
if (!success)
{
return 0;
}
}
return numbers.Sum(int.Parse);
}
Refactor time — now it’s a little more obvious that this function is probably doing too many things. On one hand, it handles string preparation and parsing; on the other, it performs the actual calculation. We can tidy it up by splitting these responsibilities into different functions, which would be easier to maintain.
Here, we begin to feel the power of TDD. We can experiment and iterate with great safety, without needing extensive manual checks to confirm that our implementation works — our automated tests handle that for us.
Nowadays, this approach becomes even more valuable given how easy it is to generate code with LLMs. For example, here is a more modular proposal from ChatGPT-4.
public static int Add(string input)
{
bool success = GetNumbers(input, out var numbers);
if (numbers.Count() == 0)
{
return 0;
}
return numbers.Sum();
}
}
private static bool GetNumbers(string input, out IEnumerable<int> numbers)
{
numbers = input.Split([',', '\n'])
.Select(number => int.TryParse(number.Trim(), out var result) ? (int?)result : null)
.Where(n => n.HasValue)
.Select(static n => n.GetValueOrDefault());
return true;
}
We can run our tets to verify that is a valid implementation to our problem! We don’t need to be afraid of hallucinations as far as we have tests that prove that a given piece of code is valid.
Back to the code, I like some of the ideas, and ChatGPT-4 is definitely more eloquent with LINQ than I am. I can now apply my personal preferences based on my standards of clean code and maintainability. Here is the final result, which I’m happy enough with to move on.
namespace TDDKata;
public class SimpleCalculator
{
public static bool Add(string input, out int result)
{
bool success = GetNumbers(input, out var numbers);
if (!success)
{
result = 0;
return false;
}
if (numbers.Count() == 0)
{
result = 0;
return true;
}
result = numbers.Sum();
return true;
}
private static bool GetNumbers(string input, out IEnumerable<int> numbers)
{
var maybeParsedNumbers = input.Split([',', '\n'])
.Select(static number => int.TryParse(number.Trim(), out var result) ? (int?)result : null);
if (maybeParsedNumbers.Any(static n => n == null))
{
numbers = Enumerable.Empty<int>();
return false;
}
numbers = maybeParsedNumbers.Select(n => n.GetValueOrDefault());
return true;
}
}
I’ve decided to slightly change the interface to be more similar to that of TryParse; therefore, the test had to be updated. It’s important to note that tests only change when we modify the external interface.
namespace TDDKata.Tests;
public class TestSimpleCalculator
{
[TestCase("", 0)]
[TestCase("3", 3)]
[TestCase("0, 1", 1)]
[TestCase("1, 2", 3)]
[TestCase("1, 2, 3, 4, 5", 15)]
[TestCase("1, 2 \n 5", 8)]
public void GivenAStringInput_ItReturnsTheExpectedResult(string input, int expected)
{
SimpleCalculator.Add(input, out var result);
Assert.That(result, Is.EqualTo(expected));
}
}
Requirement 4
- Add validation to disallow a separator at the end.
While working on one requirement, it’s possible to break previous ones. For example, I broke the requirement that an empty string returns success and 0. Having tests makes it easier to debug and iterate. It was particularly useful to connect the VSCode debugger during the process.
How to connect with the VSCode C# debugger
In VSCode it’s as simple as:
If you select the Test solution, the run test icon will appear next to each test. Right-clicking (or holding Alt) shows the option to run them in debug mode, which will stop at the breakpoints you set.
After some iterations, the code took this shape.
namespace TDDKata.Tests;
public class TestSimpleCalculator
{
[TestCase("", 0)]
[TestCase("3", 3)]
[TestCase("0, 1", 1)]
[TestCase("0, 1", 1)]
[TestCase("1, 2", 3)]
[TestCase("1, 2, 3, 4, 5", 15)]
[TestCase("1,2, 3,4, 5", 15)]
[TestCase("1, 2 \n 5", 8)]
public void GivenAStringInput_ThenItReturnsTheExpectedResult(string input, int expected)
{
var success = SimpleCalculator.Add(input, out var result);
Assert.That(success, Is.True);
Assert.That(result, Is.EqualTo(expected));
}
[TestCase("1,2,")]
[TestCase("1, 2,\n")]
[TestCase("1,2\n")]
public void GivenAStringInputWithASeparatorAtTheEnd_ThenReturnsFalse(string input)
{
var success = SimpleCalculator.Add(input, out var result);
Assert.That(success, Is.False);
}
}
namespace TDDKata;
public class SimpleCalculator
{
public static bool Add(string input, out int result)
{
if (!IsInputStringValid(input))
{
result = 0;
return false;
}
bool success = GetNumbers(input, out var numbers);
if (!success)
{
result = 0;
return false;
}
result = numbers.Sum();
return true;
}
private static readonly char[] _Separators = [',', '\n'];
private static bool IsInputStringValid(string input)
{
if (input.Length == 0) return true;
var lastChar = input.Last();
return !_Separators.Contains(lastChar);
}
private static bool GetNumbers(string input, out IEnumerable<int> numbers)
{
var maybeParsedNumbers = input
.Split(_Separators)
.Select(x => x.Trim())
.Where(static n => !string.IsNullOrWhiteSpace(n))
.Select(static number => int.TryParse(number.Trim(), out var result) ? (int?)result : null);
// Return false if any of the numbers could not be parsed
if (maybeParsedNumbers.Any(static n => n == null))
{
numbers = [];
return false;
}
numbers = maybeParsedNumbers.Select(n => n.GetValueOrDefault());
return true;
}
}
Requirement 5
- Allow the add method to handle different delimiters
While we add more tests, and it’s important to appreciate that our test code also improves. We may learn new techniques, find ways to avoid repetition in our tests, or discover methods to speed up the testing process. Our test code continues to evolve. When we rely solely on manual testing, we lose the benefits of this positive feedback loop.
It is much more difficult to maintain version control and track improvements with manual tests (and we also lose more time executing them), whereas code does not have these downsides. We can iterate and improve continuously. This is the same reason why infrastructure as code is far more powerful than manual infrastructure management. Nevertheless, it’s always a trade-off, as there is obviously some overhead in developing tests. In my experience, this overhead is easily recovered. Still, it’s important to understand what we are sacrificing when making trade-offs. The more skilled you become at writing tests, the more often the trade-off will point towards automated testing.
[TestCase("//;\n1;2", 3)]
public void GivenAStringInputWithCustomSeparator_ThenItReturnsTheSum(string input, int expected)
{
var success = SimpleCalculator.Add(input, out var result);
Assert.Multiple(() =>
{
Assert.That(success, Is.True);
Assert.That(result, Is.EqualTo(expected));
});
}
Code implementation:
namespace TDDKata;
public class SimpleCalculator
{
private static readonly char[] _DefaultSeparators = [',', '\n'];
public static bool Add(string input, out int result)
{
if (!IsInputStringValid(input))
{
result = 0;
return false;
}
ParseCustomSeparator(input, out string calculationInput, out char[] separators);
bool success = GetNumbers(calculationInput, separators, out var numbers);
if (!success)
{
result = 0;
return false;
}
result = numbers.Sum();
return true;
}
private static void ParseCustomSeparator(string input, out string calculationInput, out char[] separators)
{
bool hasCustomSeparator =
input.Length > 4 && input[0] == '/' && input[1] == '/' && input[3] == '\n';
if (!hasCustomSeparator)
{
separators = _DefaultSeparators;
calculationInput = input;
return;
}
separators = [input[2]];
calculationInput = input[4..];
}
private static bool IsInputStringValid(string input)
{
if (input.Length == 0) return true;
var lastChar = input.Last();
return !_DefaultSeparators.Contains(lastChar);
}
private static bool GetNumbers(string input, char[] separators, out IEnumerable<int> numbers)
{
var maybeParsedNumbers = input
.Split(separators)
.Select(x => x.Trim())
.Where(static n => !string.IsNullOrWhiteSpace(n))
.Select(static number => int.TryParse(number.Trim(), out var result) ? (int?)result : null);
// Return false if any of the numbers could not be parsed
if (maybeParsedNumbers.Any(static n => n == null))
{
numbers = [];
return false;
}
numbers = maybeParsedNumbers.Select(n => n.GetValueOrDefault());
return true;
}
}
Time to do some refactoring! I don’t like having to validate input strings directly, it gives too much work to the Add class of the calculator; in these cases, I prefer using the Value Object Pattern. The requirements force us to accept a string as input, but we can also provide a simple implicit conversion from string to our SimpleCalculatorInput, so that our tests calling the interface with a string won’t need to be changed.
Notice how we can also separate the testing. When a class requires extensive testing, it’s usually a sign that it is handling too many responsibilities. Over time, this can make maintenance increasingly difficult. By splitting input parsing and validation into a separate sub-problem, we simplify the calculator and improve maintainability.
Final Version
Final version for the tests:
namespace TDDKata.Tests;
public class TestSimpleCalculator
{
[Test]
public void GivenASimpleCalculatorInput_WhenAddIsCalled_ItReturnsTheSum()
{
var input = new SimpleCalculatorInput([1, 2, 3]);
var success = SimpleCalculator.Add(input, out var result);
Assert.Multiple(() =>
{
Assert.That(success, Is.True);
Assert.That(result, Is.EqualTo(6));
});
}
}
public class TestSimpleCalculatorInput
{
[Test]
public void GivenAnEmptyInputString_ThenItReturnsIsValidTrueAndEmptyNumbers()
{
var input = SimpleCalculatorInput.Create("");
Assert.Multiple(() =>
{
Assert.That(input.IsValid, Is.True);
Assert.That(input.Numbers, Is.Empty);
});
}
[TestCase("1,2,3", new int[] { 1, 2, 3 })]
[TestCase("1\n2\n3", new int[] { 1, 2, 3 })]
[TestCase("1\n2,3", new int[] { 1, 2, 3 })]
public void GivenAStringUsingDefaultSeparators_ThenItReturnsIsValidAndTheNumbers(
string inputString, int[] numbers)
{
var input = SimpleCalculatorInput.Create(inputString);
Assert.Multiple(() =>
{
Assert.That(input.IsValid, Is.True);
Assert.That(input.Numbers, Is.EqualTo(numbers));
});
}
[TestCase("1,2,3,")]
[TestCase("1,2,3\n")]
[TestCase("//;\n1,2;3;")]
public void GivenAStringWithASeparatorAtTheEnd_ThenItReturnsIsValidFalse(
string inputString)
{
var input = SimpleCalculatorInput.Create(inputString);
Assert.Multiple(() =>
{
Assert.That(input.IsValid, Is.False);
Assert.That(input.Numbers, Is.Empty);
});
}
[TestCase("//;\n1;2;3", new int[] { 1, 2, 3 })]
[TestCase("//;\n1,2;3", new int[] { 1, 2, 3 })]
public void GivenACustomSeparator_ThenItReturnsTheExpectedNumbers(
string inputString, int[] numbers)
{
var input = SimpleCalculatorInput.Create(inputString);
Assert.Multiple(() =>
{
Assert.That(input.IsValid, Is.True);
Assert.That(input.Numbers, Is.EqualTo(numbers));
});
}
}
public class TestSimpleCalculatorRequirements
{
[TestCase("", 0)]
[TestCase("3", 3)]
[TestCase("0, 1", 1)]
[TestCase("0,1", 1)]
[TestCase("\n0,1", 1)]
[TestCase("1, 2", 3)]
[TestCase("1, 2, 3, 4, 5", 15)]
[TestCase("1,2, 3,4, 5", 15)]
[TestCase("1, 2 \n 5", 8)]
public void GivenAStringInput_ThenItReturnsTheSum(string input, int expected)
{
var success = SimpleCalculator.Add(input, out var result);
Assert.Multiple(() =>
{
Assert.That(success, Is.True);
Assert.That(result, Is.EqualTo(expected));
});
}
[TestCase("1,2,")]
[TestCase("1, 2,\n")]
[TestCase("1,2\n")]
public void GivenAStringInputWithASeparatorAtTheEnd_ThenReturnsFalse(string input)
{
var success = SimpleCalculator.Add(input, out var result);
Assert.That(success, Is.False);
}
[TestCase("//;\n1;2", 3)]
[TestCase("//\n\n1\n2", 3)]
public void GivenAStringInputWithCustomSeparator_ThenItReturnsTheSum(string input, int expected)
{
var success = SimpleCalculator.Add(input, out var result);
Assert.Multiple(() =>
{
Assert.That(success, Is.True);
Assert.That(result, Is.EqualTo(expected));
});
}
}
And the code implementation:
namespace TDDKata;
public class SimpleCalculatorInput(IEnumerable<int> numbers)
{
public bool IsValid { get; private set; } = true;
public IEnumerable<int> Numbers { get; private set; } = numbers;
private static readonly char[] _DefaultSeparators = [',', '\n'];
public static SimpleCalculatorInput Create(string input)
{
if (input.Length == 0)
{
var emptyResult = new SimpleCalculatorInput(numbers: [])
{
IsValid = true
};
return emptyResult;
}
ParseCustomSeparator(input, out string calculationInput, out char[] separators);
if (!IsInputStringValid(input, separators))
{
var invalidResult = new SimpleCalculatorInput(numbers: [])
{
IsValid = false
};
return invalidResult;
}
bool success = GetNumbers(calculationInput, separators, out var numbers);
var result = new SimpleCalculatorInput(numbers)
{
IsValid = success
};
return result;
}
public static implicit operator SimpleCalculatorInput(string input)
{
return Create(input);
}
private static bool IsInputStringValid(string input, char[] separators)
{
var lastChar = input.Last();
return !separators.Contains(lastChar);
}
private static void ParseCustomSeparator(string input, out string calculationInput, out char[] separators)
{
bool hasCustomSeparator =
input.Length > 4 && input[0] == '/' && input[1] == '/' && input[3] == '\n';
if (!hasCustomSeparator)
{
separators = _DefaultSeparators;
calculationInput = input;
return;
}
separators = [.._DefaultSeparators, input[2]];
calculationInput = input[4..];
}
private static bool GetNumbers(string input, char[] separators, out IEnumerable<int> numbers)
{
var maybeParsedNumbers = input
.Split(separators)
.Select(x => x.Trim())
.Where(static n => !string.IsNullOrWhiteSpace(n))
.Select(static number => int.TryParse(number.Trim(), out var result) ? (int?)result : null);
if (maybeParsedNumbers.Any(static n => n == null))
{
numbers = [];
return false;
}
numbers = maybeParsedNumbers.Select(n => n.GetValueOrDefault());
return true;
}
}
public class SimpleCalculator
{
public static bool Add(SimpleCalculatorInput input, out int result)
{
if (!input.IsValid)
{
result = 0;
return false;
}
result = input.Numbers.Sum();
return true;
}
}
And with this we have completed our implementation of Kata 2!
Top comments (0)