Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~30 min· topic 5 of 8

Topic 11.5

unittest and doctest

In one line

unittest is the testing framework built into Python: tests are methods of a TestCase class with setUp and assertEqual-style checks. doctest runs the examples written inside docstrings, so documentation that shows >>> sessions stays true.

Think of it like this

Two ways to check a recipe book. unittest is a formal exam: a printed paper with a fixed layout, a marker, and a score sheet. doctest is a textbook whose worked examples check themselves: every time you build the book, a robot cooks each example and shouts if the picture in the book no longer matches the dish. Both ship with Python, so they need no install.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

`unittest`
The testing framework in Python's standard library.
`TestCase`
The base class for unittest tests. Each test_* method is one test.
`setUp` / `tearDown`
Methods that run before and after every test method.
Assertion method
A self.assertXxx method that checks something and fails the test with a clear message.
Test loader / runner / result
The parts that find tests, run them, and record what passed or failed.
`doctest`
A module that runs the examples written inside docstrings.
Docstring
A string right under a def or class that documents it (Topic 5.9).
Directive
A special comment such as +ELLIPSIS that changes how a doctest compares output.
Legacy suite
An older, existing set of tests written in another style than the one you now prefer.

Step by step

01A TestCase class

The class below tests a small Wallet. The class name and the method names start with the word test so the loader finds them. setUp gives each test a new wallet holding 100, so spending in one test cannot change the next.

test_wallet.pywhole filepython
import unittest


class Wallet:
    def __init__(self, balance=0):
        self.balance = balance

    def spend(self, amount):
        if amount > self.balance:
            raise ValueError("not enough money")
        self.balance -= amount


class WalletTests(unittest.TestCase):
    def setUp(self):
        self.wallet = Wallet(100)

    def test_spend_reduces_balance(self):
        self.wallet.spend(30)
        self.assertEqual(self.wallet.balance, 70)

    def test_cannot_overspend(self):
        with self.assertRaises(ValueError):
            self.wallet.spend(500)

    def test_wrong_on_purpose(self):
        self.wallet.spend(10)
        self.assertEqual(self.wallet.balance, 100)


if __name__ == "__main__":
    unittest.main()

02Run it and read the report

-v prints one line per test. A failing test gets a section with its name, the traceback and the assertion message 90 != 100. The last lines say how many tests ran and the verdict. The paths and the timing differ on your machine (the traceback path is trimmed here).

terminal
$ python -m unittest -v test_wallet
── expected output ──
test_cannot_overspend (test_wallet.WalletTests.test_cannot_overspend) ... ok
test_spend_reduces_balance (test_wallet.WalletTests.test_spend_reduces_balance) ... ok
test_wrong_on_purpose (test_wallet.WalletTests.test_wrong_on_purpose) ... FAIL
 
======================================================================
FAIL: test_wrong_on_purpose (test_wallet.WalletTests.test_wrong_on_purpose)
----------------------------------------------------------------------
Traceback (most recent call last):
File ".../test_wallet.py", line 28, in test_wrong_on_purpose
self.assertEqual(self.wallet.balance, 100)
AssertionError: 90 != 100
 
----------------------------------------------------------------------
Ran 3 tests in 0.001s
 
FAILED (failures=1)

03The order of setUp and tearDown

For every test method: a new instance of the class is created, setUp runs, the test runs, tearDown runs. setUpClass and tearDownClass wrap the whole class once. If setUp raises an error, the test is reported as an error and tearDown is skipped, so keep setUp simple.

The order of setUp and tearDowndiagram
Rendering diagram…

04Run unittest from your own code

The runnable example builds a suite, runs it with a TextTestRunner that writes to a hidden stream, then reads the result object: testsRun, failures, errors, wasSuccessful(). This is how tools and notebooks drive unittest, and it avoids the timing line that changes between runs.

A failure means an assertion was false. An error means the test crashed with another exception (a bug in the code or the test). They are counted apart.

05doctest: examples that check themselves

Write the example in the docstring exactly as it would look in the interactive shell (Topic 0.5). doctest finds every >>>, runs it, and compares what was printed. A module's doctests run with doctest.testmod(module).

Here the first two examples are real documentation: a reader sees how to call double, and the tests keep the docs honest.

mathbits.pywhole filepython
def double(n):
    """Return n times two.

    >>> double(4)
    8
    >>> double(-1)
    -2
    """
    return n * 2
terminal
$ python -m doctest mathbits.py -v
── expected output ──
Trying:
double(4)
Expecting:
8
ok
Trying:
double(-1)
Expecting:
-2
ok
1 item had no tests:
mathbits
1 item passed all tests:
2 tests in mathbits.double
2 tests in 2 items.
2 passed.
Test passed.

06When a doctest fails

The report shows the file and line, the example, what was expected and what was actually printed. In the second runnable doctest example, the docstring says 'OK' but the function returns 'OK!'. The output also tells you how many examples were tried.

07Directives for text that changes

Output that contains memory addresses, long lines or empty lines needs help. +ELLIPSIS makes ... a wildcard, +NORMALIZE_WHITESPACE ignores spacing, and <BLANKLINE> marks an empty line. The last runnable example uses all three. If you need many of these, a regular test is probably clearer than a doctest.

08Reading and adopting a legacy suite

When you join a project with unittest tests, you do not have to rewrite them. Run them with pytest and you get better failure output and -k, -x, --lf for free. Write new tests in the pytest style beside them. When you touch an old test file anyway, convert it gradually.

Translation guide: self.assertEqual(a, b) is assert a == b. self.assertTrue(x) is assert x. with self.assertRaises(E): is with pytest.raises(E):. setUp becomes a fixture. self.assertAlmostEqual(a, b) is assert a == pytest.approx(b).

Try it yourself

  1. 1

    Fix the failing test

    In the first example, change the expected value in test_wrong_on_purpose to 90 and run. Check that tests run: 4 and was successful: True.

  2. 2

    Add a setUp-dependent test

    Add test_spend_everything that spends 100 and checks the balance is 0. Then add a second test that spends 100 as well. Both pass, because setUp gives each its own wallet. Predict what would happen if the wallet were created once outside the class.

  3. 3

    Break a doctest

    In the passing doctest example change -2 to -3. Run it and read the report: the expected text, the text it got, and the line number.

Code & diagrams

Run a TestCase and read the result object New tab

One test is wrong on purpose (a wallet that spent 10 cannot still have 100). Fix the expected value and run again.

Sign in to run this example in your browser.

Expected output

tests run: 4
failures: 1
errors: 0
FAILED: __main__.WalletTests.test_wrong_on_purpose
   AssertionError: 90 != 100
was successful: False
doctest: examples in docstrings that pass New tab

doctest looks inside a module, so the code is written to mathbits.py first. `verbose=True` shows every example it tried.

Sign in to run this example in your browser.

Expected output

Trying:
    double(4)
Expecting:
    8
ok
Trying:
    double(-1)
Expecting:
    -2
ok
Trying:
    shout("hi")
Expecting:
    'HI!'
ok
Trying:
    shout("ok")
Expecting:
    'OK!'
ok
1 item had no tests:
    mathbits
2 items passed all tests:
   2 tests in mathbits.double
   2 tests in mathbits.shout
4 tests in 3 items.
4 passed.
Test passed.
TestResults(failed=0, attempted=4)
doctest: reading a failure report New tab

The second example in `shout` expects 'OK' but the function returns 'OK!'. The code hides the temporary folder name so the report looks the same on every computer.

Sign in to run this example in your browser.

Expected output

**********************************************************************
File "mathbits.py", line 18, in mathbits.shout
Failed example:
    shout("ok")
Expected:
    'OK'
Got:
    'OK!'
**********************************************************************
1 item had failures:
   1 of   2 in mathbits.shout
***Test Failed*** 1 failure.
TestResults(failed=1, attempted=4)
doctest directives: ELLIPSIS, NORMALIZE_WHITESPACE, BLANKLINE New tab

Remove `# doctest: +ELLIPSIS` from the first example and run again: the changing memory address makes it fail.

Sign in to run this example in your browser.

Expected output

TestResults(failed=0, attempted=3)
How the three styles relatediagram
Rendering diagram…

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

A test method without the test prefix

Rename test_cannot_overspend to cannot_overspend and run the suite.

main.pywhole filepython
import io
import unittest


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

    def cannot_overspend(self):          # no test prefix: never collected
        self.assertEqual(1 + 1, 3)


suite = unittest.defaultTestLoader.loadTestsFromTestCase(Demo)
result = unittest.TextTestRunner(stream=io.StringIO()).run(suite)
print("tests run:", result.testsRun, "- successful:", result.wasSuccessful())
terminal
$ python main.py
── what you'll see ──
tests run: 1 - successful: True

Myth vs fact

Myth

unittest is obsolete and pytest has replaced it.

Fact

unittest is maintained, ships with Python and underlies a lot of existing code. pytest is more comfortable, and it runs unittest tests, so the two work together.

Myth

doctests are a replacement for a real test suite.

Fact

They test short, pure examples. They are fragile for setup, randomness and long output. Use them to keep documentation true, and use real tests for behaviour.

Myth

assertEqual(a, b) is just a longer way of writing assert a == b.

Fact

Under plain unittest a bare assert a == b fails with no detail, so assertEqual exists to print both values (and a diff for long strings, lists and dicts). Under pytest, assert rewriting gives equal detail for a plain assert, but assertEqual and friends still work.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    unittest.TestCase.subTest is the unittest answer to parametrize: with self.subTest(n=n): inside a loop reports every failing case with its parameters instead of stopping at the first. It does not produce separate test ids, which is one reason pytest's parametrize is nicer.

  • ▸

    unittest.IsolatedAsyncioTestCase (Python 3.8+) runs async def test_... methods and asyncSetUp/asyncTearDown, which matters for tests of asyncio code (Phase 12). Plain TestCase cannot await.

  • ▸

    doctest examples run with the module's globals copied in, so they do not see leaks from each other, but they do share state within one docstring: a variable set in the first example is available in the second. Put one idea in each docstring and avoid long chains.

  • ▸

    doctest can test errors too: write the expected output as the Traceback (most recent call last): header line, then the last line of the traceback (ValueError: ...); the lines in between are ignored. # doctest: +IGNORE_EXCEPTION_DETAIL also ignores the message text, which differs between Python versions.

  • ▸

    self.addCleanup(function, *args) registers teardown at the moment you acquire a resource. Cleanups run in reverse order even when setUp itself fails half-way, which tearDown does not do, so prefer addCleanup for anything acquired in setUp (a started patcher, an open file). Under pytest, a TestCase method cannot take fixtures as arguments and cannot use parametrize; use @pytest.mark.usefixtures("name") or an autouse fixture instead.

  • ▸

    Exit codes: python -m unittest exits with 1 on failure, which is what CI looks at. doctest.testmod() returns TestResults(failed, attempted); a script that wants a non-zero exit must call sys.exit itself, or you can run doctests under pytest.

Remember this

  1. 1

    unittest follows the xUnit design that came from Java's JUnit. You write a class (Topic 8.1) that inherits from unittest.TestCase (Topic 8.5). Each method whose name starts with test is one test. Inside, you use methods such as self.assertEqual(a, b) instead of a bare assert.

  2. 2

    Many older projects and libraries (and Python's own test suite) are written this way, so you will meet it even if you prefer pytest.

  3. 3

    setUp runs before every test method and tearDown runs after it (even when the test fails). Use them to give each test a fresh object, like the Wallet(100) in the example. Each test method runs on its own new instance of the class, which keeps tests apart.

  4. 4

    setUpClass and tearDownClass (class methods, Topic 8.3) run once for the whole class. They are the unittest version of a wider-scope fixture (Topic 11.3).

  5. 5

    The assertion methods tell you what failed. assertEqual(a, b) fails with 90 != 100. assertTrue, assertFalse, assertIs, assertIsNone, assertIn and assertAlmostEqual (for floats) cover the common cases. For errors use with self.assertRaises(ValueError): (a with block, Topic 7.4), or assertRaisesRegex to match the message.

  6. 6

    self.subTest(...) runs several cases in one method and reports each failing one separately. @unittest.skip and @unittest.expectedFailure mark tests that should not count.

  7. 7

    Run a suite with python -m unittest (it discovers files named test*.py), add -v to list each test, or name one: python -m unittest test_wallet.WalletTests.test_cannot_overspend. A script can call unittest.main(). The exit code (Topic 11.2) is non-zero if anything failed, so CI can use it.

  8. 8

    Underneath, a loader builds a suite of tests, a runner runs them, and a result object counts passes, failures and errors. The runnable example uses these directly.

  9. 9

    doctest searches docstrings (the text in triple quotes right under a def, Topic 5.9) for text that looks like an interactive session (Topic 0.5). A line starting with >>> is code, and the lines under it are the expected output. It runs the code and compares the printed output text exactly.

  10. 10

    python -m doctest -v module.py or doctest.testmod() run them, and pytest --doctest-modules runs them inside pytest. They are perfect for short, pure examples that also serve as documentation. They are poor for anything with setup, randomness, or large output.

  11. 11

    Because doctest compares text, you sometimes need directives. # doctest: +ELLIPSIS lets ... match any text (hiding a memory address, the number that says where an object sits in the computer, which changes every run). +NORMALIZE_WHITESPACE ignores differences in spaces and line breaks. <BLANKLINE> stands for an empty line in expected output.

  12. 12

    Without directives, a doctest that prints a dictionary or a memory address fails for reasons that have nothing to do with correctness.

  13. 13

    Reading a legacy unittest suite is mostly translation. self.assertEqual(a, b) is assert a == b; setUp is a fixture; assertRaises is pytest.raises. The good news is that **pytest runs TestCase classes unchanged**, so you can adopt pytest as the runner for an old suite today and write new tests in pytest style next to it. You do not need to rewrite working tests.

Explain it without notes

01

How do you write a unittest test, and what do setUp and tearDown do?

02

What is the difference between a failure and an error in unittest?

03

How does doctest work, and where does it fall short?

04

How would you move a legacy unittest suite to pytest?

05

What do the +ELLIPSIS and +NORMALIZE_WHITESPACE directives do?

Practice

01

Write a TestCase for a function is_even(n) with three tests, and run it in the browser with a TextTestRunner writing to a hidden stream. Print result.testsRun and result.wasSuccessful().

02

Write a function initials(name) with a docstring containing two doctest examples, put it in a module file and run doctest.testmod.

03

Use subTest to check is_even for several numbers in one test method.

Trade-offs

  • ↔

    unittest: no install, more ceremony. It is always available and explicit, but needs classes and long assertion method names. pytest is shorter but is a dependency.

  • ↔

    doctest: documentation that cannot rot, but fragile. Examples stay correct because they run, but exact text matching makes them break on harmless formatting changes. Keep them small.

  • ↔

    Rewriting old tests: low value, high risk. A working test suite that still runs has value. Convert when you change the code under it, not for fashion.

Done when you can

  • Done when you can write a TestCase with setUp and assertion methods and run it.

  • Done when you can explain the difference between a failure and an error.

  • Done when you can write a docstring with >>> examples and run it with doctest.

  • Done when you can use +ELLIPSIS and <BLANKLINE> in a doctest.

  • Done when you can read a unittest-based test file and say how its pieces map to pytest.