Writing code the easy way with TDD
Often I need to write some complex code with multiple requirements that when stacked on top of each other require a larger cognitive load than I care for. Along with its other benefits, Test-driven Development makes it easier and quicker to write code of this nature.
For example, lets say we need to write some code that will convert a fashion brand, like Off-White, L’Afshar, or Issey Miyake, into a hashtag. Hashtags have rules, and in our case the requirements will be:
- They should start with a
#
- They should not have any punctuation
- They should not have any spaces
- They should be in lower case
Writing the tests
First, let’s create a test project using xUnit. We’ll also add my favorite assertion library, Shouldly, not only because I prefer the syntax, but because it does a great job of describing why tests fail.
dotnet new xunit -o Test
cd Test
dotnet add package Shouldly
We’ll create a new class (likely outside our test project) that will contain our hashtag generator logic and, for now, its only method will just return whatever was passed to it.
public class HashtagGenerator
{
public string Generate(string input)
{
return input;
}
}
Rather than put any thought into how we should implement that method, instead we’ll take a number of brand examples and convert them into hashtags ourselves.
Brand | Hashtag |
---|---|
Off-White | #offwhite |
L’Afshar | #lafshar |
Issey Miyake | #isseymiyake |
We’ll then use the Theory
and InlineData
xUnit attributes to write test methods that will map directly to my hashtag requirements and using our example brands with their expected hashtags. I found a number of other brand examples I want to ensure the code will cater for so we’ll those as well.
public class HashtagGeneratorTests
{
HashtagGenerator _generator = new HashtagGenerator();
[Theory]
[InlineData("Off-White", "#offwhite")]
[InlineData("L'Afshar", "#lafshar")]
[InlineData("P.A.R.O.S.H.", "#parosh")]
[InlineData("Nº21", "#n21")]
[InlineData("RED(V)", "#redv")]
public void Should_strip_punctuation(string input, string expected)
{
_generator.Generate(input).ShouldBe(expected);
}
[Theory]
[InlineData("K Jacques", "#kjacques")]
[InlineData("Issey Miyake", "#isseymiyake")]
[InlineData("A.W.A.K.E. Mode", "#awakemode")]
public void Should_strip_spaces(string input, string expected)
{
_generator.Generate(input).ShouldBe(expected);
}
[Theory]
[InlineData("AQUAZZURA", "#aquazzura")]
[InlineData("Missoni", "#missoni")]
public void Should_transform_to_lowercase(string input, string expected)
{
_generator.Generate(input).ShouldBe(expected);
}
[Theory]
[InlineData("Givenchy", "#givenchy")]
[InlineData("Birkenstock", "#birkenstock")]
public void Should_prefix_with_hash(string input, string expected)
{
_generator.Generate(input).ShouldBe(expected);
}
}
For each InlineData
, it’s parameters will be passed to the method the attribute adorns. This means that each test method will be called once per InlineData
. Notice too that the test methods are identical - only the test name changes - each one representing a requirement. 12 InlineData
means 12 tests.
Running the tests, expectedly, results in 12 failures. I’ve --CUT--
the output for brevity, but already we can see the value that Shouldly gives us as it explains why our tests didn’t pass.
dotnet test
--CUT--
Starting test execution, please wait...
--CUT--
X Test.HashtagGeneratorTests.Should_strip_punctuation(input: "Off-White", expected: "#offwhite") [1ms]
Error Message:
Shouldly.ShouldAssertException : _generator.Generate(input)
should be
"#offwhite"
but was
"Off-White"
difference
Difference | | | | |
| \|/ \|/ \|/ \|/
Index | 0 1 2 3 4 5 6 7 8
Expected Value | # o f f w h i t e
Actual Value | O f f - W h i t e
Expected Code | 35 111 102 102 119 104 105 116 101
Actual Code | 79 102 102 45 87 104 105 116 101
Stack Trace:
at Test.HashtagGeneratorTests.Should_strip_punctuation(String input, String expected) in /Users/staffordwilliams/git/coach_bags/src/Test/HashtagGeneratorTests.cs:line 20
--CUT--
Test Run Failed.
Total tests: 12
Failed: 12
Total time: 3.1424 Seconds
Implementing the requirements
Now that the tests are written, it’s time to implement the requirements. Because we’re lazy, but also 😎, we’ll automate future test runs while we write the code.
dotnet watch test
watch : Waiting for a file to change before restarting dotnet...
Right off the bat, we’ll implement two requirements: lower case and hash prefix.
public class HashtagGenerator
{
public string Generate(string input)
{
var transformed = input.ToLower();
return $"#{transformed}";
}
}
The moment we save, dotnet watch
triggers another test run and, we’ve got four passing tests!
Total tests: 12
Passed: 4
Failed: 8
Now we need to strip punctuation and, like all good programmers, we presume someone has already written that code. As such, we type the following into google:
.net strip everything except letters and numbers
The first result is a 10 year old stackoverflow question and we unabashedly copy paste the highest voted answer directly into our code.
public class HashtagGenerator
{
public string Generate(string input)
{
Regex rgx = new Regex("[^a-zA-Z0-9 -]");
var transformed = rgx.Replace(input, "")
.ToLower();
return $"#{transformed}";
}
}
On save, four more tests are passing, leaving us with only four failing tests. One of the failing tests however is from our strip punctuation requirement.
Error Message:
Shouldly.ShouldAssertException : _generator.Generate(input)
should be
"#offwhite"
but was
"#off-white"
Taking a closer look at the regex from the code we pasted, we notice a hyphen at the end of the pattern. Removing the hyphen results in the test passing! It also draws our inquisitive eye to the space at the end of the pattern, so we remove that also and our last requirement is met - all tests pass!
public class HashtagGenerator
{
public string Generate(string input)
{
Regex rgx = new Regex("[^a-zA-Z0-9]");
var transformed = rgx.Replace(input, "")
.ToLower();
return $"#{transformed}";
}
}
Test Run Successful.
Total tests: 12
Passed: 12
Conclusion
The finished result is an implementation with great test coverage. The real examples that the tests use give us high confidence that the code works as expected and, that we weren’t breaking the implementation as we edited code to implement additional requirements. The tests were trivial to create and, were we to make breaking changes in the future, we’ll quickly see both the requirement and the reason we’ve created a bug. The benefits of using this process and its lowered cognitive load can make Test-driven Development an easier, quicker and safer way to write code.