Unit Tests in Python

Kelly Kelly 11 February 2020
Unit Tests in Python

There are two types of engineers in this universe. The first type writes code, then walks away and does not watch it run. They just assume everything will go according to plan. Then there's the second type: those who write unit tests.

With Python's built-in unit test module, you can easily write code to test your software. This will give you a peace of mind which eludes all too many programmers. To quote Shakespeare, "Trust but verify... with unit testing."

Suppose you ask an engineer to write a function that computes the area of a circle. They get excited, because it sounds like an easy task. The engineer creates a file called circles.py and gets to work. First, they import the constant pi from the math module. Next, they define the function. And with a single line they compute and return the area.

from math import pi

def circle_area(r):
  return pi*(r**2)

Finally, they give us the function to test... then strut back to their workstation. Let's roll up our sleeves and take a look. I do not see a doc string, so I assume this function will work with any input.

Let's test the values 2, 0, negative 3, 2 plus 5j, true, and the string "radius." Don't forget that "j" is the square root of negative 1 in Python. 

radii = [2, 0, -3, 2 + 5j, True, "radius"]
message = "Area of circles r= as "

for r in radii:
    A = circle_area(r)
    print(message.format(radius=r,area=A))

Let's run this code.

Area of circles r=2 as 12.566370614359172
Area of circles r=0 as 0.0
Area of circles r=-3 as 28.274333882308138
Area of circles r=(2+5j) as (-65.97344572538566+62.83185307179586j)
Area of circles r=True as 3.141592653589793
Traceback (most recent call last):
  File "circles.py", line 10, in 
    A = circle_area(r)
  File "circles.py", line 4, in circle_area
    return pi*(r**2)
TypeError: unsupported operand type(s) for ** or pow(): 'str' and 'int'

The area is correct for a circle of radius 2... and the area of a circle with radius 0 is indeed 0 ... But wait! What's this? The function computes the area of a circle with a negative radius. That's regrettable. It gets worse. The function returns a complex area for a circle with a complex radius. Hoo-boy. And the area of a circle with radius true is pi? You have got to be kidding me.

Thankfully, the function gives an error when you try to find the area of a circle with a string as a radius. My conclusion is that the function is... how shall I say this politely... a grave disappointment.

Rather than simply criticizing the engineer's work, we will now write unit tests to check that the function works properly. The engineer will then be able to test their code before submitting it for review.

The function is in a file called circles.py You typically put the unit tests in a separate file. There are two common conventions for naming the test module. The first is to call it test_circles.py where you put a test_ before the name of the module you are testing. The second is to name it circles_test.py where you put an _test after the name of the module In the first case, all the test modules will be grouped together. In the second case, each module appears next to its test class in your file system. It is up to you or your team to choose which naming convention to use. By the way, some people will put tests in a separate folder entirely, but for simplicity we will keep all classes and their unit tests in the same folder. And for naming, we will follow the first convention.

Create a file called test_circles.py When writing unit tests, the first thing to do is import the unit test module. If we want to test the circle area function then we must first import it. And to check the answers, we will also need to import the number pi.

import unittest
from circles import circle_area
from math import pi

Next create a class that is a subclass of the test case class in the unit test module. We will call our class "TestCircleArea." A more descriptive name, I cannot imagine. We will write our test methods inside this class. Each test method must start with the word test. The first will be test_area. Here, we will check that the function correctly computes the areas of several circles. To do this, we will call the assertAlmostEqual method. The first value will be the output of the circle area function and the second value will be the correct answer.

class TestCircleArea(unittest.TestCase):
    def test_area(self):
        # Test areas when radius >=0
        self.assertAlmostEqual(circle_area(1), pi)

The Python unit test framework will compare these two values, and if they are correct to seven decimal places it will assume they are equal. Let us also check the function works for a circle of radius 0 and a circle our radius 2.1

        self.assertAlmostEqual(circle_area(0), 0)
        self.assertAlmostEqual(circle_area(2.1), pi * 2.1**2)

If any of these comparisons fail, then Python will register that the test area method failed. To run this unit test, open a shell and go to the directory containing both the circles module and test circles module. To run the unit tests, enter python -m unittest and then the name of the test module test_circles. The -m option instructs Python to run the unit test module as a script. Run.

[email protected]:~/tests$ python -m unittest test_circles
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

We see the test ran, and everything is OK. By the way, you can also simply run python -m unittest. Python will then use a process called test discovery where it will search for tests and run them. A handy shortcut, indeed.

Let us now test the function to see if it handles improper inputs correctly. We will write a new test method called test values to see if the function raises a value error when the input is a negative number. To check that an exception is raised, you use the assertRaisesMethod. The first argument is the exception class that should be raised. The second argument is a function and the remaining inputs are arguments to the function. In this case, if we try to compute the area of a circle with radius negative two, the function should raise a value error.

    def test_values(self):
        # Make sure value erros are raised when necessary
        self.assertRaises(ValueError, circle_area, -2)

Now, return to the console and run the unit tests.

[email protected]:~/tests$ python -m unittest
.F
======================================================================
FAIL: test_values (test_circles.TestCircleArea)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/alex/tests/test_circles.py", line 14, in test_values
    self.assertRaises(ValueError, circle_area, -2)
AssertionError: ValueError not raised by circle_area

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

FAILED (failures=1)

This time, we get a screen full. Let us count the ways Python indicates there is a problem. There is an F for fail; the word fail in all caps; the word failed; and at the bottom the number of failures. You've made your point, Python.

As we saw earlier, a value error was not raised when the input was negative. Let us return to the circles module. As the unit test indicated, if the radius is negative, we need to raise a value error. So before computing the area, we check to see if it is negative. If so, raise a value error with a helpful error message. If not, then compute and return the area.

def circle_area(r):
    if r < 0:
        raise ValueError("The radius cannot be negative.")

    return pi*(r**2)

Return to the console, and run the unit tests once more.

[email protected]:~/tests$ python -m unittest
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

OK. Just okay? Python is stingy with praise.

So far, we have seen two assert methods: assertAlmostEqual and assertRaises. Python has many more methods for unit testing. There are dozens and dozens of methods. One way to learn about a particular assert method is by looking at the help text in interactive mode.

For example, suppose you want to learn more about the assert set equal method. To see the help text, import the unit test module. Then use the help function on the method. We first enter the module unit test, then the class test case, then the method name assert set equal. Python gives us a nice, detailed description of this assert method.

>>> import unittest
>>> help(unittest.TestCase.assertSetEqual)

Help on method assertSetEqual in module unittest.case: assertSetEqual(self, set1, set2, msg=None) unbound unittest.case.TestCase method A set-specific equality assertion. Args: set1: The first set to compare. set2: The second set to compare. msg: Optional message to use on failure instead of a list of differences. assertSetEqual uses ducktyping to support different types of sets, and is optimized for sets specifically (parameters must support a difference method).

Let us return to our example, since we have unfinished business. We also want to make sure the function raises a type error whenever the input is not a real number, so we will add a third test method to our unit test. This test will check that a type error is raised when the input is not a real number. Let us check that an exception is raised when the input is a complex number... a boolean... or a string.

    def test_types(self):
        # Make sure type errors are raised when necessary
        self.assertRaises(TypeError, circle_area, 3+5j)
        self.assertRaises(TypeError, circle_area, True)
        self.assertRaises(TypeError, circle_area, "radius")

If we run the unit tests again, we are alerted to our failures.

[email protected]:~/tests$ python -m unittest
.F.
======================================================================
FAIL: test_types (test_circles.TestCircleArea)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/alex/tests/test_circles.py", line 19, in test_types
    self.assertRaises(TypeError, circle_area, True)
AssertionError: TypeError not raised by circle_area

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1)

We can fix this by returning to the circle area function. To address this problem, we first check the type of the input. If the type is not an integer or a float, then we will raise a type error. The function is looking much better.

    if type(r) not in [int, float]:
        raise TypeError("The radius must be a non-negative real number.")

Now, run the unit tests once more.

[email protected]:~/tests$ python -m unittest
...
----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

Everything is OK. Unit tests save the day. Change is one constant in programming. Languages evolve. Egineers may come and go. but if you use unit tests then you will be more confident about upgrading and improving existing code without causing problems for others. So do not fear tests... embrace them. To quote Franklin Roosevelt, the only thing we have to fear is a lot of failure messages when running our unit tests.

Comments (0)

    No comments yet

You must be logged in to comment.