Skip to content
On this page

Unit Testing

If you've heard of "Test-Driven Development" (TDD), then unit testing should be familiar to you.

Unit testing is the process of verifying the correctness of a module, function, or class.

For example, for the function abs(), we can write several test cases:

  • Input positive numbers like 1, 1.2, 0.99, expecting the return value to be the same as the input.
  • Input negative numbers like -1, -1.2, -0.99, expecting the return value to be the opposite of the input.
  • Input 0, expecting the return value to be 0.
  • Input non-numeric types like None, [], {}, expecting a TypeError to be raised.

Putting the above test cases into a test module creates a complete unit test.

If the unit test passes, it indicates that the function we tested works correctly. If the unit test fails, there might be a bug in the function or an incorrect test condition, meaning we need to fix something to make the unit test pass.

What’s the significance of passing unit tests? If we modify the code for the abs() function, we simply need to rerun the unit tests. If they pass, it means our changes do not affect the original behavior of the abs() function. If the tests fail, it indicates that our modifications are inconsistent with the original behavior, requiring either code or test adjustments.

This test-driven development approach ensures that a program module behaves according to our designed test cases. It greatly assures that the module's behavior remains correct when modified in the future.

Let's write a Dict class, which behaves like a dictionary but can be accessed via attributes, allowing usage like the following:

python
>>> d = Dict(a=1, b=2)
>>> d['a']
1
>>> d.a
1

The code for mydict.py is as follows:

python
class Dict(dict):
    def __init__(self, **kw):
        super().__init__(**kw)

    def __getattr__(self, key):
        try:
            return self[key]
        except KeyError:
            raise AttributeError(r"'Dict' object has no attribute '%s'" % key)

    def __setattr__(self, key, value):
        self[key] = value

To write unit tests, we need to import Python's built-in unittest module and write mydict_test.py as follows:

python
import unittest

from mydict import Dict

class TestDict(unittest.TestCase):
    def test_init(self):
        d = Dict(a=1, b='test')
        self.assertEqual(d.a, 1)
        self.assertEqual(d.b, 'test')
        self.assertTrue(isinstance(d, dict))

    def test_key(self):
        d = Dict()
        d['key'] = 'value'
        self.assertEqual(d.key, 'value')

    def test_attr(self):
        d = Dict()
        d.key = 'value'
        self.assertTrue('key' in d)
        self.assertEqual(d['key'], 'value')

    def test_keyerror(self):
        d = Dict()
        with self.assertRaises(KeyError):
            value = d['empty']

    def test_attrerror(self):
        d = Dict()
        with self.assertRaises(AttributeError):
            value = d.empty

When writing unit tests, we need to create a test class that inherits from unittest.TestCase.

Methods that begin with test are recognized as test methods; methods that do not begin with test are not considered test methods and will not be executed during testing.

Each category of test requires a test_xxx() method. Since unittest.TestCase provides many built-in assertion methods, we can simply call these methods to assert whether the output meets our expectations. The most commonly used assertion is assertEqual():

python
self.assertEqual(abs(-1), 1)  # Asserts that the function returns a result equal to 1

Another important assertion is expecting a specified type of error to be raised. For example, when accessing a non-existent key via d['empty'], we assert that a KeyError is raised:

python
with self.assertRaises(KeyError):
    value = d['empty']

Similarly, when accessing a non-existent key via d.empty, we expect an AttributeError:

python
with self.assertRaises(AttributeError):
    value = d.empty

Running Unit Tests

Once the unit tests are written, we can run them. The simplest way is to add the following two lines at the end of mydict_test.py:

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

This way, you can run mydict_test.py as a normal Python script:

$ python mydict_test.py

Another way is to run the unit tests directly from the command line using the -m unittest parameter:

$ python -m unittest mydict_test
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

This is the recommended approach, as it allows you to run many unit tests in bulk, and there are many tools available to automate these unit tests.

setUp and tearDown

You can define two special methods in unit tests, setUp() and tearDown(). These methods are executed before and after each test method is called, respectively.

What are setUp() and tearDown() useful for? Suppose your tests require starting a database. You could connect to the database in the setUp() method and close it in the tearDown() method, eliminating the need to repeat the same code in each test method:

python
class TestDict(unittest.TestCase):
    def setUp(self):
        print('setUp...')

    def tearDown(self):
        print('tearDown...')

You can rerun the tests to see if setUp... and tearDown... are printed before and after each test method is called.

Exercise

Write unit tests for the Student class. If the tests do not pass, modify the Student class to make them pass:

python
import unittest

class Student(object):
    def __init__(self, name, score):
        self.name = name
        self.score = score
        
    def get_grade(self):
        if self.score >= 60:
            return 'B'
        if self.score >= 80:
            return 'A'
        return 'C'

class TestStudent(unittest.TestCase):
    def test_80_to_100(self):
        s1 = Student('Bart', 80)
        s2 = Student('Lisa', 100)
        self.assertEqual(s1.get_grade(), 'A')
        self.assertEqual(s2.get_grade(), 'A')

    def test_60_to_80(self):
        s1 = Student('Bart', 60)
        s2 = Student('Lisa', 79)
        self.assertEqual(s1.get_grade(), 'B')
        self.assertEqual(s2.get_grade(), 'B')

    def test_0_to_60(self):
        s1 = Student('Bart', 0)
        s2 = Student('Lisa', 59)
        self.assertEqual(s1.get_grade(), 'C')
        self.assertEqual(s2.get_grade(), 'C')

    def test_invalid(self):
        s1 = Student('Bart', -1)
        s2 = Student('Lisa', 101)
        with self.assertRaises(ValueError):
            s1.get_grade()
        with self.assertRaises(ValueError):
            s2.get_grade()

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

Summary

Unit testing effectively tests the behavior of a program module, providing confidence for future code refactoring.

Unit test cases should cover common input combinations, edge cases, and exceptions.

Unit test code should be straightforward; if the test code is too complex, it may introduce bugs.

Passing unit tests does not guarantee the absence of bugs in the program, but failing unit tests certainly indicates the presence of bugs.

Unit Testing has loaded