Development and testing go hand in hand: one can not be complete without the other. Traditionally, the emphasis has always been placed on developing. Developers code first, then test, fix any bugs they find, commit code, and the cycle repeats. Test-driven development (TDD) takes a slightly contrarian approach: test first, then write code later. These test results guide development activities.
Although it may sound counterintuitive, TDD is actually a better approach to development and testing. In this article, we’ll explore in depth the TDD concept, its best practices, and how you can leverage it for a better coding – testing experience.
TDD, or Test-Driven Development, is the process of writing and executing automated tests before writing code. Insights from the tests help the developer improve the code. TDD reflects the spirit of continuous feedback, resulting in faster bug identification and debugging.
TDD is a continuous, iterative process of improving code through tests. To do TDD, simply follow the mantra of Red - Green - Refactor cycle. Some may call it Fail - Pass - Refactor, but it’s all the same thing.
The idea is to fail a test - make it pass - then rewrite the code. Let’s see how it’s done in detail here:
You start by writing a test case for one specific feature. There is absolutely no code written for this feature yet. Essentially, we are writing the test for a feature that has not even been created.
It should be quite easy to see that this test will definitely fail. Counterintuitive as it may seem, this is where the ingenuity of TDD lies. There are four major rationales as to why we must fail the test:
Once you fail your test, you have reached the Red stage of the Red - Green - Refactor cycle.
In other words, to do TDD is to have a different mindset. A failed test is a good test. There is no failure, only lessons to learn from.
Now is the time to code. But the good thing is you don’t have to immediately flesh out a fully functioning and optimized application. Write just enough code to make it pass. After all, the goal of TDD is to use test results to guide development, not the other way around.
In a way, this approach requires developers to shift their focus away from the bigger picture and how the application works as a whole to the little details satisfying the requirements of that particular test case they are working on. This promotes code minimalism.
When we think of code minimalism, we think of:
And that is exactly what TDD is capable of. It turns writing code into this kind of goal-based activity with short feedback loops while also guiding the coder to write code that is concise and straight to the point.
TDD also automatically promotes loose coupling. When you write code for one test at a time, you don’t have to constantly worry as much about the impact a certain module has on another. They are designed to operate independently. As long as they work by themselves, it’s fine.
Now that the code is ready, you can run the test until it passes. A passed test now indicates that the code meets the specified requirements as outlined in the test case.
Once done, you have reached the Green stage of the Red - Green - Refactor cycle.
Read More: Test Case Management Full Guide
We have arrived at the Refactor stage. To refactor is to restructure it to improve the code (of course without changing any external behavior). You have found the solution to the problem, but is it the best solution yet? Here you understand the underlying mechanism of your code so ideas for optimization should come more easily.
The cycle repeats. Write another test. Run it and observe it fails. Write code for that test until it passes. Over time, you get more and more feedback from tests, continuously refactor the code, and improve its design, readability, and maintainability.
Here’s a fun TDD example.
We want to implement a function to calculate the factorial of a number. The factorial of a non-negative integer n, denoted as n!, is the product of all positive integers less than or equal to n.
Here’s the test:
import unittest
from factorial import calculate_factorial
class TestFactorialCalculator(unittest.TestCase):
"""Test the factorial calculator function"""
def test_factorial_of_0(self):
expected = 1
result = calculate_factorial(0)
self.assertEqual(expected, result)
def test_factorial_of_positive_number(self):
expected = 120
result = calculate_factorial(5)
self.assertEqual(expected, result)
def test_factorial_of_negative_number(self):
with self.assertRaises(ValueError):
calculate_factorial(-5)
def test_factorial_of_non_integer(self):
with self.assertRaises(ValueError):
calculate_factorial(5.5)
if __name__ == '__main__':
unittest.main()
As you can see, our test has four scenarios:
You can run the test. Without any code written, it sure will fail. This is when we start to write code:
# factorial.py
def calculate_factorial(n):
if n < 0:
raise ValueError("Factorial is not defined for negative numbers")
elif not isinstance(n, int):
raise ValueError("Factorial is only defined for integers")
elif n == 0:
return 1
else:
result = 1
for i in range(1, n + 1):
result *= i
return result
TDD is incredibly powerful once you get into the rhythm of it.
For starters, it helps you stay focused on what truly matters. It is basically just a three-stage process that tells you to write code for one particular feature at a time. Absolutely no tool or special technique is needed, just plain old coding and testing as you have always done. However, you will soon find that it can automatically improve the design of your application. Each component is written for itself. Such independence makes it so much easier to maintain code during updates.
Another cool benefit of TDD is that it makes you feel safe. You have tests confirming every step along your way. You make any changes you want, since you can always run the test and see if it passes. If it fails, you can always go back to a state before that, and start over.
Read More: TDD vs BDD: A Complete Guide
Aspect | Test-Driven Development (TDD) | Traditional Testing |
Definition | A development approach where tests are written before the actual code. Developers write tests, then implement code to pass those tests. | A testing approach where tests are written after the code is developed. |
Test Creation | Tests are created before the code implementation begins. Developers write small, focused tests to define the desired behavior of the code. | Tests are written after the code is implemented. The focus is on verifying that the code behaves as expected. |
Design Process | TDD is considered a design process. Writing tests first helps in clarifying requirements and design decisions before writing the actual code. | Testing is a separate phase from the design process. The focus is on verifying the correctness of the implemented code. |
Feedback Loop | Immediate feedback loop as tests are run frequently during development. Any deviation from expected behavior is caught early. | Feedback comes later in the development process, potentially after more code has been written. |
Code Quality | Tends to lead to cleaner and more modular code, as developers need to design code that is easily testable. Encourages better software design. | Code quality depends on the testing practices and may not be as naturally inclined toward modularity. |
Refactoring | Encourages continuous refactoring as new code is added to maintain clean, efficient, and well-designed code. | Refactoring may occur but might be a separate step and not as integral to the development process. |
Debugging | Easier to debug since tests are written for specific functionalities, making it clear which part of the codebase is causing issues. | Debugging may be more challenging as issues might be discovered later in the development process, and the root cause might not be as obvious. |
Regression Testing | Built-in regression testing as tests are run frequently. Any changes that break existing functionality are caught immediately. | Regression testing is a separate step and may require additional effort to ensure that new changes don't break existing functionality. |
Adaptability to Changes | More adaptable to changes in requirements, as the code is designed to meet the specified tests. Changing requirements can be accommodated more easily. | Adapting to changes may be more challenging, especially if the existing codebase is not well-structured or modular. |
Time and Effort | May require more time upfront due to the creation of tests before coding. However, it can lead to time savings in debugging and maintenance phases. | May appear faster initially, but debugging and fixing issues later in the process can be time-consuming. |
Here’s a table to compare TDD with AMDD (Agile Model-driven Development):
Aspect | Test-Driven Development (TDD) | Agile Model-Driven Development (AMDD) |
Focus | Tests drive code development. | Models guide development and understanding. |
Testing/Modeling Approach | Prioritizes writing tests for code. | Prioritizes creating visual models for communication. |
Feedback Loop | Immediate feedback through continuous testing. | Feedback via collaborative model refinement. |
Design Process | Considered a design process. | Emphasizes collaborative model creation. |
Documentation | Code and tests serve as documentation. | Models are used as visual documentation. |
Flexibility to Changes | Adaptable through test modifications. | Adaptable through collaborative model updates. |
Collaboration | Collaboration through test creation. | Collaboration through model refinement. |
Use Cases | Well-suited for code-centric projects. | Well-suited for visual documentation needs. |
Integration with Agile | Fits well within Agile principles. | An integral part of the broader Agile framework. |
Development Cycle | Short cycles with iterative code and test evolution. | Iterative cycles with continuous model updates. |
The nature of Agile aligns well with TDD practices.
The pillars of Agile are flexibility and collaboration, and TDD contributes to those pillars. TDD is iterative by nature, breaking down development into small cycles. Agile is the same. You can easily allocate TDD activities into Agile sprints.
Agile teams also tend to be resilient against change, and TDD provides an additional safety net. When requirements change, developers can make adjustments confidently, knowing that they have a list of TDD tests ready to check the quality.
Some of the popular TDD frameworks include:
What to do:
What to avoid: