Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Testing & Debugging  ›  Lesson

unittest, doctest & tox

Testing 20 minThe standard library's own test tools, and the one that runs your whole suite on every Python version
Your task
Build run_unittest(source, class_name): exec source (it defines a unittest.TestCase subclass using the name unittest from the namespace you provide), load its tests with unittest.defaultTestLoader.loadTestsFromTestCase, run them with a TextTestRunner writing to an io.StringIO so nothing prints, and return {"ran": result.testsRun, "failures": len(result.failures), "errors": len(result.errors)}. Example: a TestCase with one passing and one failing test gives {"ran": 2, "failures": 1, "errors": 0}.

unittest, doctest & tox

In short: unittest is the test framework Python ships with no install — tests are methods on a TestCase, self.assertEqual/self.assertRaises instead of a bare assert, and setUp builds fresh state before every test method. doctest turns the example already in a function's docstring into a test, so the documentation can't quietly drift from the code it describes. tox runs your whole suite once per supported Python version from a tox.ini, catching the bug that only shows up on the version nobody tested locally. pytest runs unittest classes and doctest examples unchanged, so none of this is wasted — each tool just answers a different question.

Four boxes: unittest (classes, ships with Python), doctest (the docstring's own example), pytest (plain functions, fixtures — highlighted, what lessons 01-06 build), and tox (the whole suite, once per Python version, read-only here)

Lesson 1 showed unittest and doctest running right alongside your hand-built test rig. This lesson goes one level deeper into each — and covers tox, the one tool in this section that genuinely cannot run in a browser, because it needs real Python interpreters installed on disk.

Run a legacy unittest suite nobody has rewritten yet

Quarry's original codebase has a module of unittest tests from before anyone adopted pytest. A TestCase groups related tests as methods, and self.assertEqual/self.assertRaises replace the bare assert you've been writing:

python

setUp runs before every test method, building whatever each one needs fresh — the same job a pytest fixture does, just scoped to one class instead of shared by name. unittest.main(argv=[...], exit=False) runs the tests in the current module without sys.exit-ing the page, which is what makes it usable here instead of only from a terminal.

A trap worth seeing once: inside with self.assertRaises(...), the block stops at the first line that raises — anything after that line never runs. Keep exactly the one call that should raise inside the block. And if nothing raises at all, the test fails with ValueError not raised:

step through it
1import unittest
2 
3class Probe(unittest.TestCase):
4 def test_guarded(self):
5 with self.assertRaises(ValueError):
6 int("not a number")
7 
8result = unittest.TextTestRunner(verbosity=0).run(
9 unittest.defaultTestLoader.loadTestsFromTestCase(Probe))
10outcome = "passed" if result.wasSuccessful() else "failed"
11print(outcome)

For code that still lives as a standalone script, TextTestRunner over a loaded suite is the same thing spelled without unittest.main:

python

Let the docstring's own example be the test

A handful of Quarry's functions are small, pure, and already have a usage example in their docstring. doctest runs that example as a test and compares the output character for character — so a stale comment can't survive:

python

Nothing printed before "checked" means every example matched. Break the claimed answer and doctest reports the mismatch on its own, with no test function to write:

What does this print?
import contextlib
import doctest
import io

def area(w, h):
    """
    >>> area(3, 4)
    13
    """
    return w * h

buf = io.StringIO()
with contextlib.redirect_stdout(buf):
    doctest.run_docstring_examples(area, {"area": area}, verbose=False, name="area")
print("captured" if buf.getvalue() else "silent")

That silence-on-success, loud-on-failure behaviour is exactly backwards from assert, and it's worth remembering precisely for that reason.

Guard three supported Python versions with tox

pytest and unittest both answer "does the suite pass, right now, on this Python." Neither answers "does it still pass on every version we promise to support." That's tox's one job: a tox.ini lists environments, one per interpreter, and running tox builds a clean virtual environment for each and runs the whole suite in it.

# tox.ini
[tox]
envlist = py310, py311, py312

[testenv]
deps = pytest
commands = pytest

tox needs real Python 3.10, 3.11 and 3.12 binaries installed on the machine it runs on — there is no version of that in a browser tab, so this is read-only. What matters is reading the result: tox -e py310 runs just one environment, and tox exits non-zero the moment any environment fails, the same exit-code convention lesson 6's rig ended on. A bug that only appears on an older Python — a removed stdlib function, a changed default — is exactly the class of bug a single local run can never catch, because your own machine only has one Python on it.

Quick practice

What does this print?
import unittest

import io

class T(unittest.TestCase):
    def test_a(self):
        self.assertEqual(1, 1)
    def test_b(self):
        self.assertEqual(1, 2)
    def test_c(self):
        raise RuntimeError("boom")

result = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(
    unittest.defaultTestLoader.loadTestsFromTestCase(T))
print(result.testsRun, len(result.failures), len(result.errors))
What does this print?
import contextlib
import doctest
import io

def half(n):
    """
    >>> half(10)
    5
    >>> half(7)
    3.5
    """
    return n / 2

finder = doctest.DocTestFinder()
runner = doctest.DocTestRunner(verbose=False)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
    for t in finder.find(half, "half", globs={"half": half}):
        runner.run(t, out=lambda s: None)
    result = runner.summarize(verbose=False)
print(result)

🎯 Your turn

Build run_unittest(source, class_name): exec source (it defines a unittest.TestCase subclass, using the name unittest you already put in its namespace), load its tests with unittest.defaultTestLoader.loadTestsFromTestCase, run them with a TextTestRunner writing to an io.StringIO so nothing prints, and return {"ran": result.testsRun, "failures": len(result.failures), "errors": len(result.errors)}.

run_unittest(
    "class Suite(unittest.TestCase):\n"
    "    def test_ok(self):\n"
    "        self.assertEqual(1 + 1, 2)\n",
    "Suite",
)
# → {"ran": 1, "failures": 0, "errors": 0}

Hint — ns = {"unittest": unittest} before exec(source, ns), so the class body's own unittest.TestCase resolves; everything after that is the loader → runner → result pattern from this lesson.

Then press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upA hand-made assertRaises

unittest's assertRaises (like pytest.raises) accepts a subclass match. Write assert_raises_like(fn_name, arg, exc_name): look up the function in FUNCS, call it with arg, and return True if it raises an exception that is an instance of the built-in class named exc_name (or a subclass of it), False if it raises anything else or nothing at all.

unittestexceptions
DrillPull the examples out of a docstring

Write doctest_lines(docstring) returning a list of [code, expected] pairs — one per >>> line in the docstring, where expected is every non-blank line that follows it up to the next >>> or a blank line, joined with newlines. A docstring with no >>> lines returns [].

docteststrings
BuildWhat setUp hands the test

Write setup_values(source, class_name, attr): exec source (it defines a unittest.TestCase subclass with a setUp method, using the name unittest already in scope), create an instance of the named class, call its setUp(), and return the value of the attribute named attr afterwards.

unittesttest-design
BossRun a function's own doctest

Write doctest_report(source, fn_name): exec source (it defines a function named fn_name with >>> examples in its docstring), find and run its doctests with doctest.DocTestFinder and doctest.DocTestRunner, and return {"attempted": n, "failed": n} from the runner's summary.

doctestunittest
CapstoneRead a tox-style version matrix

tox.ini lists environments (one per Python version) in envlist and runs the whole suite once per environment. Write tox_matrix(envlist, results): envlist is a comma-separated string like "py310,py311,py312"; results maps an environment name to a list of per-test booleans for that environment. Return a dict mapping every environment in envlist to "pass" (all True, or no tests at all), "fail" (at least one False), or "not-run" (missing from results). Raise ValueError if results has an environment that isn't in envlist.

pytesttest-design
Explainpytest, unittest, doctest or tox?

Quarry's team is choosing tools: they already write pytest tests, a few small pure functions have good docstring examples, and the project needs to keep working on three supported Python versions. Explain which tool fits each job and why reaching for the wrong one would be worse.

run_unittest.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
run_unittest(source, class_name) → dictRun a unittest.TestCase class defined in source and report how many ran, failed and errored.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.