How to improve your design skills using Test-Driven Development and Top-Down approach
Keep the big picture always in mind while developing functionalities using TDD
What is TDD
Test-Driven Development is the practice used to write software where tests drive the design of the software.
This means that, instead of writing working code and, in the end, testing it, you do the opposite. You write a test and then write the code the test needs to pass. The code grows together with the tests, and when your code is complete, you don’t need to start writing tests since they are already there.
TDD can make good developers better and great developers greater.
(“Modern Software Engineering” by Dave Farley)
Not another Fizz-Buzz tutorial
There are several tutorials to explain the basics of TDD using basic examples like Fizz-Buzz, and they are great.
So, I decided to move the explanation to the next level. This post assumes you already know the basic principles of TDD, and its purpose is to guide you through implementing a more complex functionality. We will create, design, and implement the architecture of a small application using TDD.
This post will explain how TDD can help you through architectural design at a higher level instead of simple functions.
Note: In this post, I will skip basic examples and assume the reader is already familiar with the basic principles of TDD. So you won’t find tests like “should be a function,” I’ll go straight to more meaningful examples.
The games application
We want to implement an application with the following requirements:
It should accept 2 inputs: the name of the game and an argument
it should support 2 different games: Fizz-Buzz and Fibonacci.
the argument should be an integer
the Fizz-Buzz game should return the sequence of numbers with fizz and buzz in the correct positions until it reaches the number passed as the argument
the Fibonacci should return the Fibonacci sequence until the number passed as the argument
Fizz-Buzz example:
node fizzbuzz 10
1 2 fizz 4 buzz fizz 7 8 fizz 10
Fibonacci example:
node fibonacci 10
1 1 2 3 5 8
Top-Down approach
There are essentially 2 ways to approach this problem: bottom-up and top-down.
Choosing a bottom-up approach means you start by implementing the part of the applicant at the lowest level. In this case, the 2 functions responsible for the Fizz-Buzz and the Fibonacci. Then, you will integrate those functions in a higher-level function containing the logic to decide when to use Fizz-Buzz and Fibonacci.
A top-down approach is the exact opposite. You’ll start by implementing a function to choose between the 2 games. Empty functions will replace the 2 games at the beginning and then be appropriately implemented.
The second approach has the advantage of forcing the developer to think about the final result.
The implementation
When implementing functionalities involving choices, starting with a flow diagram is always a good practice.
As shown in the diagram, we must implement the 3 blocks: choice-function, Fizz-Buzz, and Fibonacci. Since we decided to use a top-down approach, the first step is implementing the choice function.
1. Implement the structure of the high-level function
The goal here is to finish by having the basic structure of the high-level function.
Start with the sad paths
Sad paths are the ones where the function cannot reach the end of the flow.
I always start with the sad paths since they are easier to test. This usually happens if an error is thrown. The test should be something like this:
After running it and ensuring it fails, we can move to the basic implementation to make it pass:
The next step is to ensure that it doesn’t throw an error if the input is a supported game:
And the related code to make it pass is the following:
The following sad path is when the argument is not an integer:
The minimum code to make it pass:
The happy path
The happy path is the one that makes the flow of the function reach its end.
I like to leave this one as the last one. It is usually more complicated than the sad paths and includes more steps to test it properly. When doing TDD, always let the requirements and the test errors guide your implementation.
The following requirement is:
The Fizz-Buzz game should return the sequence of numbers with fizz and buzz in the correct positions until it reaches the number passed as the argument.
The test might look like this:
Remember this test because having this high-level test for the “fizzbuzz” scenario will be crucial later in the process.
If you follow TDD strictly, you should start writing the code to pass this test. What you should do, instead, is leave the test and the “app.js” function as they are and move to the implementation of the “fizzBuzz.” Put the function in the place it is supposed to be and leave it there:
Because you don’t want to test the “fizzBuzz” function at this level, you assume you have a function named like that and assume it works appropriately.
2. The Fizz-Buzz function
I am not going deeply into implementing this function using TDD since this is not the purpose of the post, so here is a possible implementation with its test.
You need to work at this level until all tests for this function (basically only the “fizz-buzz.test.js” file) are green, and the function is completed. After that, rerun the entire test suite.
The result should be that all your tests passed.
3. Move up one level
All your tests are green now, so look at the requirements.
The following requirement is about the Fibonacci game. Once again, write a test for the “app” related to that requirement.
And change the “app.js” file accordingly.
Like before, it is time to test and implement the “Fibonacci” function.
4. The Fibonacci function
Here are the test and the implementation for the “fibonacci” function.
And now you can run all the tests together and see them pass.
Summary
This simple example explains how TDD can help you implement complex functionalities by following the path shown by the tests and the requirements.
Here is a recap of the steps:
write tests for the sad paths of the high-level function and implement the code needed to pass those tests
use the requirements as a guideline to write the tests for the happy path until you reach a possible lower-level function
when you reach a lower-level function, create a simple test in the higher-level function tests for that
move to tests and implementation of the lower-level function until all the tests pass
go back to the higher-level function and restart from step 2.