Test Driven Development, or TDD, is a well-known practice in the world of software development. In this blog post, I’ll be doing a deep dive into it and hopefully inspire some of you to try it out. The concept of it is fairly simple. The actual code is created from unit tests, so everything starts with a test. Imagine a puzzle where each test represents a piece of it, and as you fit them together, a bigger picture emerges.
One of the benefits of TDD is that it lets you focus on testing small pieces of code at a time; with that, your knowledge grows incrementally. This means that TDD follows an iterative process. You will slowly see the code evolve, giving space for refactoring and refinement. So, TDD’s structured approach provides a comprehensive overview of the development process and enables early error detection.
What is TDD?
The core of TDD lies in its alignment with requirements, which drive the unit tests forward. By crafting unit tests that reflect the desired outcomes, you can ensure that the code meets specific criteria. You start by writing the least amount of code needed for a test to pass. However, as additional requirements are implemented, previously passing tests may fail. This leads to a necessity of re-writing the code to restore harmony and make the tests pass again. So the code gracefully adapts to changing expectations.
The circular buffer tells the story
I created a small project to showcase how tests guide the coding process. I decided to implement a circular buffer in Python since this does not exist in the standard Python library.
So, what is a circular buffer? A circular buffer, also known as a circular queue or ring buffer, is a type of data structure that stores a collection of elements of a fixed size in a circular fashion. When the buffer reaches maximum capacity, the oldest elements are overwritten.
I created a list of requirements, pretending to be a customer that presents a wishlist for the developer. With all the expected functions and requirements written down, I could get started!
Red tests turn green
I planned to follow the TDD approach, write failing tests for each function and make them pass with minimal coding. Always keeping in mind that moving on to the next function would, at some point, make earlier tests fail since I’d gradually add on more requirements. This would, in turn, require me to go back to previously written functions and fix them.
I started with the tests for the is_empty() method, which should return True if the circular buffer is empty; otherwise False.
After the CircularBuffer class had been implemented, I added an expected variable to the test, which contained what I wanted the method to return.
To get the actual variable, I called the is_empty() method, and as the test result suggested; I then needed to implement it.
Then to the fun part, where the assertion told me that the is_empty() method returned None. The smallest change I could make for the test to pass was to return True. I could then move on to a new method, knowing that I might have to return to is_empty() since implementing more requirements could make the tests fail again. I started to look at the enqueue method. This method should add an item to the circular buffer. If the buffer isn’t full, the item should be added. If it is full, the oldest item in the buffer should be overwritten with the new item.
After implementing the enqueue method and making a test pass for it by merely adding an item to the list, the test for is_empty() failed. So, now I wanted to ensure that is_empty() could also return False if the circular buffer isn’t empty. I added a new test for that. This turned into another iteration of improving and refactoring the code for this method, and; voila! The requirements for is_empty() had been fulfilled.
The red-green process was one simple example of how iterations can be applied to TDD, and how we are left with a test suite that encompasses the functional behaviors.
However much I’d like to show you the whole process of how the thoroughly tested implementation materializes out of precise requirements, it would make this blog post a very long one!
But one last example I could show you is from the later stages in the implementation, where one of the core behaviors of a circular buffer was tested, which was how adding items to a full buffer should overwrite the oldest value. By comparing the expected output with the requirements, I realized that I needed to loop back around when I’d reached the end of the buffer. With help from the modulus operator as a checker for this scenario, I updated the code so that the reader and the writer for the buffer looped back around when it reached the end.
Test-driven Development, aka TDD, is highly valued for many compelling reasons. It offers numerous advantages for us developers and our software projects. The iterative cycle of writing tests, implementing code, and refactoring helps us improve maintainability and code quality. It also provides more rapid feedback, allowing us to catch errors early, therefore resulting in more reliable software. Of course, it might not always be the most fitting approach, and thorough testing takes time, but all in all, TDD does promote a meticulous and structured approach to development.