Takeaways from Python Crash Course: Python Unit Test and Test Case by unittest Module

Aug. 24, 2024

This post is a record made while learning Chapter 11 “Testing Your Code” in Eric Matthes’s book, Python Crash Course.1

Test a function

Here, we define a function get_formatted_name() and simply test its function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def get_formatted_name(first, last):
    """Generate a neatly formatted full name."""
    full_name = f"{first} {last}"
    return full_name.title()

print("Enter 'q' at any time to quit.")
while True:
    first = input("\nPlease give me a first name: ")
    if first == 'q':
        break
    last = input("Please give me a last name: ")
    if last == 'q':
        break
 
    formatted_name = get_formatted_name(first, last)
    print(f"\tNeatly formatted name: {formatted_name}.")
1
2
3
4
5
6
7
Enter 'q' at any time to quit.

Please give me a first name: tommy
Please give me a last name: shelby
	Neatly formatted name: Tommy Shelby.

Please give me a first name: q

Unit test and test case

Python module unittest2 from the Python standard library provides tools for testing the code. A unit test verifies that one specific aspect of a function’s behavior is correct. A test case is a collection of unit tests that together prove that a function behaves as it’s supposed to, within the full range of situations we expect it to handle.

A good test case considers all the possible kinds of input a function could receive and includes tests to represent each of these situations. A test case with full coverage includes a full range of unit tests covering all the possible ways users can use a function. Achieving full coverage on a large project is usually ideal. It’s often good enough to write tests for code’s critical behaviors and then aim for full coverage only if the project starts to see widespread use.

A passing test

To write a test case for a function, import the unittest module and the function to test. Then, create a class that inherits from the class unittest.TestCase, and write a series of methods to test different aspects of the function’s behavior.

Here’s a test case with one method that verifies that the function get_formatted_name() works correctly when given a first and last name:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import unittest

def get_formatted_name(first, last):
    """Generate a neatly formatted full name."""
    full_name = f"{first} {last}"
    return full_name.title()

class NamesTestCase(unittest.TestCase):
    """Tests for 'name_function.py'."""
    
    def test_first_last_name(self):
        """Do names like 'Janis Joplin' work?"""
        formatted_name = get_formatted_name('janis', 'joplin')
        self.assertEqual(formatted_name, 'Janis Joplin')

if __name__ == '__main__':
#     unittest.main(argv=['first-arg-is-ignored'], exit=False)
    unittest.main(argv=['ignored', '-v'], exit=False)
1
2
3
4
5
6
7
test_first_last_name (__main__.NamesTestCase.test_first_last_name)
Do names like 'Janis Joplin' work? ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

Note: To normally run unittest’s main function in Jupyter Notebook, simple unittest.main() is not necessary. We should use unittest.main(argv=['ignored', '-v'], exit=False) or unittest.main(argv=['first-arg-is-ignored'], exit=False) instead, which prevent unittest.main from trying to shutdown the kernel process34.

As can be seen, we create a class called NamesTestCase (it’s better to make a class name relating to the function we’re about to test and containing the word “Test”). The class NamesTestCase contains one or some unit tests for get_formatted_name(), and it must inherit from the class unittest.TestCase so Python knows how to run the tests within it.

In this simple case, NamesTestCase only contains a single method test_first_last_name() to test one aspect of get_formatted_name(), that is verifying names with only a first and last name are formatted correctly. Any method that starts with test_ will be run automatically when we run the unittest.main(argv=['ignored', '-v'], exit=False). Within a test method, we call the function we want to test, like in this example we call get_formatted_name() with the arguments 'janis' and 'joplin', and assign the result to the variable formatted_name.

Then, we use one of unittest’s most useful features: assert method. Assert methods verify that a result received matches the result we expected to receive. In this example, we expect the value of formatted_name to be Janis Joplin, and in order to check if this is true, we use unittest’s assertEqual() method and pass it formatted_name and 'Janis Joplin'.

After running the script, message Ran 1 test in 0.001s tells us the number of tests and elapsed time; final OK tells that unit tests in the test case passed.

A failing test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import unittest

def get_formatted_name(first, middle, last):
    """Generate a neatly formatted full name."""
    full_name = f"{first} {middle} {last}"
    return full_name.title()

class NamesTestCase(unittest.TestCase):
    """Tests for 'name_function.py'."""
    
    def test_first_last_name(self):
        """Do names like 'Janis Joplin' work?"""
        formatted_name = get_formatted_name('janis', 'joplin')
        self.assertEqual(formatted_name, 'Janis Joplin')

if __name__ == '__main__':
    unittest.main(argv=['ignored', '-v'], exit=False)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
test_first_last_name (__main__.NamesTestCase.test_first_last_name)
Do names like 'Janis Joplin' work? ... ERROR

======================================================================
ERROR: test_first_last_name (__main__.NamesTestCase.test_first_last_name)
Do names like 'Janis Joplin' work?
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\Users\whatastarrynight\AppData\Local\Temp\ipykernel_13952\4024878731.py", line 13, in test_first_last_name
    formatted_name = get_formatted_name('janis', 'joplin')
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: get_formatted_name() missing 1 required positional argument: 'last'

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (errors=1)

Here, the test test_first_last_name() in NamesTestCase causes an error, and the traceback reposts that the function call get_formatted_name('janis', 'joplin') no longer works because a required positional argument is missing.

Then, we should respond to this failed test. By modifying get_formatted_name() function as follows, we can make the unit test test_first_last_name() pass:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import unittest

def get_formatted_name(first, last, middle=''):
    """Generate a neatly formatted full name."""
    if middle:
        full_name = f"{first} {middle} {last}"
    else:
        full_name = f"{first} {last}"
    return full_name.title()

class NamesTestCase(unittest.TestCase):
    """Tests for 'name_function.py'."""
    
    def test_first_last_name(self):
        """Do names like 'Janis Joplin' work?"""
        formatted_name = get_formatted_name('janis', 'joplin')
        self.assertEqual(formatted_name, 'Janis Joplin')

if __name__ == '__main__':
    unittest.main(argv=['ignored', '-v'], exit=False)
1
2
3
4
5
6
7
test_first_last_name (__main__.NamesTestCase.test_first_last_name)
Do names like 'Janis Joplin' work? ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

Add more tests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import unittest

def get_formatted_name(first, last, middle=''):
    """Generate a neatly formatted full name."""
    if middle:
        full_name = f"{first} {middle} {last}"
    else:
        full_name = f"{first} {last}"
    return full_name.title()

class NamesTestCase(unittest.TestCase):
    """Tests for 'name_function.py'."""
    
    def test_first_last_name(self):
        """Do names like 'Janis Joplin' work?"""
        formatted_name = get_formatted_name('janis', 'joplin')
        self.assertEqual(formatted_name, 'Janis Joplin')
        
    def test_first_last_middle_name(self):
        """Do names like 'Wolfgang Amadeus Mozart' work?"""
        formatted_name = get_formatted_name('wolfgang', 'mozart', 'amadeus')
        self.assertEqual(formatted_name, 'Wolfgang Amadeus Mozart')

if __name__ == '__main__':
    unittest.main(argv=['ignored', '-v'], exit=False)
1
2
3
4
5
6
7
8
9
test_first_last_middle_name (__main__.NamesTestCase.test_first_last_middle_name)
Do names like 'Wolfgang Amadeus Mozart' work? ... ok
test_first_last_name (__main__.NamesTestCase.test_first_last_name)
Do names like 'Janis Joplin' work? ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

It’s fine to have long method names in the TestCase classes—they need to be descriptive so we can make sense of the output when the tests fail. On other hand, we don’t have to worry about the inconvenience of using long method names because we’ll never have to write code that calls these methods.


Test a class

A variety of assert methods

Python provides a number of assert methods in the unittest.TestCase class. Including above assertEqual(), there are six commonly used assert methods available from the unittest module:

  • assertEqual(a, b): Verify that a == b
  • assertNotEqual(a, b): Verify that a != b
  • assertTrue(x): Verify that x is True
  • assertFalse(x): Verify that x is False
  • assertIn(item, list): Verify that item is in list
  • assertNotIn(item, list): Verify that item is not in list

Of course, we can only use these methods in a class that inherits from unittest.TestCase.

A class to test

Testing a class is similar to testing a function—much of the work involves testing the behavior of methods in a class. In the following code, we create a class named AnonymousSurvey and test its behaviors:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class AnonymousSurvey:
    """Collect anonymous answers to a survey question."""
    
    def __init__(self, question):
        """Store a question, and prepare to store responses."""
        self.question = question
        self.responses = []
        
    def show_question(self):
        """Show the survey question."""
        print(self.question)
        
    def store_response(self, new_response):
        """Store a single response to the survey."""
        self.responses.append(new_response)
        
    def show_results(self):
        """Show all the responses that have been given."""
        print("Survey results:")
        for response in self.responses:
            print(f"- {response}")

# Define a question, and make a survey.
question = "What language did you first learn to speak?"
my_survey = AnonymousSurvey(question)

# Show the question, and store responses to the question.
my_survey.show_question()
print("Enter 'q' at any time to quit.\n")
while True:
    response = input("Language: ")
    if response == 'q':
        break
    my_survey.store_response(response)

# Show the survey results.
print("\nThank you to everyone who participated in the survey!")
my_survey.show_results()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
What language did you first learn to speak?
Enter 'q' at any time to quit.

Language: English
Language: Spanish
Language: English
Language: Mandarin
Language: q

Thank you to everyone who participated in the survey!
Survey results:
- English
- Spanish
- English
- Mandarin

Test the AnonymousSurvey class

Following test test_store_single_response() uses the assertIn() method to verify whether a single response to the survey question is in the list of responses after it’s been stored:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import unittest

class AnonymousSurvey:
    """Collect anonymous answers to a survey question."""
    
    def __init__(self, question):
        """Store a question, and prepare to store responses."""
        self.question = question
        self.responses = []
        
    def show_question(self):
        """Show the survey question."""
        print(self.question)
        
    def store_response(self, new_response):
        """Store a single response to the survey."""
        self.responses.append(new_response)
        
    def show_results(self):
        """Show all the responses that have been given."""
        print("Survey results:")
        for response in self.responses:
            print(f"- {response}")
            
class TestAnonymousSurvey(unittest.TestCase):
    """Tests for the class AnonymousSurvey"""
    def test_store_single_response(self):
        """Test that a single response is stored properly."""
        question = "What language did you first learn to speak?"
        my_survey = AnonymousSurvey(question) # make an instance of the class
        my_survey.store_response('English')
        self.assertIn('English', my_survey.responses)

if __name__ == '__main__':
    unittest.main(argv=['ignored', '-v'], exit=False)
1
2
3
4
5
6
7
test_store_single_response (__main__.TestAnonymousSurvey.test_store_single_response)
Test that a single response is stored properly. ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

Take it further, we can verify that three responses can be stored correctly, by following test_store_three_responses() method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import unittest

class AnonymousSurvey:
    """Collect anonymous answers to a survey question."""
    
    def __init__(self, question):
        """Store a question, and prepare to store responses."""
        self.question = question
        self.responses = []
        
    def show_question(self):
        """Show the survey question."""
        print(self.question)
        
    def store_response(self, new_response):
        """Store a single response to the survey."""
        self.responses.append(new_response)
        
    def show_results(self):
        """Show all the responses that have been given."""
        print("Survey results:")
        for response in self.responses:
            print(f"- {response}")
            
class TestAnonymousSurvey(unittest.TestCase):
    """Tests for the class AnonymousSurvey"""
    def test_store_single_response(self):
        """Test that a single response is stored properly."""
        question = "What language did you first learn to speak?"
        my_survey = AnonymousSurvey(question) # make an instance of the class
        my_survey.store_response('English')
        self.assertIn('English', my_survey.responses)
    
    def test_store_three_responses(self):
        """Test that three individual responses are stored properly."""
        question = "What language did you first learn to speak?"
        my_survey = AnonymousSurvey(question)  # make an instance of the class
        responses = ['English', 'Spanish', 'Mandarin']
        for response in responses:
            my_survey.store_response(response)
        
        for response in responses:
            self.assertIn(response, my_survey.responses)

if __name__ == '__main__':
    unittest.main(argv=['ignored', '-v'], exit=False)
1
2
3
4
5
6
7
8
9
test_store_single_response (__main__.TestAnonymousSurvey.test_store_single_response)
Test that a single response is stored properly. ... ok
test_store_three_responses (__main__.TestAnonymousSurvey.test_store_three_responses)
Test that three individual responses are stored properly. ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

setUp() method

In above script, when both defining test_store_single_response() method and test_store_three_responses() method, we create a new instance of AnonymousSurvey in each test method, and new responses in each method. It looks kind of repetitive.

The unittest.TestCase class has a setUp() method that allows us to create these objects once and then use them in every test method. If we set a setUp() method in a TestCase class, Python will run it before running each method starting with test_. Any objects created in the setUp() method are then available in each test method.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import unittest

class AnonymousSurvey:
    """Collect anonymous answers to a survey question."""
    
    def __init__(self, question):
        """Store a question, and prepare to store responses."""
        self.question = question
        self.responses = []
        
    def show_question(self):
        """Show the survey question."""
        print(self.question)
        
    def store_response(self, new_response):
        """Store a single response to the survey."""
        self.responses.append(new_response)
        
    def show_results(self):
        """Show all the responses that have been given."""
        print("Survey results:")
        for response in self.responses:
            print(f"- {response}")
            
class TestAnonymousSurvey(unittest.TestCase):
    """Tests for the class AnonymousSurvey"""
    def setUp(self):
        """
        Create a survey and a set of responses for use in all test methods.
        """
        question = "What language did you first learn to speak?"
        self.my_survey = AnonymousSurvey(question)
        self.responses = ['English', 'Spanish', 'Mandarin']
    
    
    def test_store_single_response(self):
        """Test that a single response is stored properly."""
        self.my_survey.store_response(self.responses[0])
        self.assertIn(self.responses[0], self.my_survey.responses)
    
    def test_store_three_responses(self):
        """Test that three individual responses are stored properly."""
        for response in self.responses:
            self.my_survey.store_response(response)
        for response in self.responses:
            self.assertIn(response, self.my_survey.responses)

if __name__ == '__main__':
    unittest.main(argv=['ignored', '-v'], exit=False)
1
2
3
4
5
6
7
8
9
test_store_single_response (__main__.TestAnonymousSurvey.test_store_single_response)
Test that a single response is stored properly. ... ok
test_store_three_responses (__main__.TestAnonymousSurvey.test_store_three_responses)
Test that three individual responses are stored properly. ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.002s

OK

The method setUp() does two things: it creates a survey instance, and it creates a list of responses. Each of these is prefixed by self, so they can be used anywhere in the class. This makes the two test methods simpler, because neither one has to make a survey instance or a response.

Anyway, when we’re testing our own classes, the setUp() method can make test methods easier to write. We can make one set of instances and attributes in setUp() and then use these instances in all test methods. This is much easier than making a new set of instances and attributes in each test method time and time again. From this perspective, this setUp() method resembles __init__() method of a Python class.


References