Python Test Driven Development
Introduction
Test Driven Development (TDD) is a software development approach where tests are written before the actual code. This might seem counterintuitive at first—how can you test something that doesn't exist? But this methodology has proven to be incredibly effective for creating reliable, maintainable, and bug-free software.
In this tutorial, we'll explore the basics of Test Driven Development in Python, understand why it's beneficial, and walk through practical examples to get you started with TDD.
What is Test Driven Development?
Test Driven Development follows a simple cycle often referred to as "Red-Green-Refactor":
- Red: Write a failing test for the functionality you want to implement
- Green: Write the minimal amount of code needed to make the test pass
- Refactor: Improve the code while ensuring the tests still pass
This cycle is repeated for each new feature or functionality, ensuring that your code is always tested and working as expected.
Why Use TDD?
Before diving into the how, let's understand why TDD is valuable:
- Better design: Writing tests first forces you to think about how your code will be used
- Fewer bugs: Tests catch issues early in development
- Confidence in changes: Existing tests ensure that new changes don't break working functionality
- Documentation: Tests serve as living documentation of how your code should behave
- Focus: TDD helps you focus on one specific requirement at a time
Getting Started with TDD in Python
Required Tools
For TDD in Python, we'll use the built-in unittest
module. For larger projects, you might consider other testing frameworks like pytest
, but unittest
is perfect for understanding the basics.
Let's start by setting up our environment:
# Create a project directory
mkdir tdd_tutorial
cd tdd_tutorial
# Create files we'll need
touch calculator.py
touch test_calculator.py
Your First TDD Cycle
Let's implement a simple calculator function using TDD. We'll start with addition.
Step 1: Write a Failing Test (Red)
In test_calculator.py
, write a test for an addition function that doesn't exist yet:
import unittest
from calculator import Calculator
class TestCalculator(unittest.TestCase):
def test_addition(self):
calc = Calculator()
result = calc.add(4, 7)
self.assertEqual(11, result)
if __name__ == '__main__':
unittest.main()
Run the test. It should fail since we haven't implemented the Calculator
class yet:
python test_calculator.py
Output:
ImportError: cannot import name 'Calculator' from 'calculator'
Step 2: Write Minimal Code to Pass the Test (Green)
Now, let's create the minimal implementation in calculator.py
:
class Calculator:
def add(self, a, b):
return a + b
Run the test again:
python test_calculator.py
Output:
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Great! Our test is now passing.
Step 3: Refactor (if necessary)
Our implementation is already simple, but if there were redundancies or improvements, this would be the time to refactor while ensuring the tests still pass.
Expanding Functionality with TDD
Let's add a subtraction feature to our calculator using the TDD approach.
Step 1: Write a Failing Test
Add a new test method to test_calculator.py
:
# Add this test method to the TestCalculator class
def test_subtraction(self):
calc = Calculator()
result = calc.subtract(10, 5)
self.assertEqual(5, result)
Run the tests:
python test_calculator.py
Output:
E.
======================================================================
ERROR: test_subtraction (__main__.TestCalculator)
----------------------------------------------------------------------
AttributeError: 'Calculator' object has no attribute 'subtract'
Step 2: Implement the Code
Update calculator.py
:
class Calculator:
def add(self, a, b):
return a + b
def subtract(self, a, b):
return a - b
Run the tests again:
python test_calculator.py
Output:
..
----------------------------------------------------------------------
Ran 2 tests in 0.001s
OK
A More Complex TDD Example
Let's implement a more realistic example: a function that validates email addresses.
Step 1: Write a Failing Test
Create two new files:
touch email_validator.py
touch test_email_validator.py
In test_email_validator.py
, write:
import unittest
from email_validator import is_valid_email
class TestEmailValidator(unittest.TestCase):
def test_valid_email(self):
self.assertTrue(is_valid_email("[email protected]"))
def test_missing_at_symbol(self):
self.assertFalse(is_valid_email("userexample.com"))
def test_missing_domain(self):
self.assertFalse(is_valid_email("user@"))
def test_missing_username(self):
self.assertFalse(is_valid_email("@example.com"))
if __name__ == '__main__':
unittest.main()
Run the test to see it fail:
python test_email_validator.py
Step 2: Implement the Function
In email_validator.py
, write:
def is_valid_email(email):
if not isinstance(email, str):
return False
if '@' not in email:
return False
username, domain = email.split('@', 1)
if not username or not domain:
return False
return True
Run the tests again to see them pass:
python test_email_validator.py
Output:
....
----------------------------------------------------------------------
Ran 4 tests in 0.001s
OK
Step 3: Refactor and Add More Tests
Let's add a more complex validation rule and refactor our code:
# Add this to the TestEmailValidator class
def test_invalid_domain_without_period(self):
self.assertFalse(is_valid_email("user@domaincom"))
Update email_validator.py
:
def is_valid_email(email):
if not isinstance(email, str):
return False
if '@' not in email:
return False
username, domain = email.split('@', 1)
if not username or not domain:
return False
# Check for at least one period in the domain
if '.' not in domain:
return False
return True
Real-World Application: Building a To-Do List with TDD
Now let's apply TDD to a more realistic application: a simple To-Do list manager.
Step 1: Define the Requirements
Our To-Do list should:
- Add new tasks
- Mark tasks as complete
- Remove tasks
- List all tasks
- List only incomplete tasks
Step 2: Create the Test File
touch todo_list.py
touch test_todo_list.py
In test_todo_list.py
:
import unittest
from todo_list import TodoList
class TestTodoList(unittest.TestCase):
def setUp(self):
"""Set up a new TodoList before each test"""
self.todo = TodoList()
def test_add_task(self):
"""Test adding a task to the list"""
self.todo.add_task("Buy groceries")
tasks = self.todo.list_tasks()
self.assertIn("Buy groceries", [task['description'] for task in tasks])
def test_mark_task_complete(self):
"""Test marking a task as complete"""
self.todo.add_task("Buy groceries")
# Get the id of the task we just added
task_id = self.todo.list_tasks()[0]['id']
self.todo.mark_complete(task_id)
task = [t for t in self.todo.list_tasks() if t['id'] == task_id][0]
self.assertTrue(task['completed'])
def test_remove_task(self):
"""Test removing a task from the list"""
self.todo.add_task("Buy groceries")
task_id = self.todo.list_tasks()[0]['id']
self.todo.remove_task(task_id)
self.assertEqual(len(self.todo.list_tasks()), 0)
def test_list_incomplete_tasks(self):
"""Test listing only incomplete tasks"""
self.todo.add_task("Buy groceries")
self.todo.add_task("Clean house")
task_id = self.todo.list_tasks()[0]['id']
self.todo.mark_complete(task_id)
incomplete_tasks = self.todo.list_incomplete_tasks()
self.assertEqual(len(incomplete_tasks), 1)
self.assertEqual(incomplete_tasks[0]['description'], "Clean house")
if __name__ == '__main__':
unittest.main()
Step 3: Implement the TodoList Class
Now let's implement the TodoList
class in todo_list.py
:
class TodoList:
def __init__(self):
self.tasks = []
self.next_id = 1
def add_task(self, description):
task = {
'id': self.next_id,
'description': description,
'completed': False
}
self.tasks.append(task)
self.next_id += 1
return task['id']
def mark_complete(self, task_id):
for task in self.tasks:
if task['id'] == task_id:
task['completed'] = True
return True
return False
def remove_task(self, task_id):
for i, task in enumerate(self.tasks):
if task['id'] == task_id:
del self.tasks[i]
return True
return False
def list_tasks(self):
return self.tasks
def list_incomplete_tasks(self):
return [task for task in self.tasks if not task['completed']]
Step 4: Run the Tests
python test_todo_list.py
All tests should pass, showing that our implementation meets the requirements we established.
TDD Best Practices
As you continue with TDD, keep these best practices in mind:
- Write simple tests: Each test should focus on one specific behavior
- Keep the red phase short: Write minimal failing tests
- Write minimal code to pass tests: Don't implement more than needed
- Refactor regularly: Clean code is easier to maintain and extend
- Test edge cases: Consider null values, empty inputs, etc.
- Run tests frequently: Ideally after each small change
Advanced TDD Techniques
Once you're comfortable with the basics, you can explore more advanced TDD practices:
Test Fixtures
Test fixtures help set up consistent test environments. We used a simple version with setUp()
in our To-Do list example.
Mock Objects
For testing code with external dependencies, you might need mock objects:
from unittest.mock import Mock, patch
def test_with_mock():
# Create a mock object
mock_service = Mock()
mock_service.get_data.return_value = {'key': 'value'}
# Use the mock object
result = function_that_uses_service(mock_service)
# Assert that the mock was called correctly
mock_service.get_data.assert_called_once()
Parameterized Tests
For testing multiple input-output combinations:
import unittest
class TestMultiplication(unittest.TestCase):
def test_multiplication(self):
test_cases = [
(2, 3, 6),
(0, 5, 0),
(-1, -1, 1),
(10, 10, 100)
]
for a, b, expected in test_cases:
with self.subTest(a=a, b=b):
self.assertEqual(a * b, expected)
Summary
Test Driven Development is a powerful methodology that helps you create more reliable code by writing tests first. The Red-Green-Refactor cycle ensures that your code is thoroughly tested and meets the requirements.
In this tutorial, you've learned:
- The basic principles of TDD
- How to implement the TDD cycle in Python
- Practical examples of TDD for different applications
- Best practices for effective test-driven development
By integrating TDD into your development workflow, you'll create more robust, maintainable code with fewer bugs.
Additional Resources
To continue your TDD journey:
- Python's unittest documentation
- pytest framework for more advanced testing
- Test-Driven Development with Python - Online book by Harry Percival
- Clean Code by Robert C. Martin
Exercises
-
Create a string utility class using TDD with functions for:
- Reversing a string
- Converting a string to title case
- Counting occurrences of a character
-
Build a simple bank account class using TDD that can:
- Deposit money
- Withdraw money
- Check balance
- Apply interest
- Prevent withdrawals that would result in negative balances
-
Refactor the email validator using TDD to include more validation rules:
- Minimum length requirements
- Character validations (no special characters in usernames)
- Top-level domain validation (.com, .org, etc.)
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)