Ultimate Guide to Understanding Python Unit Testing with Unittest Framework

Ultimate Guide to Understanding Python Unit Testing with Unittest Framework

Introduction to Unit Testing in Python

When creating a program, it is important to ensure it works as it should. In large and complex programs, it may sometimes be difficult to find bugs in code after programming. This is where unit testing comes in.

Unit testing is a software development process where individual sections of the software are tested to ensure they work accurately. It does this by isolating the code into several sections and testing it for accurate performance.

One significant advantage of unit testing is that it helps developers spot bugs and errors that may be more difficult to find in the later stage of development.

In Python development, two major frameworks- unittest and pytest are used to carry out unit testing.

In this article, we will cover all you need to know about testing codes using unittest in Python.

Understanding the Python Unittest Framework

Unittest is a unit testing framework that makes the testing of Python programs possible. It provides a variety of tools that aids in constructing and running tests. Some important concepts in the unittest include text fixture, test case, test suite, and test runner.

Test fixtures deal with the preparation and cleanup actions necessary to run unit tests. Common test fixtures include setup() which is called before every test method and is used for setting up any necessary resources or initial conditions and teardown() which is called after every test method and is used for cleaning up any resources.

The Testcase is used to check individual units of a program. Test cases in unittest are responsible for defining the test scenarios, including assertions to check the expected behavior of the code being tested. They can also utilize test fixtures to set up and tear down the required resources. Common assert methods used are expressed in the image below

A Testsuite is a collection of test cases, other test suites, or even a mixture of both that are grouped together for execution. It allows you to run multiple test cases at the same time and organize them based on their logical grouping. In unittest, a test suite is typically created by subclassing unittest.TestSuite or using the unittest.TestLoader class.

The test runner is responsible for discovering and executing the tests defined in your test suite.

Prerequisites

To follow up on this tutorial, you will need

  1. An IDE

  2. Basic understanding of Python classes and functions

  3. Import the unittest framework. Unittest framework is available on a majority of IDEs including VScode and Pycharm so all you need to do is import it into your work file and start writing your code.

Testing Programs Using Test Case

Let’s test a code that performs simple calculations. To do this, we need to open a new file called main.py and write the code below.

#main.py

class Calculator:
    def init(self, num1, num2):
        self.num1 = num1
        self.num2 = num2

    def add(self):
        return self.num1 + self.num2

    def subtract(self):
        return self.num1 - self.num2

    def multiply(self):
        return self.num1 * self.num2

A class called Calculator is created to handle mathematical calculations. The init method which serves as the constructor for the Calculator class is created also and it takes num1 and num2 as parameters, representing the numbers on which calculations will be performed. It further initializes the instance variables self.num1 and self.num2 with the values passed as arguments. The class provides three methods: add() for addition, subtract() for subtraction, and multiply() for multiplication. Each method takes no additional parameters and performs the respective operation using the instance variables self.num1 and self.num2.

Moving forward, we write the test code using the unittest.Testcase module to check if the code above is correct.

To do this, in the same folder as your main.py, create a new file called maintest.py and input the code below.

#maintest.py

import unittest
from main import Calculator


class TestCalculator(unittest.TestCase):
   def test_add(self):
       calculator = Calculator(4, 2)
       self.assertEqual(calculator.add(), 6, 'The addition is incorrect.')

   def test_subtract(self):
       calculator = Calculator(4, 2)
       self.assertEqual(calculator.subtract(),  'The subtraction is incorrect')

   def test_multiply(self):
       calculator = Calculator(4, 2)
       self.assertEqual(calculator.multiply(), 8, 'The multiplication is incorrect')


if __name__ == '__main__':
   unittest.main()

This code tests the various functions defined in our main.py. To carry out the test do the following:

  1. Import the unittest framework

  2. Link this current file to the main.py file by importing main and Calculator class

  3. Create the TestCalculations class and give it the unittest.Testcase inheritance. Inside this class, you test each function.

It is important to note that each test method starts with the prefix test and contains assertions to verify the expected behavior of the Calculator methods. For example, the test_add() method tests the add() method of the Calculator class and asserts that the result is equal to 6 (the expected sum of 4 and 2). It also goes further to attach a failure message which will be displayed if an incorrect answer pops up.

  1. Finally, run the test by calling the unittest.main().

This script outputs the result below

Complex Problems
Now, let us test more complex scripts. In this example, we create a new file called newmain.py and we have a TodoList class that manages a list of tasks. The class has methods to add tasks, remove tasks, and get the count of tasks in the list. The code below explains this concept better.

#newmain.py

class TodoList:
   def __init__(self):
       self.tasks = []

   def add_task(self, task):
       self.tasks.append(task)

   def remove_task(self, task):
       self.tasks.remove(task)

   def get_task_count(self):
       return len(self.tasks)

To test the code, create another file in the same folder as newmain.py, and give it the name newtest.py. Using the code below, test the code:

#newtest.py

import unittest
from newmain import TodoList


class TestTodoList(unittest.TestCase):

   def test_add_task(self):
       todo_list = TodoList()
       task = "Buy groceries"
       todo_list.add_task(task)
       self.assertEqual(todo_list.get_task_count(), 1)

   def test_remove_task(self):
       todo_list = TodoList()
       task = "Buy groceries"
       todo_list.add_task(task)
       todo_list.remove_task(task)
       self.assertEqual(todo_list.get_task_count(), 0)

   def test_get_task_count(self):
       todo_list = TodoList()
       self.assertEqual(todo_list.get_task_count(), 0)
       task1 = "Buy groceries"
       task2 = "Walk the dog"
       todo_list.add_task(task1)
       todo_list.add_task(task2)
       self.assertEqual(todo_list.get_task_count(), 2)


if __name__ == '__main__':
   unittest.main()

This will import the unittest framework, import the TodoList class from the newtest.py, and do the following:

  1. Create a TestTodoList class that inherits from the unittest.TestCase and represent a test case for the TodoList class.

  2. Use the test_add_task method to create an instance of TodoList, add a task ("Buy groceries"), and asserts that the task count is equal to 1.

  3. Use the test_remove_task method to create an instance of TodoList, add a task ("Buy groceries"), removes the same task, and assert that the task count is equal to 0.

  4. Use the test_get_task_count method creates an instance of TodoList, asserts that the initial task count is 0, adds two tasks ("Buy groceries" and "Walk the dog"), and asserts that the task count is equal to 2.

The unittest.main() function runs the tests and provides the test results as below:

If we do a little manipulation in the test_remove_task method part by changing the task count to 1 as indicated below, we get an error and the error message attached to the line of code is printed out.

def test_remove_task(self):
   todo_list = TodoList()
   task = "Buy groceries"
   todo_list.add_task(task)
   todo_list.remove_task(task)
   self.assertEqual(todo_list.get_task_count(), 1, 'You got it wrong')

The output printed out is given below:

Testing Programs Using setUp() and teardown()

In Python's unittest framework, setUp and tearDown are special methods that can be used to set up and tear down resources or perform common actions before and after each test method is executed.

Let us explore how to use the setup() method to test the Todolist sample code in our newmain.py.

To do this, open a new file and name it test.py.

In the file, write the code below.

#test.py

import unittest
from newmain import TodoList

class TestTodoList(unittest.TestCase):

   def setUp(self):
       self.todo_list = TodoList()
       self.task1 = "Buy groceries"
       self.task2 = "Walk the dog"

   def test_add_task(self):
       self.todo_list.add_task(self.task1)
       self.assertEqual(self.todo_list.get_task_count(), 1)

   def test_remove_task(self):
       self.todo_list.add_task(self.task1)
       self.todo_list.remove_task(self.task1)
       self.assertEqual(self.todo_list.get_task_count(), 0)

   def test_get_task_count(self):
       self.assertEqual(self.todo_list.get_task_count(), 0)
       self.todo_list.add_task(self.task1)
       self.todo_list.add_task(self.task2)
       self.assertEqual(self.todo_list.get_task_count(), 2)


if __name__ == '__main__':
   unittest.main()

In the setUp method executed above, before each test method we set up a new instance of the TodoList class using self.todo_list = TodoList(). We also define two task strings, self.task1 and self.task2, which will be used in the tests.

The test_add_task method tests the add_task method of the TodoList class. It adds self.task1 to the TodoList using self.todo_list.add_task(self.task1) and then asserts that the task count is 1 using self.assertEqual(self.todo_list.get_task_count(), 1).

The test_remove_task method tests the remove_task method of the TodoList class. It adds self.task1 to the TodoList, removes it using self.todo_list.remove_task(self.task1), and then asserts that the task count is 0 using self.assertEqual(self.todo_list.get_task_count(), 0).

The test_get_task_count method tests the get_task_count method of the TodoList class. It asserts that the initial task count is 0 by calling self.assertEqual(self.todo_list.get_task_count(), 0). Then, it adds the two tasks using self.todo_list.add_task(self.task1) and self.todo_list.add_task(self.task2). Finally, it asserts that the task count is 2 by calling self.assertEqual(self.todo_list.get_task_count(), 2). The unittest.main() function runs the tests and provides the test results as below:

Teardown Method

The teardown() method is illustrated below:

import unittest

from newmain import TodoList


class TestTodoList(unittest.TestCase):

   def setUp(self):
       self.todo_list = TodoList()
       self.task1 = "Buy groceries"
       self.task2 = "Walk the dog"

   def tearDown(self):
       self.todo_list = None
       self.task1 = None
       self.task2 = None

   def test_add_task(self):
       self.todo_list.add_task(self.task1)
       self.assertEqual(self.todo_list.get_task_count(), 1)

   def test_remove_task(self):
       self.todo_list.add_task(self.task1)
       self.todo_list.remove_task(self.task1)
       self.assertEqual(self.todo_list.get_task_count(), 0)

   def test_get_task_count(self):
       self.assertEqual(self.todo_list.get_task_count(), 0)
       self.todo_list.add_task(self.task1)
       self.todo_list.add_task(self.task2)
       self.assertEqual(self.todo_list.get_task_count(), 2)


if __name__ == '__main__':
   unittest.main()

Inside the tearDown method, we reset the self.todo_list, self.task1, and self.task2 variables to None. This ensures that any resources or states created during the test are properly cleaned up, providing a clean state for the next test.

By including the tearDown method, we guarantee that each test method is executed in isolation and does not leave any side effects that could affect the accuracy of other tests.

Understanding the Use of TestSuite and Testrunner

A TestSuite is a collection of test cases that can be executed together. Testsuite allows users to group related tests and run them as a cohesive unit. One amazing thing about it is that it gives users the room to create and customize test suites to suit their testing needs.

To further explain how Testsuite and Testrunner work, we will use the TodoList code as implemented in our newmain.py.

Open a new file in the same folder as our newmain.py and give it the name testnew.py.

Copy the code below into your file.

#testnew.py

import unittest

from newmain import TodoList


class TestTodoList(unittest.TestCase):

   def setUp(self):
       self.todo_list = TodoList()
       self.task1 = "Buy groceries"
       self.task2 = "Walk the dog"

   def test_add_task(self):
       self.todo_list.add_task(self.task1)
       self.assertEqual(self.todo_list.get_task_count(), 1)

   def test_remove_task(self):
       self.todo_list.add_task(self.task1)
       self.todo_list.remove_task(self.task1)
       self.assertEqual(self.todo_list.get_task_count(), 0)

   def test_get_task_count(self):
       self.assertEqual(self.todo_list.get_task_count(), 0)
       self.todo_list.add_task(self.task1)
       self.todo_list.add_task(self.task2)
       self.assertEqual(self.todo_list.get_task_count(), 2)


if __name__ == '__main__':
   # Create a TestSuite using TestLoader and load the tests from TestTodoList
   loader = unittest.TestLoader()
   suite = unittest.TestSuite()
   suite.addTests(loader.loadTestsFromTestCase(TestTodoList))

   # Run the TestSuite
   runner = unittest.TextTestRunner()
   runner.run(suite)

The TestLoader class is used to create the TestSuite and load the tests from the TestTodoList test case.

We first create a TestLoader object named loader using unittest.TestLoader(). Then, we create a TestSuite object named suite and add the tests from the TestTodoList test case using suite.addTests(loader.loadTestsFromTestCase(TestTodoList)).

The loadTestsFromTestCase() method from TestLoader is used to discover and load all the test methods within the TestTodoList test case.

Finally, the TestSuite is executed using the TextTestRunner, and the test results are displayed in the console.

By using TestLoader and loadTestsFromTestCase(), we can dynamically discover and load the tests from test case classes, providing a more flexible and future-proof to create test suites. The TextTestRunner is one of the runners provided by unittest that displays test results in the console.

Advanced Testing Techniques

Aside from the use of Testcase, Testfixtures, Testsuites, and Testrunner, there are some more advanced ways to test your code. In this article, we will focus on two such methods which are:

  • Skipping and excluding decorators

  • Testing exceptions with assertRaises() and asseetWarns()

Skipping and Excluding Decorators

In unittest, skipping and excluding specific tests can be achieved using decorators. Decorators are special functions that modify the behavior of the decorated function or method. Two commonly used decorators for skipping and excluding tests in unittest are @unittest.skip() and @unittest.skipIf().

1. Skipping a test with @unittest.skip() decorator:

This decorator is used to skip specific test cases or test methods and it marks the test as skipped and skips its execution when the test suite is run.

Here's an example:

import unittest

class MyTestCase(unittest.TestCase):
     @unittest.skip("Enter a reason for skipping this test")
     def test_something(self):
      # Test implementation
       pass

In this example, the test_something() method is marked with @unittest.skip() decorator, along with a reason for skipping. When the test suite is executed, this test will be skipped, and its result will be reported as skipped.

2. Skipping a test conditionally with @unittest.skipIf() decorator:

To skip a test based on a condition, the @unittest.skipIf() decorator is used a it allows the specification of a condition and if the condition evaluates to True, the test will be skipped.

Here's an example:

import unittest

class MyTestCase(unittest.TestCase):
    @unittest.skipIf(condition, reason)
    def test_something(self):
        # Test implementation
        pass

In this example, condition is a Boolean expression or a function returning a Boolean value. If the condition is True, the test will be skipped, and its result will be reported as skipped. You can also provide a reason to explain why the test is being skipped.

It is essential to understand that these decorators provide flexibility in controlling the execution of tests based on specific conditions or requirements. They can be useful in scenarios where certain tests need to be skipped temporarily or conditionally excluded based on the environment or configuration.

Testing exceptions with assertRaises() and asseetWarns()

Another more advanced way to test a program is through the use of assertRaises() and assertWarns() methods.

1. assertRaises() method:

This method is used to test whether a specific exception is raised when executing a particular code block. It ensures that the code under test raises the expected exception.

To demonstrate its use, consider the code sample below:

import unittest

class Calculator:
    def divide(self, a, b):
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

class TestCalculator(unittest.TestCase):
    def test_divide_by_zero(self):
        calculator = Calculator()
        with self.assertRaises(ValueError):
            calculator.divide(10, 0)

if __name__ == '__main__':
    unittest.main()

The Calculator class is defined, which contains a divide() method that performs division and raises a ValueError if the divisor is zero.

  • The TestCalculator class is a unittest.TestCase subclass that includes the test_divide_by_zero() method.

  • In the test_divide_by_zero() method, an instance of the Calculator class is created, and the divide() method is called with the arguments 10 and 0.

  • The self.assertRaises(ValueError) context manager is used to assert that a ValueError is raised when executing the specified code.

  • If the expected exception is raised within the context manager, the test case passes; otherwise, it fails.

2. assertWarns() method:

This method is used to test whether a specific warning is issued when executing a particular code block. It ensures that the code under test generates the expected warning.

To demonstrate its use, consider the code sample below:

import unittest
import warnings

class Calculator:
    def square_root(self, x):
        if x < 0:
            warnings.warn("Square root of a negative number")
        return x + 1

class TestCalculator(unittest.TestCase):
    def test_negative_number_warning(self):
        calculator = Calculator()
        with self.assertWarns(UserWarning):
            calculator.square_root(-9)

if __name__ == '__main__':
    unittest.main()

In this example:

  • The Calculator class is defined with a square_root() method that calculates the square root of a number. If the input (x) is negative, a UserWarning is raised.

  • The TestCalculator class is a unittest.TestCase subclass that contains the test_negative_number_warning() method.

  • In the test_negative_number_warning() method, an instance of the Calculator class is created, and the square_root() method is called with the argument -9.

  • The self.assertWarns(UserWarning) context manager is used to assert that a UserWarning is raised when executing the specified code.

  • If the expected warning is raised within the context manager, the test case passes; otherwise, it fails.

assertwarns() is useful for ensuring that specific warnings are raised when executing code. It allows you to define the expected warning behavior and assert that the code generates the expected warning under specific conditions.

5 Best practices and tips for effective unit testing using python unittest

Here are five best practices and tips for effective unit testing using the unittest framework in Python:

  1. Keep Tests Independent and Isolated:

Always ensure that each unit test case is independent and isolated from other tests and does not depend on the outcome of other tests. This helps in identifying issues more easily and preventing any side effects thus ensuring that the tests remain reliable regardless of the order of execution.

  1. Descriptive Test Names:

Use descriptive test names for every test method. This provides more clarity of intentions and makes it easier for anyone who stumbles on your code to understand the purpose of the test.

  1. Validate actions using assertions:

Make use of assertions from the unittest.TestCase class to validate the expected outcomes of your tests and always use appropriate assertions based on the type of test and the expected behavior.

  1. Use setUp() and tearDown():

Utilize the setUp() method to set up preconditions and create common objects required for multiple test methods in the same test case. Additionally, tearDown() can be used to clean up any resources after each test. This approach avoids code duplication and ensures a clean environment for each test.

  1. Organize Tests with Test Suites:

Group related tests into test suites using the TestSuite class. Test suites allow you to run multiple test cases together and organize tests hierarchically. This is particularly useful when you have a large number of tests or want to group tests based on specific functionalities or modules.

Lastly, when using the unittest framework to test codes, always ensure you write clear and concise tests. Make your tests readable and maintainable. Two ways to achieve this is by using comments and docstrings to provide context and explanations when necessary.

By following these best practices and tips, you can write effective unit tests that are easy to understand, maintain, and debug, thereby improving the reliability and quality of your code.

Conclusion

The Python unittest framework is a good way to ensure that all lines of your code are working as expected. It is of great importance to test and run your code before deploying it.

Ensure to adopt the tips and best practices listed above for proper maintenance and debugging of your code.