Mastering Test-Driven Development in Python: Unleashing the Power of Unittest

Table of contents
- What is Test-Driven Development (TDD)?
- The Power of unittest in Python
- Practical Walkthrough
- Step 0: Setting up the Project
- Step 1: Writing the Test Cases
- Step 2: Running the Tests and making sure they all fail
- Step 3: Implement the minimum code to make the test pass
- Step 4: Running the Tests again
- Step 5: Test-Driven Development Cycle
- Step 6: Refactoring and Iteration
- Advantages of Test-Driven Development (TDD)
- Disadvantages of Test-Driven Development (TDD)
- Conclusion

There are two distinct types of developers in the world of software engineering. The first type believes in writing code and simply walking away, assuming that everything will proceed according to plan. However, there exists another breed of developers who follow a more rigorous and disciplined approach: those who write unit tests.
In this article, we will delve into the world of Test-Driven Development with unittest
in Python. We will explore the benefits of adopting TDD and how writing unit tests can help you build high-quality software. Whether you're a seasoned developer looking to improve your coding practices or a newcomer eager to learn the ropes, this article will serve as a comprehensive guide to leveraging the power of unit tests in your Python projects.
What is Test-Driven Development (TDD)?
Test-Driven Development is an iterative development process that emphasizes writing tests before writing the code. This approach challenges the traditional development cycle, where tests are often an afterthought. By starting with tests, TDD encourages developers to think deeply about the requirements and design of their code before implementing it. The process typically involves four steps:
Step 1: Write a test
Step 2: Run the test and make sure they all fail
Step 3: Implement the minimum code to make the test pass, and finally
Step 4: Refactor the code to improve its structure without changing its functionality
The Power of unittest
in Python
Python's unittest
module is a built-in framework that provides a rich set of tools for designing and executing tests. With unittest
, you can define test cases, organize them into test suites, and easily execute them to validate your code's behavior. Let's explore some of the key features and functionalities that make unittest
a powerful tool for implementing TDD in Python.
Practical Walkthrough
To illustrate the concepts of Test-Driven Development (TDD) and the usage of unittest
, let's consider a simple example of a Calculator
class that performs basic arithmetic operations.
Step 0: Setting up the Project
Create a new Python project directory and navigate to it in your terminal. I'll assume you have Python and the unittest
module installed.
mkdir python-unittest
cd python-unittest
Inside your project directory, create a new file called calculator.py
. Open the file and define the Calculator
class blueprint as follows:
class Calculator:
pass
Step 1: Writing the Test Cases
Inside the project directory, create a new file called test_calculator.py
. This file will contain our test cases. Open the file and import the necessary modules:
# unittest library
import unittest
# Calculator class
from calculator import Calculator
Before implementing the code, it is important to carefully consider the requirements and design. Here are some of the requirements for the calculator class:
Input validation: The calculator should only accept
float
orinteger
values as input. Any other data types should be handled appropriately.Division by zero handling: The calculator should handle division by zero and prevent such operations from causing errors.
Arithmetic operations: The calculator should be able to perform arithmetic operations accurately for different use cases.
Next, let's define a test case class called TestCalculator
that inherits from unittest.TestCase
. Inside this class, we'll write test methods to verify the behavior of the Calculator
class.
class TestCalculator(unittest.TestCase):
def setUp(self):
self.calculator = Calculator()
A test case is created by subclassing unittest.TestCase
. The tests are then defined with methods whose names start with the letters test
. This naming convention informs the test runner about which methods represent tests.
The setUp()
method allow you to define instructions that will be executed before each test method. In the context of this scenario, an instance of the Calculator
class has to be created before any test can be carried out on its functionalities. By utilizing the setUp()
method, we can ensure the Calculator
object is properly initiated and available for use in each test case.
Input validation
class TestCalculator(unittest.TestCase):
# ... setUp method ...
def test_data_types(self):
"""
Testing data types
"""
self.assertRaises(TypeError, self.calculator.add, "3", 5)
self.assertRaises(TypeError, self.calculator.add, True, 5)
self.assertRaises(TypeError, self.calculator.subtract, True, "45")
self.assertRaises(TypeError, self.calculator.subtract, "4","12")
self.assertRaises(TypeError, self.calculator.multiply, "3", 5)
self.assertRaises(TypeError, self.calculator.multiply, True, 5)
To verify that a TypeError
exception is raised when the Calculator receives a data type other than float
or integer
values as input, the assertRaises()
method is used.
Division by zero handling
class TestCalculator(unittest.TestCase):
# ... existing methods ...
def test_zero_div(self):
"""
Testing zero division instance
"""
self.assertRaises(ZeroDivisionError, self.calculator.divide, 7, 0)
To verify that a ZeroDivisionError
exception is raised when the Calculator receives a division by zero, the assertRaises()
method is used.
Arithmetic operations
class TestCalculator(unittest.TestCase):
# ... existing methods ...
def test_add(self):
"""
Testing the add functionality
"""
self.assertEqual(self.calculator.add(2.8, 3), 5.8)
self.assertEqual(self.calculator.add(2, -3), -1)
self.assertEqual(self.calculator.add(-2, -3), -5)
def test_subtract(self):
"""
Testing the subtract functionality
"""
self.assertEqual(self.calculator.subtract(2.5, 3), -0.5)
self.assertEqual(self.calculator.subtract(2, -3), 5)
self.assertEqual(self.calculator.subtract(-2, -3), 1)
def test_multiply(self):
"""
Testing the subtract functionality
"""
self.assertEqual(self.calculator.multiply(2, 3), 6)
self.assertEqual(self.calculator.multiply(1, -3), -3)
self.assertEqual(self.calculator.multiply(-2, -13), 26)
def test_divide(self):
"""
Testing the division functionality
"""
self.assertEqual(self.calculator.divide(2, -2), -1)
self.assertEqual(self.calculator.divide(8, 0.5), 16)
self.assertEqual(self.calculator.divide(-3, -2), 1.5)
In general, the core of each test typically involves using assertion methods such as assertEqual()
, assertTrue()
, assertFalse()
, or assertRaises()
to validate specific conditions or behaviors.
Step 2: Running the Tests and making sure they all fail
To execute the tests, open your terminal, navigate to the project directory, and run the following command:
python -m unittest test_calculator.py
You should see an output indicating the test results. If all tests fail, you will see something like this:
EEEEEE
======================================================================
...
----------------------------------------------------------------------
Ran 6 tests in 0.006s
FAILED (errors=6)
The E's represent each test cases, and the FAILED (errors=6)
signifies that all 6 tests failed.
Step 3: Implement the minimum code to make the test pass
Open the calculator.py
file and define the Calculator
class as follows:
class Calculator:
def add(self, x, y):
"""Input validation"""
if (type(x) not in [int, float]) or (type(y) not in [int, float]):
raise TypeError("Input value has to be a float or an integer")
return x + y
def subtract(self, x, y):
"""Input validation"""
if (type(x) not in [int, float]) or (type(y) not in [int, float]):
raise TypeError("Input value has to be a float or an integer")
return x - y
def multiply(self, x, y):
"""Input validation"""
if (type(x) not in [int, float]) or (type(y) not in [int, float]):
raise TypeError("Input value has to be a float or an integer")
return x * y
def divide(self, x, y):
"""Input validation and zero division"""
if (type(x) not in [int, float] )or (type(y) not in [int, float]):
raise TypeError("Input value has to be a float or an integer")
if y == 0:
raise ZeroDivisionError("Cannot divide by zero.")
return x / y
Step 4: Running the Tests again
To execute the tests using python test_calculator.py
instead of python -m unittest test_calculator.py
, open the test_calculator.py
file and append the following code at the end of the file, outside the TestCalculator
class:
if __name__ == "__main__":
unittest.main()
The unittest.main()
provides a command-line interface to the test script.
Open your terminal, navigate to the project directory, and run the following command:
python test_calculator.py
You should see an output indicating the test results. If all tests pass, you will see something like:
......
----------------------------------------------------------------------
Ran 6 tests in 0.003s
OK
The dots represent each test case, and the "OK" signifies that all tests passed successfully.
Step 5: Test-Driven Development Cycle
Now that we have our initial tests passing, let's practice the TDD cycle by adding a new feature to the Calculator
class: finding the square root of a number.
First, we'll add a new test method to our TestCalculator
class:
def test_square_root(self):
"""Testing the division functionality"""
self.assertEqual(self.calculator.square_root(16), 4)
self.assertEqual(self.calculator.square_root(25), 5)
Run the tests again, and you should see that the new test fails, indicating that the square_root
method is not yet implemented.
....E..
======================================================================
...
----------------------------------------------------------------------
Ran 7 tests in 0.003s
FAILED (errors=1)
Next, open the caculator.py
file, import the math library and update the Calculator
class with the square_root
method:
import math
class Calculator:
# ... existing methods ...
def square_root(self, x):
"""Input validation"""
if type(x) not in [int, float]:
raise TypeError("Input value has to be a float or an integer")
if x < 0:
raise ValueError("Cannot calculate square root of a negative number.")
return math.sqrt(x)
Run the tests once more, and you should see that the new test passes, indicating that the square_root
method is implemented correctly.
.......
----------------------------------------------------------------------
Ran 7 tests in 0.002s
OK
Step 6: Refactoring and Iteration
With the new feature implemented and the tests passing, you can now refactor your code as needed to improve its structure and readability. Refactoring should not alter the behavior of the code, as the tests act as a safety net to catch any unintentional changes.
Continue the TDD cycle by adding more test cases, implementing the corresponding functionality, and ensuring all tests pass. This iterative process allows you to gradually build a comprehensive test suite that ensures the reliability and correctness of your code.
Advantages of Test-Driven Development (TDD)
Better code quality: TDD encourages developers to write tests that specify the desired behavior of the code.
Faster debugging and refactoring: With a comprehensive test suite in place, developers can confidently refactor their code, knowing that if something breaks, the tests will catch it.
Better collaboration and documentation: Tests serve as living documentation for the codebase.
Disadvantages of Test-Driven Development (TDD)
Complete test coverage may be challenging: Achieving complete test coverage, especially in complex systems, can be challenging. It is possible to have blind spots where certain scenarios or edge cases are not adequately covered by tests, leading to potential bugs going undetected.
Maintenance overhead: With TDD, the test suite becomes an integral part of the codebase. As the code evolves, tests need to be updated or added to reflect the changes. This can introduce additional maintenance overhead, especially if the codebase undergoes frequent changes.
Conclusion
In this article, we explored how to use unittest
in Python to implement Test-Driven Development. By writing tests before writing the actual code, we can verify the behavior and correctness of our software. unittest
provides a powerful framework for defining test cases, organizing them into test suites, and running them efficiently. By embracing TDD and leveraging unittest
, you can enhance the quality, maintainability, and reliability of your Python projects.
Subscribe to my newsletter
Read articles from Sodiq Babawale directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
