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

Sodiq BabawaleSodiq Babawale
8 min read

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 or integer 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.

4
Subscribe to my newsletter

Read articles from Sodiq Babawale directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sodiq Babawale
Sodiq Babawale