Granular Enforcement of Python Unit Test Coverage through Code Inspection
If you’re maintaining a medium-sized software project, you’ve probably found yourself in a situation where you’ve added a new feature or model to your Python project and then realized that you forgot to write unit tests for it. You might have code coverage tools in place, but measuring code coverage of unit tests is an imperfect science that can sometimes give a false sense of security. We can supplement code coverage tools by enforcing unit test coverage through “tests for our tests”. Python’s “everything is an object” philosophy makes it easy for us to detect when new code is added and validate whether one or more unit tests exist for it.
If you’re interested, the source code for this proof of concept can be found in the GitHub repository here.
Project Structure
Let’s start by taking a look at our project structure. Fire up the terminal and run the tree
command:
1
2
3
4
5
6
7
8
9
10
11
(venv) christopher@ubuntu-playground:~/GitHub/enforcing-unit-tests$ tree ./ -P *.py -I "*.pyc|venv/|__pycache__"
./
├── project
│ ├── __init__.py
│ └── models.py
└── tests
├── __init__.py
├── test_models_dynamic.py
└── test_models_static.py
2 directories, 5 files
The project
directory houses our Python software application, including a set of data models in project/models.py
. The contents of this file are shown below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"""Houses models that are used elsewhere in the project.
All of these models are imported into the test_models.py file for testing.
"""
NETWORK_VRFS = [
"red",
"blue",
"green",
]
IPV4_SUBNETS = [
"192.168.0.0/24",
"192.168.1.0/24",
"10.250.250.0/24",
]
We have some simple unit tests for our models defined in tests/test_models_static.py
. Here, we are importing all models from project/models.py
and testing whether they are lists of strings.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
"""Houses static unit tests for the models.py file."""
from typing import List
from project import models
def generic_list_of_strings_test(list_of_strings: List[str]) -> None:
"""Tests whether a supposed list of strings is as described."""
assert isinstance(list_of_strings, list)
assert len(list_of_strings) > 0
for item in list_of_strings:
assert isinstance(item, str)
def test_NETWORK_VRFS() -> None:
"""Ensure that the NETWORK_VRFS model is a list of strings."""
generic_list_of_strings_test(models.NETWORK_VRFS)
def test_IPV4_SUBNETS() -> None:
"""Ensure that the IPV4_SUBNETS model is a list of strings."""
generic_list_of_strings_test(models.IPV4_SUBNETS)
The Problem
If we run these unit tests with code coverage reporting enabled, we can see that they pass with 100% test coverage:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(venv) christopher@ubuntu-playground:~/GitHub/enforcing-unit-tests$ python -m pytest --cov=project tests/test_models_static.py
=========================================== test session starts ============================================
platform linux -- Python 3.10.12, pytest-7.4.1, pluggy-1.3.0
rootdir: /home/christopher/GitHub/enforcing-unit-tests
plugins: cov-4.1.0
collected 2 items
tests/test_models_static.py .. [100%]
---------- coverage: platform linux, python 3.10.12-final-0 ----------
Name Stmts Miss Cover
-----------------------------------------
project/__init__.py 0 0 100%
project/models.py 2 0 100%
-----------------------------------------
TOTAL 2 0 100%
============================================ 2 passed in 0.02s =============================================
Next, let’s add a new model, IPV6_SUBNETS
, to project/models.py
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
"""Houses models that are used elsewhere in the project.
All of these models are imported into the test_models.py file for testing.
"""
NETWORK_VRFS = [
"red",
"blue",
"green",
]
IPV4_SUBNETS = [
"192.168.0.0/24",
"192.168.1.0/24",
"10.250.250.0/24",
]
IPV6_SUBNETS = [
"2001:db8:1::/64",
"2001:db8:2::/64",
"2001:db8:3::/64",
]
If we re-run these unit tests with coverage reporting enabled, we can see that they still have 100% coverage. This is not correct, since we did not add a third unit test for the new IPV6_SUBNETS
model:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(venv) christopher@ubuntu-playground:~/GitHub/enforcing-unit-tests$ python -m pytest --cov=project tests/test_models_static.py
====================== test session starts ======================
platform linux -- Python 3.10.12, pytest-7.4.1, pluggy-1.3.0
rootdir: /home/christopher/GitHub/enforcing-unit-tests
plugins: cov-4.1.0
collected 2 items
tests/test_models_static.py .. [100%]
---------- coverage: platform linux, python 3.10.12-final-0 ----------
Name Stmts Miss Cover
-----------------------------------------
project/__init__.py 0 0 100%
project/models.py 3 0 100%
-----------------------------------------
TOTAL 3 0 100%
======================= 2 passed in 0.02s =======================
(venv) christopher@ubuntu-playground:~/GitHub/enforcing-unit-test
This is the crux of the problem - we have added new code, forgot to add unit tests for it, and our code coverage tool failed to catch the gap.
If this project is a proper software development project where multiple developers are reviewing each other’s code, it’s possible that during the code review process, a fellow developer could catch that this change did not include unit tests for the new model. However, if this project is backed by a single developer, or reviewers are suffering from code review fatigue, it’s possible for missing unit tests to slip through the cracks. And since coverage tools may not catch the coverage gap, nobody would be the wiser.
Programmatically Enforcing Unit Test Coverage
To fix this, we can enforce code coverage against our models through code inspection using the inspect
module. Let’s start building these unit tests out in a new file called tests/test_models_dynamic.py
, starting with a function called test_all_models_have_unit_tests()
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"""Houses dynamic unit tests for the models.py file."""
import inspect
import pytest
from project import models
def test_all_models_have_unit_tests() -> None:
"""Unit test that validates test coverage for models."""
model_objects = inspect.getmembers(models)
for model_object, _ in model_objects:
test_name = f"test_{model_object}"
for global_object in globals():
if test_name in global_object:
break
else:
pytest.fail(f"Missing unit test for model {model_object}.")
This function uses the inspect.get_members()
function to pull a list of all objects in the project.models
module. Then, it iterates through each object and checks whether there is a unit test defined in the current tests/test_models_dynamic.py
file that corresponds with the object. If there is not, it fails the test.
If we run our unit tests now, we can see that this unit test fails because we have not defined a unit test for the IPV4_SUBNETS
model.
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
(venv) christopher@ubuntu-playground:~/GitHub/enforcing-unit-tests$ python -m pytest tests/test_models_dynamic.py
================================== test session starts ==================================
platform linux -- Python 3.10.12, pytest-7.4.1, pluggy-1.3.0
rootdir: /home/christopher/GitHub/enforcing-unit-tests
plugins: cov-4.1.0
collected 1 item
tests/test_models_dynamic.py F [100%]
======================================= FAILURES ========================================
____________________________ test_all_models_have_unit_tests ____________________________
def test_all_models_have_unit_tests() -> None:
"""Unit test that validates test coverage for models."""
model_objects = inspect.getmembers(models)
for model_object, _ in model_objects:
test_name = f"test_{model_object}"
for global_object in globals():
if test_name in global_object:
break
else:
> pytest.fail(f"Missing unit test for model {model_object}.")
E Failed: Missing unit test for model IPV4_SUBNETS.
tests/test_models_dynamic.py:29: Failed
================================ short test summary info ================================
FAILED tests/test_models_dynamic.py::test_all_models_have_unit_tests - Failed: Missing unit test for model IPV4_SUBNETS.
=================================== 1 failed in 0.03s ===================================
Let’s add our unit tests for the IPV4_SUBNETS
and NETWORK_VRFS
models:
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
"""Houses dynamic unit tests for the models.py file."""
import inspect
from typing import List
import pytest
from project import models
def test_all_models_have_unit_tests() -> None:
"""Unit test that validates test coverage for models."""
model_objects = inspect.getmembers(models)
for model_object, _ in model_objects:
test_name = f"test_{model_object}"
for global_object in globals():
if test_name in global_object:
break
else:
pytest.fail(f"Missing unit test for model {model_object}.")
def generic_list_of_strings_test(list_of_strings: List[str]) -> None:
"""Tests whether a supposed list of strings is as described."""
assert isinstance(list_of_strings, list)
assert len(list_of_strings) > 0
for item in list_of_strings:
assert isinstance(item, str)
def test_NETWORK_VRFS() -> None:
"""Ensure that the NETWORK_VRFS model is a list of strings."""
generic_list_of_strings_test(models.NETWORK_VRFS)
def test_IPV4_SUBNETS() -> None:
"""Ensure that the IPV4_SUBNETS model is a list of strings."""
generic_list_of_strings_test(models.IPV4_SUBNETS)
Let’s re-run our unit tests - this time, the unit tests fail because we have not defined a unit test for the IPV6_SUBNETS
model. However, the two unit tests we’ve defined for the NETWORK_VRFS
and IPV4_SUBNETS
models do pass.
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
(venv) christopher@ubuntu-playground:~/GitHub/enforcing-unit-tests$ python -m pytest tests/test_models_dynamic.py
================================== test session starts ==================================
platform linux -- Python 3.10.12, pytest-7.4.1, pluggy-1.3.0
rootdir: /home/christopher/GitHub/enforcing-unit-tests
plugins: cov-4.1.0
collected 3 items
tests/test_models_dynamic.py F.. [100%]
======================================= FAILURES ========================================
____________________________ test_all_models_have_unit_tests ____________________________
def test_all_models_have_unit_tests() -> None:
"""Unit test that validates test coverage for models."""
model_objects = inspect.getmembers(models)
for model_object, _ in model_objects:
test_name = f"test_{model_object}"
for global_object in globals():
if test_name in global_object:
break
else:
> pytest.fail(f"Missing unit test for model {model_object}.")
E Failed: Missing unit test for model IPV6_SUBNETS.
tests/test_models_dynamic.py:29: Failed
================================ short test summary info ================================
FAILED tests/test_models_dynamic.py::test_all_models_have_unit_tests - Failed: Missing unit test for model IPV6_SUBNETS.
============================== 1 failed, 2 passed in 0.03s ==============================
Let’s add a third unit test for the IPV6_SUBNETS
model:
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
"""Houses dynamic unit tests for the models.py file."""
import inspect
from typing import List
import pytest
from project import models
def test_all_models_have_unit_tests() -> None:
"""Unit test that validates test coverage for models."""
model_objects = inspect.getmembers(models)
for model_object, _ in model_objects:
test_name = f"test_{model_object}"
for global_object in globals():
if test_name in global_object:
break
else:
pytest.fail(f"Missing unit test for model {model_object}.")
def generic_list_of_strings_test(list_of_strings: List[str]) -> None:
"""Tests whether a supposed list of strings is as described."""
assert isinstance(list_of_strings, list)
assert len(list_of_strings) > 0
for item in list_of_strings:
assert isinstance(item, str)
def test_NETWORK_VRFS() -> None:
"""Ensure that the NETWORK_VRFS model is a list of strings."""
generic_list_of_strings_test(models.NETWORK_VRFS)
def test_IPV4_SUBNETS() -> None:
"""Ensure that the IPV4_SUBNETS model is a list of strings."""
generic_list_of_strings_test(models.IPV4_SUBNETS)
def test_IPV6_SUBNETS() -> None:
"""Ensure that the IPV6_SUBNETS model is a list of strings."""
generic_list_of_strings_test(models.IPV6_SUBNETS)
Let’s re-run our unit tests. This time, we see the test_all_models_have_unit_tests()
unit test fails because of the __builtins__
object imported from the project.models
module, which most Python modules will have defined “under the hood”. For our purposes, we don’t care about this object, so we need to refactor the test_all_models_have_unit_tests()
function to do two things:
- Filter the imported objects through a predicate function. The
projects.models
module may contain helper functions or classes that are unrelated to the models we want to test. To work around this, we will define a predicate function calledis_builtin_data_structure_or_type()
that will returnTrue
if the object is a built-in data structure or type, andFalse
otherwise. Then, we will pass this predicate function to theinspect.getmembers()
function as the second argument. - Within the
test_all_models_have_unit_tests()
function, we will check to see if objects returned by theinspect.getmembers()
function are a “dunder” method or attribute starting with__
. If they are, we will ignore them.
First, the new is_builtin_data_structure_or_type()
predicate function is shown below.
1
2
3
def is_builtin_data_structure_or_type(obj: object) -> bool:
"""Return True if obj is a builtin data structure or type."""
return isinstance(obj, (list, dict, set, tuple, bool, bytes, float, int, str))
Next, the refactored test_all_models_have_unit_tests()
function is shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
def test_all_models_have_unit_tests() -> None:
"""Unit test that validates test coverage for models."""
model_objects = inspect.getmembers(models, is_builtin_data_structure_or_type)
for model_object, _ in model_objects:
if model_object.startswith("__"):
print(f"Ignoring magic method or attribute {model_object}")
continue
test_name = f"test_{model_object}"
for global_object in globals():
if test_name in global_object:
break
else:
pytest.fail(f"Missing unit test for model {model_object}.")
Our full tests/test_models_dynamic.py
file is shown below.
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
50
51
"""Houses dynamic unit tests for the models.py file."""
import inspect
from typing import List
import pytest
from project import models
def is_builtin_data_structure_or_type(obj: object) -> bool:
"""Return True if obj is a builtin data structure or type."""
return isinstance(obj, (list, dict, set, tuple, bool, bytes, float, int, str))
def test_all_models_have_unit_tests() -> None:
"""Unit test that validates test coverage for models."""
model_objects = inspect.getmembers(models, is_builtin_data_structure_or_type)
for model_object, _ in model_objects:
if model_object.startswith("__"):
print(f"Ignoring magic method or attribute {model_object}")
continue
test_name = f"test_{model_object}"
for global_object in globals():
if test_name in global_object:
break
else:
pytest.fail(f"Missing unit test for model {model_object}.")
def generic_list_of_strings_test(list_of_strings: List[str]) -> None:
"""Tests whether a supposed list of strings is as described."""
assert isinstance(list_of_strings, list)
assert len(list_of_strings) > 0
for item in list_of_strings:
assert isinstance(item, str)
def test_NETWORK_VRFS() -> None:
"""Ensure that the NETWORK_VRFS model is a list of strings."""
generic_list_of_strings_test(models.NETWORK_VRFS)
def test_IPV4_SUBNETS() -> None:
"""Ensure that the IPV4_SUBNETS model is a list of strings."""
generic_list_of_strings_test(models.IPV4_SUBNETS)
def test_IPV6_SUBNETS() -> None:
"""Ensure that the IPV6_SUBNETS model is a list of strings."""
generic_list_of_strings_test(models.IPV6_SUBNETS)
Let’s re-run our unit tests and confirm that all of our unit tests are now passing:
1
2
3
4
5
6
7
8
9
10
(venv) christopher@ubuntu-playground:~/GitHub/enforcing-unit-tests$ python -m pytest tests/test_models_dynamic.py
================================== test session starts ==================================
platform linux -- Python 3.10.12, pytest-7.4.1, pluggy-1.3.0
rootdir: /home/christopher/GitHub/enforcing-unit-tests
plugins: cov-4.1.0
collected 4 items
tests/test_models_dynamic.py .... [100%]
=================================== 4 passed in 0.01s ===================================
Conclusion
Now, we have a way to enforce that all of our models have unit tests defined for them. If we add a new model to our project/models.py
file, we will be notified that we need to add a unit test for it. This is a great way to supplement code coverage tools and ensure that we have unit tests for all of our models. Furthermore, we can standardize the names of unit tests for our models, which makes it easier for developers to understand what a unit test does. This is a purposefully simple example, but applies well to classes, functions, and other objects that we want to ensure have unit tests defined for them.