Forcing unittest to function     

            Brandon Rhodes
            April 20, 2024
             Pre-recorded

         Python + DS fwdays'24
            Kyiv — Ukraine


Let’s tackle an example problem.

When I check out a small package that
has zero runtime dependencies,

I like to be able to run its tests
with zero testing dependencies.

For a big project, with dependencies:
  Heavyweight setup
  uv venv
  uv pip install -r requirements.txt
  3rd party test framework

Zero-dependency project:
  Ideally, zero install

 Python’s old motto
‘batteries included’

The Python Standard Library does include
 a testing framework, named  `unittest`

It was originally underpowered.

So third-party testing
frameworks became popular.

But `unittest` started to
quietly improve in Python 3.2.

$ python -m unittest --help
...
  -f, --failfast  Stop on first failure
...

$ python -m unittest --help
...
  -b, --buffer    Buffer stdout/stderr
...

Test discovery!

package/__init__.py
# A simple package.

package/tests.py
from unittest import TestCase

class Tests(TestCase):
    def test_one(self):
        self.assertEqual(1+1, 2)

    def test_two(self):
        self.assertEqual(2*2, 4)

$ python -m unittest package
------------------------------------------ Ran 0 tests in 0.000s NO TESTS RAN

  `unittest` forced you to list
every module on the command line.

$ python -m unittest -v package.tests
test_one (package.tests.Tests.test_one) ... ok test_two (package.tests.Tests.test_two) ... ok ------------------------------------------ Ran 2 tests in 0.000s OK

Test discovery fixes this!

$ python -m unittest discover -v
test_one (package.tests.Tests.test_one) ... ok test_two (package.tests.Tests.test_two) ... ok ------------------------------------------ Ran 2 tests in 0.000s OK

   Test discovery made
`unittest` nearly useable!

And it’s pretty fast.

`unittest` runs our tiny `test.py` pretty quickly:

$ time python -m unittest discover project
...
0.104 total

Compare that to the third-party `pytest` framework:

$ time pytest project
...
0.808 total

‘all unit tests combined
 should run in 1 second’

But `unittest` has a big remaining problem

It makes you write tests as ugly methods,
rather than as beautiful functions.

Which means we have to:

• Import a class we don’t want
• Build a subclass we don’t like
• Lose 4 columns to useless indentation

package/tests.py
from unittest import TestCase

class Tests(TestCase):
    def test_one(self):
        self.assertEqual(1+1, 2)

    def test_two(self):
        self.assertEqual(2*2, 4)

Q: Could my own package add this missing
   feature to `unittest`? And let me start
   using `unittest` again?

A: This is Python! We can do anything!

Q: Can I add test function support
   in a way that is simple and elegant
   enough that I’m willing to use it?

A: Let’s try!

Q: How does `unittest` find tests?

A: Through Python’s powers
   of introspection.

Most Python programmers know how to
access the items in a `list` or `dict`.

But how do we loop over the names in
a namespace — a module or class?

Two builtins:

1. `dir()`
2. `getattr()`

package/tests.py
from unittest import TestCase

class Tests(TestCase):
    def test_one(self):
        self.assertEqual(1+1, 2)

    def test_two(self):
        self.assertEqual(2*2, 4)

example.py
from package import tests

names = dir(tests)
print(names)

$ python example.py
['TestCase', 'Tests', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__']

After learning a name from `dir()`,
we can get its object with `getattr()`.

example.py
from package import tests

obj = getattr(tests, 'TestCase')
print(obj)

$ python example.py
<class 'unittest.case.TestCase'>

`dir()` and `getattr()` are an
elegant part of Python’s design.

Some of the older dynamic
languages offered only `eval()`

eval('tests.' + name)

• Slow
• Overpowered
• Dangerous

So, instead of forcing you to call `eval()`,
Python offers a clean, limited, dynamic
operator for each namespace operation.

'a.b'       ==   getattr(a, 'b')
'a.b = x'   ==   setattr(a, 'b', x)
'del a.b'   ==   delattr(a, 'b')

What steps does `unittest` follow
when looking for tests in a module?

  ‘The `unittest` contract’

1. Collect `TestCase` classes.
2. Call `load_tests()`.

# `unittest/loader.py` to find classes:

for name in dir(module):
    obj = getattr(module, name)
    if issubclass(obj, case.TestCase): ...

# `unittest/loader.py` to find methods:

dir(testCaseClass)

if attrname.startswith('test'):
    ...
    getattr(testCaseClass, attrname)

Then, things get weird: `unittest`
instantiates your test class n times,
once for each test method that it found.

This is easier to show than describe.
So here’s our test module again:

package/tests.py
from unittest import TestCase

class Tests(TestCase):
    def test_one(self):
        self.assertEqual(1+1, 2)

    def test_two(self):
        self.assertEqual(2*2, 4)

And here are exactly the steps
that `unittest` will perform:

example.py
from unittest import TestSuite, TextTestRunner
from package.tests import Tests

cases = [Tests('test_one'), Tests('test_two')]
suite = TestSuite(cases)
TextTestRunner().run(suite)

$ python example.py
.. ------------------------------------------ Ran 2 tests in 0.000s OK

So that’s how `unittest` normally loads
tests: from `TestCase` methods.

But there’s also an escape hatch!

After building the test suite,
`unittest` does one last check:

# From `unittest/loader.py`:

getattr(module, 'load_tests', None)

If `load_tests()` is defined,
then `unittest` calls it.

package/tests.py
from unittest import TestCase

class Tests(TestCase):
    def test_one(self): self.assertEqual(1+1, 2)
    def test_two(self): self.assertEqual(2*2, 4)

def load_tests(loader, suite, pattern):
    # ...add or remove tests from `suite`...
    return suite

Here, for example, is how
to add a test to the suite:

package/tests.py
from unittest import TestCase

class Tests(TestCase):
    def test_one(self): self.assertEqual(1+1, 2)
    def test_two(self): self.assertEqual(2*2, 4)
    def division(self): self.assertEqual(10/5, 2)

$ python -m unittest discover -v
test_one (package.tests.Tests.test_one) ... ok test_two (package.tests.Tests.test_two) ... ok ------------------------------------------ Ran 2 tests in 0.000s OK

package/tests.py
from unittest import TestCase

class Tests(TestCase):
    def test_one(self): self.assertEqual(1+1, 2)
    def test_two(self): self.assertEqual(2*2, 4)
    def division(self): self.assertEqual(10/5, 2)

def load_tests(loader, suite, pattern):
    suite.addTest(Tests('division'))
    return suite

$ python -m unittest discover -v
test_one (package.tests.Tests.test_one) ... ok test_two (package.tests.Tests.test_two) ... ok division (package.tests.Tests.division) ... ok ------------------------------------------ Ran 3 tests in 0.000s OK

The Standard Library `doctest` documentation
recommends this exact mechanism for adding
doctests to your package’s test suite.

# Example from `doctest` documentation:

import doctest
import my_module_with_doctests

def load_tests(loader, suite, ignore):
    suite.addTests(doctest.DocTestSuite(
        my_module_with_doctests,
    ))
    return tests

So, if we want `unittest` to load our test
functions, we have two chances to do it!

  ‘The `unittest` contract’

1. Collect `TestCase` classes.
2. Call `load_tests()`.

       Design Decision!

Which mechanism should we use
for enrolling test functions?

Let’s code both solutions and use
that experience to help us decide!

  [ ] `TestCase` class
↗
↘
  [ ] `load_tests()`

package/tests.py
def test_one():
    assert 1+1 == 2

def test_two():
    assert 2*2 == 4

$ python -m unittest discover -v
------------------------------------------ Ran 0 tests in 0.000s NO TESTS RAN

Q: How should we get ourselves started?

A: Let’s do API-driven development!

Let’s write a call to an API we wish existed
and then see if we can make it exist!

package/tests.py
from .utils import find_tests

def test_one():
    assert 1+1 == 2

def test_two():
    assert 2*2 == 4

Tests = find_tests()

Next, let’s try implementing this API!

package/utils.py
def find_tests():
    ...
    # What can we put here to build
    # a `Tests` class that will convince
    # `unittest` to run our functions?
    ...

An essential technique:
      hard-coding!

I’m going to write exactly the code
that’s needed for this one specific case
so we reach a working proof-of-concept
as quickly as possible.

package/utils.py
from unittest import TestCase

def find_tests():
    from package import tests
    class Tests(TestCase):
        def test_one(self):
            tests.test_one()
    return Tests

$ python -m unittest discover -v
test_one (package.utils.find_tests.<locals>.Tests.test_one) ... ok ------------------------------------------ Ran 1 test in 0.000s OK



That’s why I love hard-coding!

Now that the code works,
I can iterate quickly towards
a full solution, enjoying working
code at every step.

Let’s iterate!

[ ] How it finds the module.
[ ] How it finds the tests.
[ ] How it adds the tests to the class.

package/utils.py
from unittest import TestCase

def find_tests():
    from package import tests
    class Tests(TestCase):
        def test_one(self):
            tests.test_one()
    return Tests

package/utils.py
from unittest import TestCase

def find_tests():
    from package import tests
    class Tests(TestCase): pass
    f = tests.test_one
    Tests.test_one = f
    return Tests

$ python -m unittest discover -v
test_one (package.utils.find_tests.<locals>.Tests.test_one) ... ERROR ========================================== ERROR: test_one (package.utils.find_tests.<locals>.Tests.test_one) ------------------------------------------ TypeError: test_one() takes 0 positional arguments but 1 was given ------------------------------------------ Ran 1 test in 0.000s FAILED (errors=1)

What did we do wrong?

Always commit working code to
version control, so you know exactly
what changed when you break something!

Let’s back up to our previous working code:

package/utils.py
from unittest import TestCase

def find_tests():
    from package import tests
    class Tests(TestCase):
        def test_one(self):
            tests.test_one()
    return Tests

So we need a wrapper.

package/utils.py
from unittest import TestCase

def wrap(function):
    def method(self):
         function()
    return method

def find_tests():
    from package import tests
    class Tests(TestCase): pass
    f = tests.test_one
    Tests.test_one = wrap(f)
    return Tests

$ python -m unittest discover -v
test_one (package.utils.find_tests.<locals>.Tests.test_one) ... ok ------------------------------------------ Ran 1 test in 0.000s OK



[ ] How it finds the module.
[ ] How it finds the tests.
[✔] How it adds the tests to the class.

It turns out that `wrap()`
should look very familiar.

It already a builtin!

   `staticmethod`

package/utils.py
from unittest import TestCase

# def wrap(function):
#     def method(self):
#          function()
#     return method

def find_tests():
    from package import tests
    class Tests(TestCase): pass
    f = tests.test_one
    Tests.test_one = staticmethod(f)
    return Tests

$ python -m unittest discover -v
test_one (package.utils.find_tests.<locals>.Tests.test_one) ... ok ------------------------------------------ Ran 1 test in 0.000s OK

[ ] How it finds the module.
[ ] How it finds the tests.
[✔] How it adds the tests to the class.

package/utils.py
from unittest import TestCase

# def wrap(function):
#     def method(self):
#          function()
#     return method

def find_tests():
    from package import tests
    class Tests(TestCase): pass
    f = tests.test_one
    Tests.test_one = staticmethod(f)
    return Tests

package/utils.py
from unittest import TestCase

def find_tests():
    from package import tests
    class Tests(TestCase): pass
    for name in dir(tests):
        if name.startswith('test'):
            f = getattr(tests, name)
            setattr(Tests, name, staticmethod(f))
    return Tests

$ python -m unittest discover -v
test_one (package.utils.find_tests.<locals>.Tests.test_one) ... ok test_two (package.utils.find_tests.<locals>.Tests.test_two) ... ok ------------------------------------------ Ran 2 tests in 0.000s OK

[ ] How it finds the module.
[✔] How it finds the tests.
[✔] How it adds the tests to the class.

Well — how do you find the module?

Q: Where do modules live?

A: They live in the `sys.modules` dictionary.

If we knew the module’s name, we
could retrieve it from `sys.modules`.

package/utils.py
import sys
from unittest import TestCase

def find_tests():
    module_name = 'package.tests'
    module = sys.modules[module_name]
    class Tests(TestCase): pass
    for name in dir(module):
        if name.startswith('test'):
            f = getattr(module, name)
            setattr(Tests, name, staticmethod(f))
    return Tests

$ python -m unittest discover -v
test_one (package.utils.find_tests.<locals>.Tests.test_one) ... ok test_two (package.utils.find_tests.<locals>.Tests.test_two) ... ok ------------------------------------------ Ran 2 tests in 0.000s OK

So now we face a simpler problem:

Q: How do I get the module’s name?

A: Adjust the API so it’s passed in!

package/tests.py
from .utils import find_tests

def test_one():
    assert 1+1 == 2

def test_two():
    assert 2*2 == 4

Tests = find_tests()

package/tests.py
from .utils import find_tests

def test_one():
    assert 1+1 == 2

def test_two():
    assert 2*2 == 4

Tests = find_tests(__name__)

package/utils.py
import sys
from unittest import TestCase

def find_tests(module_name):
    module = sys.modules[module_name]
    class Tests(TestCase): pass
    for name in dir(module):
        if name.startswith('test'):
            f = getattr(module, name)
            setattr(Tests, name, staticmethod(f))
    return Tests

$ python -m unittest discover -v
test_one (package.utils.find_tests.<locals>.Tests.test_one) ... ok test_two (package.utils.find_tests.<locals>.Tests.test_two) ... ok ------------------------------------------ Ran 2 tests in 0.000s OK

[✔] How it finds the module.
[✔] How it finds the tests.
[✔] How it adds the tests to the class.

  [✔] `TestCase` class: 10 lines of code + 1 per module
↗
↘
  [ ] `load_tests()`

Tests = find_tests(__name__)

Let’s now turn to the
other big alternative.

Once again, API-Driven Design!

package/tests.py
def test_one():
    assert 1+1 == 2

def test_two():
    assert 2*2 == 4

def load_tests(loader, suite, pattern):
    ...
    return suite

package/tests.py
def test_one():
    assert 1+1 == 2

def test_two():
    assert 2*2 == 4

def load_tests(loader, suite, pattern):
    suite.addTests(...)
    return suite

package/tests.py
from .utils import find_tests

def test_one():
    assert 1+1 == 2

def test_two():
    assert 2*2 == 4

def load_tests(loader, suite, pattern):
    suite.addTests(find_tests(...))
    return suite

package/tests.py
from .utils import find_tests

def test_one():
    assert 1+1 == 2

def test_two():
    assert 2*2 == 4

def load_tests(loader, suite, pattern):
    suite.addTests(find_tests(__name__))
    return suite

Time to write a new `utils.py` module!

Let’s again start by hard-coding the test,
to quickly reach a working solution!

package/utils.py
import sys
from unittest import TestCase

def find_tests(package_name):
    module = sys.modules[package_name]
    class Tests(TestCase):
        def test_one(self):
            module.test_one()
    return [Tests('test_one')]

$ python -m unittest discover -v
test_one (package.utils.find_tests.<locals>.Tests.test_one) ... ok ------------------------------------------ Ran 1 test in 0.000s OK



  [✔] `TestCase` class: 10 lines of code + 1 per module
↗
↘
  […] `load_tests()`

Now, we enjoy the luxury of
iterating on a working solution.

Let’s make the class fully generic.

package/utils.py
import sys
from unittest import TestCase

def find_tests(package_name):
    module = sys.modules[package_name]
    class Test(TestCase):
        def test_one(self):
            f()
    f = module.test_one
    return [Test('test_one')]

$ python -m unittest discover -v
test_one (package.utils.find_tests.<locals>.Test.test_one) ... ok ------------------------------------------ Ran 1 test in 0.000s OK

package/utils.py
import sys
from unittest import TestCase

def find_tests(package_name):
    module = sys.modules[package_name]
    class Test(TestCase):
        def test(self):
            f()
    f = module.test_one
    return [Test('test')]

$ python -m unittest discover -v
test (package.utils.find_tests.<locals>.Test.test) ... ok ------------------------------------------ Ran 1 test in 0.000s OK

package/utils.py
import sys
from unittest import TestCase

def find_tests(package_name):
    module = sys.modules[package_name]
    class Test(TestCase):
        def __init__(self):
            super().__init__('test')
        def test(self):
            f()
    f = module.test_one
    return [Test()]

$ python -m unittest discover -v
test (package.utils.find_tests.<locals>.Test.test) ... ok ------------------------------------------ Ran 1 test in 0.000s OK

package/utils.py
import sys
from unittest import TestCase

def find_tests(package_name):
    module = sys.modules[package_name]
    class Test(TestCase):
        def __init__(self, f):
            super().__init__('test')
            self.f = f
        def test(self):
            self.f()
    return [Test(module.test_one)]

$ python -m unittest discover -v
test (package.utils.find_tests.<locals>.Test.test) ... ok ------------------------------------------ Ran 1 test in 0.000s OK

package/utils.py
import sys
from unittest import TestCase

class Test(TestCase):
    def __init__(self, f):
        super().__init__('test')
        self.f = f
    def test(self):
        self.f()

def find_tests(package_name):
    module = sys.modules[package_name]
    return [Test(module.test_one)]

$ python -m unittest discover -v
test (package.utils.Test.test) ... ok ------------------------------------------ Ran 1 test in 0.000s OK

Then I noticed something.

This `Tests` class?

It already exists!

package/utils.py
import sys
from unittest import FunctionTestCase

# class Tests(TestCase):
#     def __init__(self, f):
#         super().__init__('test')
#         self.f = f
#     def test(self):
#         self.f()

def find_tests(package_name):
    module = sys.modules[package_name]
    return [FunctionTestCase(module.test_one)]

$ python -m unittest discover -v
unittest.case.FunctionTestCase (test_one) ... ok ------------------------------------------ Ran 1 test in 0.000s OK

package/utils.py
import sys
from unittest import FunctionTestCase

def find_tests(package_name):
    module = sys.modules[package_name]
    return [FunctionTestCase(module.test_one)]

$ python -m unittest discover -v
unittest.case.FunctionTestCase (test_one) ... ok ------------------------------------------ Ran 1 test in 0.000s OK

package/utils.py
import sys
from unittest import FunctionTestCase

def find_tests(package_name):
    module = sys.modules[package_name]
    tests = []
    for name in dir(module):
        if name.startswith('test'):
            f = getattr(module, name)
            tests.append(FunctionTestCase(f))
    return tests

$ python -m unittest discover -v
unittest.case.FunctionTestCase (test_one) ... ok unittest.case.FunctionTestCase (test_two) ... ok ------------------------------------------ Ran 2 tests in 0.000s OK



  [✔] `TestCase` class: 11 lines of code + 1 per module
↗
↘
  [✔] `load_tests()`: 10 lines of code + 3 per module

def load_tests(loader, suite, pattern):
    suite.addTests(find_tests(__name__))
    return suite

Verdict? I choose `load_tests()`!

Explicit rather than implicit.
• Uses a standard `unittest` feature.
• Creates only instances at runtime, not classes.
• Composes well with the `doctest` instructions.

<aside>

I’m sure you have a question:
 ‘But what about asserts?!’

Because you noticed me quietly pivot
from `assertEqual()` to plain `assert`.

class Tests(TestCase):
    def test_one(self):
        self.assertEqual(1+1, 2)

# vs

def test_one():
    assert 1+1 == 2

How do we get pretty asserts back?

If you don’t have `self`, how
do we call `self.assertEqual()`?

The Prebound Method Pattern

project/asserts.py
from unittest import TestCase

_c = TestCase()
assertEqual = _c.assertEqual
assertNotEqual = _c.assertNotEqual
assertIsNone = _c.assertIsNone
...

from asserts import assertEqual

def test_one():
    assertEqual(1+1, 2)

def test_two():
    assertEqual(2*2, 4)

</aside>

Takeaways

• How introspection works.
• How to use introspection in developer tools.
• How hard-coded solutions let us start fast.
• How prototypes help us decide between approaches.
• And, of course, you now know how to plug test
  functions into `unittest` if you ever want to.

  Thank you very much!
   I’m Brandon Rhodes.

I hope you enjoy the rest
 of Python+DS fwdays’24.