- By Rowan Lea
- ·
- Posted Jan 25, 2024 10:21:11 AM
Songbird
Inspired by the creative works of Gawain Hewitt: https://gawainhewitt.co.uk/
This kata, taken from a popular children's maths game (or student drinking game), is the starting point on the TDD track. It's designed to be a semi-guided first stop for learning TDD from scratch.
We'll emphasise the following:
Write a function that takes positive integers and outputs their string representation.
Your function should comply with the following additional rules:
For example, given the numbers from 1 to 15 in order, the function would return:
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
As this is (most likely) your first ever attempt at classical TDD, this page will start you off. We will run through the first few cycles of TDD, showing the various steps, and try to explain the underlying rationale for the decisions we make.
Initially, we have no tests, no code, but the 'system' can be considered Green because we have no failing tests. The very first action is to convert to a state of Red (at least one test is failing)
To write the first test, we'll try to find the simplest unit of functionality that we can. In this case, recall that the program takes positive integers as input. The lowest positive integer is 1, so we will start with a test for that.
class FizzBuzzShould {
@Test
void convert_1_to_1() {
assertEquals("1", new FizzBuzz().convert(1));
}
}
It is worth briefly considering general advice around naming tests. Consider how you name your tests, as maintaining your tests and making changes to them will become a 'cost' to your development. Please read the section Why do you name the tests the way you do?
This will fail to compile because the FizzBuzz
class doesn't exist. Now that we have a failing test (compilation failure is still a failure!), we can write just enough production code to make the test compile:
public class FizzBuzz {
public String convert(int number) {
throw new UnsupportedOperationException("implement me!");
}
}
The test now fails instead with:
java.lang.UnsupportedOperationException: implement me!
at com.codurance.fizzbuzz.FizzBuzz.convert(FizzBuzz.java:5)
at FizzBuzzShould.convert_1_to_1(FizzBuzzShould.java:9)
....
Here we will apply the first principle of TDD:
You are not allowed to write any production code unless it is for making a failing unit test pass
What is the simplest code that will make the test go green? It's just to return a constant:
public String convert(int number) {
return "1";
}
This is known as "Faking it". Don't be concerned too much by this.
Some of the guiding principles for TDD are "KISS" (or "keep it simple stupid"), and "YAGNI" ("you ain't going to need it"). Both of these principles are subtly different but they both agree that in the context of TDD, the focus should be to just write enough code to pass the test, using the simplest solution to do so. This helps keep the design as clean and clear as possible.
One of the seemingly magical qualities of TDD is that even though some of the code seems counterintuitive to start with, by concentrating on the simplest solution possible an elegant solution will emerge as you add tests. Stick with the simplest solution possible and only introduce additional complexity as and when it is required.
Many developers feel the urge to predict where the changes to the code should go - resist this urge. The beauty of TDD is that as you go along you will start to introduce the necessary features for the desired solution, but you will be guided by the tests, and the tests will confirm those changes once they are in place.
Even Kent Beck, the pioneer of TDD, has suggested a principle of his own: "fake it until you make it". This is what we've just done.
There's no duplication in the code that can be removed, so there's no refactoring to do yet.
Congratulations! You've completed one full cycle of classical TDD. Now we repeat the process.
Trying to decide what to write as the next test is where the driving of test-driven development comes in.
What particular behaviour in the specification are we driving towards?
In our opinion, it's in the first line:
Write a function that takes positive integers and outputs their string representation
Our code has made the first step towards implementing this feature, but it's not correct yet: convert(2)
will return "1"
.
Our next test can target this:
@Test
void convert_2_to_2() {
assertEquals("2", new FizzBuzz().convert(2));
}
The simplest thing to make this pass is adding simple if
statement:
public String convert(int number) {
if (number == 2) return "2";
return "1";
}
This is the next implementation strategy up from "Faking It" which is "Obvious Implementation" - basically if the implementation is obvious, code it in.
Be Patient. To an experienced developer, these steps seem trivial and unnecessary. And for developers who are very experienced in TDD, they sometimes skip these steps. But any discipline is about learning to do it properly, and practising in this way until it becomes internalised and second-nature. When you're learning to play the piano, you need to practice your scales and chopsticks. It's the same with TDD.
There is now some duplication in both the implementation and the tests. However, we don't want to try to refactor quite yet.
While normally code should conform to the Don't Repeat Yourself (a.k.a. DRY) principle, that doesn't mean you need to remorselessly refactor out duplication as soon as you see it. There is a more subtle guiding principle that is worth considering, which is about deferring decision making. Experienced designers know that "the best decisions are made with the maximum possible information". Therefore pragmatic designers try to defer design decisions for as long as they can.
This is not about ignoring duplication, but about tolerating it for a short time, as a trade-off to get a clearer picture of any emerging patterns in your code. Sometimes if you remove a pattern of duplication immediately it leads to less optimal designs. By waiting for the duplication to happen three times, you allow the possibility of a larger more subtle pattern to emerge that requires a different design decision.
So, until we see three cases of obvious redundancy, we will defer refactoring it out. This is known as the Rule of Three.
We are still driving towards a function that outputs the string representation of positive integers: this is our first "feature", however small. We prefer not to add more "features" (e.g. printing Fizz instead of 3) until the current one is fully implemented.
Besides, in this case, the other rules are special cases over a base case. So it makes sense to finish off the base case before moving to the next bit of behaviour.
So we choose another regular number, avoiding what we know will become Fizz
, for our next test:
@Test
void convert_4_to_4() {
assertEquals("4", new FizzBuzz().convert(4));
}
The simplest solution to make the test pass is still to add another if statement:
public String convert(int number) {
if (number == 4) return "4";
if (number == 2) return "2";
return "1";
}
We can easily spot a rule-of-three violation in the implementation now. This is a chance for us to make a leap from a specific solution to a more generic one:
public String convert(int number) {
return String.valueOf(number);
}
It's also important to keep duplication as low as possible in tests, so we convert our three methods to a parameterized test case:
@ParameterizedTest
@CsvSource({ "1,1", "2,2", "4,4" })
void convert_number_to_FizzBuzz_string(int input, String expectedOutput) {
assertEquals(expectedOutput, new FizzBuzz().convert(input));
}
In the first to third cycle, we were dealing with the most generic requirement of the function: that it takes positive integers and outputs their string representation.
The refactoring to String.valueOf()
we made in the last cycle has had a great effect: by induction we can see that implementation holds for all positive integers that aren't multiples of 3 or 5, not just the three test cases that we have.
This is fantastic: our tests have driven us to a simple generic algorithm!
However, if we look back at the specification, we'll see that we have three more unimplemented behaviours:
Fizz
for multiples of 3Buzz
for multiples of 5FizzBuzz
for multiples of 3 and 5At this point, we have an algorithm that is incomplete in its implementation but covers the largest (generic) use case. We now try to use the third implementation strategy of triangulation. We do this by creating a specific test case, that forces the behaviour of your code to change.
This is a strategy performed as a small step, where the implementation for the specific tests can start to reveal the underlying patterns that will give clues to the eventual generic solution.
So in order to start implementing the Fizz
behaviour, we can add a test for a specific case: the number 3. Adding this test and implementing the code to satisfy will start to reveal the change in the behaviour.
We add a new test:
@Test
void convert_3_to_Fizz() {
assertEquals("Fizz", new FizzBuzz().convert(3));
}
We expect this to fail - it is expecting Fizz
but our code will give it 3
. Indeed, it does fail:
org.opentest4j.AssertionFailedError:
Expected :Fizz
Actual :3
The simplest way to implement this is with a simple if
condition:
String convert(int i) {
if (i == 3) return "Fizz";
return String.valueOf(i);
}
Note that once again we have implemented only the code that is necessary to make the test pass. We might have a good idea of where the algorithm is going, but it's important not to jump ahead: let the tests lead the way.
There's no obvious duplication here, so there's nothing to refactor.
Try to implement the rest of the exercise on your own:
Buzz
behaviour for numbers that are multiples of 5FizzBuzz
It might seem it for now, because this kata is simple enough that a full solution will immediately suggest itself to most developers. However, consider this: you will often be designing algorithms that are too big to reason about in one go. When that happens, writing the simplest thing first and building it up in baby steps starts to look like a much more attractive proposal. In other words, TDD scales very well.
When doing katas, we like to focus on the Happy Path. There is certainly a time and a place for protection from bad input (e.g. in user interfaces!), but that's not what we're practising here. Besides, good code design would suggest separating the responsibility of checking input validity from the responsibility of executing the algorithm. So if this were a real system, the FizzBuzz
class might be wrapped by a FizzBuzzInputChecker
class which ensured that FizzBuzz
was only ever fed valid numbers.
One of the pitfalls of TDD is that people belatedly recognise that the tests are as much a part of the code base as the actual code. Initially, people tend to make the mistake of treating tests as second-class code that is somehow inferior to the code that implements the software. Almost everyone comes to realise that the tests are equally as important as the implementation code.
One of the great advantages of tests is that when they break they can give the developers fast feedback of where the problem has occurred. The way a test is named can either help or hinder this process.
When naming a test, it is recommended to refrain from:
Coupling your test names with the names of the classes the test is based is also not advisable. This is because your code will be constantly changing - classes can be renamed or deprecated, or have some of their functions moved into another class. Every time you change the configuration of classes in your code, you leave yourself open to breaking the associated tests.
The best practice for naming tests is to use names that describe the business functionality or feature. This allows you to refactor your code such that, as long as the business behaviour remains the same, you should not have to change your test name.
One version of the FizzBuzz game in real life adds an extra Fizz or Buzz whenever one of the digits ('3' or '5') appears in the number itself (for example, see Dr Mike's Math Games for Kids).
So, '3' would become FizzFizz
, '5' would become BuzzBuzz
, '15' would be FizzBuzzBuzz
.
Modify your program to reflect this requirement while maintaining your discipline in using red-green-refactor cycles.
Inspired by the creative works of Gawain Hewitt: https://gawainhewitt.co.uk/
What do we want to build? We are building a shopping cart for an online grocery shop. The idea of this kata is to build the product in an iterative..
Iteration 1 Business rules We want to build an ATM machine and the first thing we need to do, is to create the software that will breakdown which..
Join our newsletter for expert tips and inspirational case studies
Join our newsletter for expert tips and inspirational case studies