Skip to main content

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":

  1. Red: Write a failing test for the functionality you want to implement
  2. Green: Write the minimal amount of code needed to make the test pass
  3. 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:

bash
# 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:

python
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:

bash
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:

python
class Calculator:
def add(self, a, b):
return a + b

Run the test again:

bash
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:

python
# 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:

bash
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:

python
class Calculator:
def add(self, a, b):
return a + b

def subtract(self, a, b):
return a - b

Run the tests again:

bash
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:

bash
touch email_validator.py
touch test_email_validator.py

In test_email_validator.py, write:

python
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:

bash
python test_email_validator.py

Step 2: Implement the Function

In email_validator.py, write:

python
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:

bash
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:

python
# Add this to the TestEmailValidator class
def test_invalid_domain_without_period(self):
self.assertFalse(is_valid_email("user@domaincom"))

Update email_validator.py:

python
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

bash
touch todo_list.py
touch test_todo_list.py

In test_todo_list.py:

python
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:

python
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

bash
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:

  1. Write simple tests: Each test should focus on one specific behavior
  2. Keep the red phase short: Write minimal failing tests
  3. Write minimal code to pass tests: Don't implement more than needed
  4. Refactor regularly: Clean code is easier to maintain and extend
  5. Test edge cases: Consider null values, empty inputs, etc.
  6. 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:

python
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:

python
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:

Exercises

  1. Create a string utility class using TDD with functions for:

    • Reversing a string
    • Converting a string to title case
    • Counting occurrences of a character
  2. 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
  3. 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! :)