22. Unittest
You may have noticed that our simple
asserttests are becoming more difficult to write as our programs grow in complexityFor example, consider the
Circle,Point3D, andSphereclassesThere is some setup required for tests (e.g. creating an instance of a class)
The
asserttests get jumbled togetherIt is not easy to know what the test is testing just by doing a quick look
There are many similar tests with redundant test code
Fortunately, Python provides tools for helping us test our code —
unittestunittestprovides a lot of functionalitySpecial assert methods
Automated testing
Sharing setup/shutdown methods
Subtests
Nicer test result reporting
…
We will only scratch the surface here, but there is a lot one can do with
unittestFortunately, the basic idea of testing is the same with
unittestas it is with our simpleasserttests
22.1. Starting a Unit Test Class
The first thing we need to do to start writing our unit tests with
unittestis to import itThen we need to start defining a class
Our tests are actually going to be written within a class
1import unittest
2
3class SphereTest(unittest.TestCase):
4 # Tests go here
In the above example, you will see the import and the start of the class
Although it is not needed on Colab, depending on how we have our tests setup, we may need to import the class being tested
As a convention, we call our test classes
SomeClassTest, whereSomeClassis the name of the class we are testingSince the tests will be for the
Sphereclass, we call itSphereTest
You will also notice the
unittest.TestCasewithin parentheses next to the class nameThe nuance of what this means is outside the scope of this course, but in short, we need it in order to make use of the
unittestframework
22.2. Writing Unit Tests
1import unittest
2
3class SphereTest(unittest.TestCase):
4
5 def test_sphere_centre_point_returns_correct_point3D(self):
6 sphere = Sphere(Point3D(3, 2, 1), 11)
7 self.assertEqual(Point3D(3, 2, 1), sphere.centre_point)
8
9 def test_sphere_radius_returns_correct_radius(self):
10 sphere = Sphere(Point3D(3, 2, 1), 11)
11 self.assertEqual(11, sphere.radius)
Above are two tests confirming the correctness of the constructor and attributes
Key things to note
Tests are methods that belong to the class, each with just
selfin the parameter listEach method name starts with
test_— this prefix is required; the rest of the name should be descriptiveassertEqualis called viaself— it comes fromunittest.TestCaseand checks that two values are equalEach test method handles its own setup and tests one thing, making it easy to isolate failures
1import unittest
2
3class SphereTest(unittest.TestCase):
4
5 # Other test methods not shown for brevity
6
7 def test_equals_on_equal_spheres_returns_true(self):
8 sphere_a = Sphere(Point3D(1, 2, 3), 1)
9 sphere_b = Sphere(Point3D(1, 2, 3), 1)
10 self.assertEqual(sphere_a, sphere_b)
11
12 def test_equals_on_not_equal_spheres_returns_false(self):
13 sphere_a = Sphere(Point3D(1, 2, 3), 1)
14 sphere_b = Sphere(Point3D(1, 2, 3), 2)
15 self.assertNotEqual(sphere_a, sphere_b)
16
17 def test_equal_on_sphere_and_string_returns_false(self):
18 sphere = Sphere(Point3D(1, 2, 3), 4)
19 self.assertNotEqual("Sphere(centre_point=Point3D(x=1, y=2, z=3), radius=4)", sphere)
20
21 def test_repr_arbitrary_sphere_returns_correct_string(self):
22 sphere = Sphere(Point3D(1, 2, 3), 4)
23 self.assertEqual("Sphere(centre_point=Point3D(x=1, y=2, z=3), radius=4)", str(sphere))
Above are additional tests for the magic methods
__eq__and__repr__For two of the
__eq__methods, you will see the setup is a little more involved as we need twoSphereobjects for the testYou will also notice the use of
assertNotEqual, which is just another type of testAlthough all test methods must start with
test_, as a convention for consistency and readability, method names will follow a patterntest_method_condition_expected
One of the above examples is
test_equals_on_equal_spheres_returns_trueequalsis the method being testedon_equal_spheresis the conditionreturns_trueis what is expected
22.2.1. Subtests
Often we have functionality we would like to test on various cases
But it feels rather silly writing a whole new test for each case
Consider the
diametermethodWhat cases should be tested?
We want to check our edge cases and general cases
Test a
Sphereat the origin that has zeroradiusTest a
Sphereat the origin with non-zeroradius
But we may want to confirm that the
centre_pointhas no impact on thediameterof theSphereTest a
Spherethat exists in an arbitrary location with zeroradiusTest a
Spherethat exists in an arbitrary location with non-zeroradius
To test all four example cases the same way as the above tests, we would need four separate tests that are nearly identical
1import unittest
2
3class SphereTest(unittest.TestCase):
4
5 # Other test methods not shown for brevity
6
7 def test_diameter_radius_zero_origin_returns_zero(self):
8 sphere = Sphere(Point3D(0, 0, 0), 0)
9 self.assertEqual(0, sphere.diameter())
10
11 def test_diameter_radius_one_origin_returns_two(self):
12 sphere = Sphere(Point3D(0, 0, 0), 1)
13 self.assertEqual(2, sphere.diameter())
14
15 def test_diameter_radius_zero_arbitrary_centre_returns_zero(self):
16 sphere = Sphere(Point3D(1, 1, 1), 0)
17 self.assertEqual(0, sphere.diameter())
18
19 def test_diameter_radius_ten_arbitrary_centre_returns_twenty(self):
20 sphere = Sphere(Point3D(10, 11, 12), 10)
21 self.assertEqual(20, sphere.diameter())
We can instead make use of
subTestto consolidate these into one test method
1import unittest
2
3class SphereTest(unittest.TestCase):
4
5 # Other test methods not shown for brevity
6
7 def test_diameter_various_spheres_returns_correct_diameter(self):
8 cases = [
9 Sphere(Point3D(0, 0, 0), 0),
10 Sphere(Point3D(0, 0, 0), 1),
11 Sphere(Point3D(1, 1, 1), 0),
12 Sphere(Point3D(10, 11, 12), 10.1),
13 ]
14 expecteds = [0, 2, 0, 20.2]
15 for case, expect in zip(cases, expecteds):
16 with self.subTest(case=case, expect=expect):
17 self.assertAlmostEqual(expect, case.diameter(), 5)
In the above example, each test input and expected output were stored in lists
I used two separate lists, but there is nothing stopping you from using one list of tuples
The variable names for the lists,
casesandexpecteds, were arbitrary and by no means required
Notice the loop — there is nothing particularly important for the
subTesthere, but thezipfunction has not been seen yetThis just provides an easy way to loop over data within two lists at the same time
This whole portion could be re-written as follows
1cases = [...] 2expecteds = [...] 3for i in range(len(cases)): 4 with self.subTest(): 5 self.assertAlmostEqual(expecteds[i], cases[i].diameter(), 5)
It is possible to do multiple tests within a single test by just using a loop without the use of
subTestHowever, without
subTest, if one of the tests fail, execution of the rest of the tests would stop and I would not know which subtest failedAlso notice the use of
self.assertAlmostEqualAlmost equal is a nice way to manage floating point precision issues, and in the above example we specified the precision we care about —
5
22.3. Running Unit Tests
Depending on your programming environment, the
unittesttests may run automatically or may need to be run with a few clicksIn our case, on Colab, we will need to run the tests with a line of code
unittest.main(argv=[''], verbosity=2, exit=False)
That’s it — if you wrote all your
unittesttests on Colab, and you then run that line of code, it will run all your testsFor now, ignore the arguments provided to the
unittest.maincall — they’re just needed to make it workAfter running your tests, if everything ran correctly, you will likely see something like this as output
1test_diameter_various_spheres_returns_correct_diameter (__main__.SphereTest) ... ok
2test_distance_between_centres_various_spheres_returns_correct_distance (__main__.SphereTest) ... ok
3test_distance_between_edges_various_spheres_returns_correct_distance (__main__.SphereTest) ... ok
4test_equal_on_sphere_and_string_returns_false (__main__.SphereTest) ... ok
5test_equals_on_equal_spheres_returns_true (__main__.SphereTest) ... ok
6test_equals_on_not_equal_spheres_returns_false (__main__.SphereTest) ... ok
7test_overlaps_various_spheres_returns_correct_boolean (__main__.SphereTest) ... ok
8test_repr_arbitrary_sphere_returns_correct_string (__main__.SphereTest) ... ok
9test_sphere_centre_point_returns_correct_point3D (__main__.SphereTest) ... ok
10test_sphere_radius_returns_correct_radius (__main__.SphereTest) ... ok
11test_surface_area_various_spheres_returns_correct_surface_area (__main__.SphereTest) ... ok
12test_volume_various_spheres_returns_correct_volume (__main__.SphereTest) ... ok
13
14----------------------------------------------------------------------
15Ran 12 tests in 0.026s
16
17OK
18<unittest.main.TestProgram at 0x7f2810a68c90>
In the above example output, every test passed
However, if a test failed, we would see something like the below example
1test_diameter_various_spheres_returns_correct_diameter (__main__.SphereTest) ... ok
2test_distance_between_centres_various_spheres_returns_correct_distance (__main__.SphereTest) ...
3test_distance_between_edges_various_spheres_returns_correct_distance (__main__.SphereTest) ... ok
4test_equal_on_sphere_and_string_returns_false (__main__.SphereTest) ... ok
5test_equals_on_equal_spheres_returns_true (__main__.SphereTest) ... ok
6test_equals_on_not_equal_spheres_returns_false (__main__.SphereTest) ... ok
7test_overlaps_various_spheres_returns_correct_boolean (__main__.SphereTest) ... ok
8test_repr_arbitrary_sphere_returns_correct_string (__main__.SphereTest) ... ok
9test_sphere_centre_point_returns_correct_point3D (__main__.SphereTest) ... ok
10test_sphere_radius_returns_correct_radius (__main__.SphereTest) ... ok
11test_surface_area_various_spheres_returns_correct_surface_area (__main__.SphereTest) ... ok
12test_volume_various_spheres_returns_correct_volume (__main__.SphereTest) ... ok
13
14======================================================================
15FAIL: test_distance_between_centres_various_spheres_returns_correct_distance (__main__.SphereTest) (case=(Sphere(Point3D(0, 0, 0), 1), Sphere(Point3D(1, 1, 0), 1)), expect=1.732051)
16----------------------------------------------------------------------
17Traceback (most recent call last):
18 File "<ipython-input-17-defcdea75152>", line 60, in test_distance_between_centres_various_spheres_returns_correct_distance
19 self.assertAlmostEqual(expect, case[0].distance_between_centres(case[1]), 5)
20AssertionError: 1.732051 != 1.4142135623730951 within 5 places (0.31783743762690486 difference)
21
22----------------------------------------------------------------------
23Ran 12 tests in 0.030s
24
25FAILED (failures=1)
26<unittest.main.TestProgram at 0x7f2810a882d0>
To generate the error for demonstration purposes, I changed the
test_distance_between_centres_various_spheres_returns_correct_distancetest to be wrongYou will see that the output from the test is a lot more helpful than the simple
asserttests we used to writeIt is telling us which test failed
It is telling us which subtest failed
It tells us what was expected
It tells us what we actually got
Just because a test failed, all other tests still ran
Writing tests may feel like extra work, but demonstrating correctness is just as important as writing the code itself
22.4. For Next Topic
Check out the test folder in the GitHub repo to see the unit tests written for the course content