Test Driven Development
What is Test Driven Development (TDD)
Test Driven Development (TDD) is a software development practice where tests are written before the implementation code.
Instead of writing a piece of code and then writing tests to verify it, TDD reverses the order:
- Write a test that describes the desired behavior.
- Write just enough code to make the test pass.
- Refactor the code while keeping the tests passing.
This cycle is commonly known as Red-Green-Refactor:
- Red - First, write a test for the behavior we want. The test should fail because the functionality has not been implemented yet.
- Green - Next, write the minimum amount of implementation necessary to make the test pass.
- Refactor - Once the test passes, improve the implementation without changing its externally observable behavior. Because the tests are already passing, they provide a safety net while the code is being improved.
Compare with tradidional development process:
# traditional development
Requirements
↓
Implementation
↓
Testing
↓
Bug Fixing
# test driven development
Requirements
↓
Write a Test
↓
Implementation
↓
Refactor
↺TDD is not simply a testing technique. It is a development approachi in which tests are used to guide the design and implementation of the software.
Why Use TDD?
The primary benefit of TDD is that it encourages developers to think about behavior before implementation.
- Traditional development - How should I implement this?
- Test driven development - What should this code do?
This seemingly small change can have a significant effect on how software is designed. A good test describes expected behavior in an executable form.
TDD Encourages Small, Focused Changes
TDD naturally encourages developers to work in small increments. Rather than implementing a large feature and testing everything afterward, we can break the feature into smaller behaviors:
Behavior 1 → Test → Implement
Behavior 2 → Test → Implement
Behavior 3 → Test → ImplementEach cycle produces a small, verifiable change. This can make the development process easier to understand and debug.
TDD Provides Fast Feedback
Because tests are run frequently, problems can be detected close to where they were introduced. After adding a new behavior, we might run test code immediately
- If all tests pass, we can continue.
- If a test fails, we have a relatively small amount of recently changed code to investigate.
The shorter the feedback loop, the easier it is to identify and fix problems.
TDD Supports Refactoring
Refactoring changes the internal structure of code without changing its externally observable behavior. Without automated tests, refactoring can be risky because it may accidentally introduce a defect.
With a good test suite, we can make structural improvements and then run the tests to verify that the existing behavior remains intact. This makes TDD particularly useful when working on code that will evolve over time.
How to Do TDD?
The basic TDD workflow is simple:
- Choose one small behavior.
- Write a test for that behavior.
- Run the test and verify that it fails.
- Implement the minimum code necessary to make it pass.
- Run the complete test suite.
- Refactor if necessary.
- Repeat the cycle.
The key is to keep each cycle small.
TDD Example with Python
Let’s look at a simple example using Python and pytest. Suppose we want to implement a function called full_name() that combines a person’s first name and last name. At first, the requirement sounds simple:
Given a first name and a last name, return the person’s full name.
Instead of immediately implementing the function, let’s use TDD to develop it step by step.
Step 1: Write the First Test
We start with the most straightforward case: both a first name and a last name are provided.
def test_full_name():
assert full_name("John", "Smith") == "John Smith"At this point, full_name() does not exist, so the test fails. This is the Red stage.
Step 2: Make the Test Pass
We write the simplest implementation that satisfies the test:
def full_name(first_name, last_name):
return f"{first_name} {last_name}"Run the test with pytest. The test passes. We are now at the Green stage. At this point, we could stop and say that the function works. But we haven’t considered what should happen when one of the names is missing.
Step 3: Add an Edge Case
What should happen if no first name is provided? For example:
full_name("", "Smith")It would be reasonable for this to return Smith. So we add another test:
def test_full_name_without_first_name():
assert full_name("", "Smith") == "Smith"Run the tests again. The new test fails because our current implementation produces:
SmithThere is an unwanted leading space. Now we have a failing test that tells us exactly what behavior needs to be addressed.
Step 4: Make Both Tests Pass
We can modify the implementation:
def full_name(first_name, last_name):
if not first_name:
return last_name
return f"{first_name} {last_name}"Run the tests again. Both tests should now pass.
Step 5: Consider the Other Case
We should also consider the opposite situation: a last name is not provided. Let’s add another test:
def test_full_name_without_last_name():
assert full_name("John", "") == "John"Our implementation does not handle this case correctly. So we need to adjust the implementation.
Step 6: Refactor the Implementation
We can simplify the function by handling both optional values together:
def full_name(first_name, last_name):
return " ".join(
name for name in (first_name, last_name) if name
)Now the function handles all three cases:
full_name("John", "Smith")
# "John Smith"
full_name("John", "")
# "John"
full_name("", "Smith")
# "Smith"Our tests can now look like this:
def test_full_name():
assert full_name("John", "Smith") == "John Smith"
def test_full_name_without_first_name():
assert full_name("", "Smith") == "Smith"
def test_full_name_without_last_name():
assert full_name("John", "") == "John"All three tests should pass.
Step 7: Consider Both Names Missing
There is still another case we haven’t considered:
full_name("", "")What should the function do when neither a first name nor a last name is provided? For this example, let’s define this as invalid input. Instead of returning an empty string, the function should raise a ValueError. We can express that requirement with another test:
import pytest
def test_full_name_without_names():
with pytest.raises(ValueError):
full_name("", "")The implementation now needs to handle this case explicitly:
def full_name(first_name, last_name):
if not first_name and not last_name:
raise ValueError("At least one name must be provided")
return " ".join(
name for name in (first_name, last_name) if name
)Now the function has clearly defined behavior for all four cases:
full_name("John", "Smith")
# "John Smith"
full_name("John", "")
# "John"
full_name("", "Smith")
# "Smith"
full_name("", "")
# raises ValueErrorOur complete test suite is:
import pytest
def test_full_name():
assert full_name("John", "Smith") == "John Smith"
def test_full_name_without_first_name():
assert full_name("", "Smith") == "Smith"
def test_full_name_without_last_name():
assert full_name("John", "") == "John"
def test_full_name_without_names():
with pytest.raises(ValueError):
full_name("", "")Recap
Notice that we didn’t start by designing the final implementation. Instead, we gradually discovered the required behavior through tests:
Requirement
↓
Test: first + last name
↓
RED
↓
Implement
↓
GREEN
↓
Test: no first name
↓
RED
↓
Improve implementation
↓
GREEN
↓
Test: no last name
↓
RED
↓
Refactor
↓
GREEN
↓
Test: neither name
↓
RED
↓
Handle invalid input
↓
GREENThis is the essence of TDD. The tests don’t merely verify the final implementation. They drive the development of the implementation. When we encounter an edge case, we don’t have to guess whether the existing code handles it correctly. We express the desired behavior as a test and let the test tell us whether the implementation satisfies that behavior.