Python unittest Explained: TestCase, setUp and subTest

The unittest framework in the Python standard library: TestCase methods, assertEqual and assertRaises, setUp and tearDown, subTest tables and running suites.

  • Course: Python study plan
  • Module: Testing and debugging
  • Kind: Lesson
  • Reading time: 15 min
  • Runtime: CPython 3.11

How do you write a unit test with Python's unittest?

To write a unit test with Python's unittest, subclass unittest.TestCase and add methods whose names start with test, each checking one behaviour with an assertion method such as assertEqual or assertRaises. setUp builds fresh state before every test and tearDown cleans up after it. Run the tests with python -m unittest, or call unittest.main() at the bottom of the file.

Lesson

unittest is the test framework in the standard library: tests are methods named test_* on a TestCase subclass, assertions are methods (assertEqual, assertRaises, …) that produce readable failure messages, setUp and tearDown build and clear state around each test, and python -m unittest discovers and runs them. It is more verbose than pytest (next lesson) and it is everywhere — CI images, older code bases, environments where nothing can be installed — so knowing it well is not optional. This lesson covers the class, the assertion family, fixtures at three scopes, subTest for tables, skipping and expected failures, and running a suite in-process so a program can report its own results.

The shape

import unittest

def slugify(text):
    return "-".join(text.lower().split())

class TestSlugify(unittest.TestCase):
    def test_lowercases_and_joins(self):
        self.assertEqual(slugify("Hello World"), "hello-world")

    def test_collapses_whitespace(self):
        self.assertEqual(slugify("  a   b "), "a-b")

    def test_empty(self):
        self.assertEqual(slugify(""), "")

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

Run with python test_slugify.py or python -m unittest test_slugify (or python -m unittest alone to discover test*.py files). Output: a dot per passing test, F for a failure (an assertion was false), E for an error (an unexpected exception), then a summary.

The assertion family

MethodChecks
assertEqual(a, b) / assertNotEquala == b, with a diff for strings, lists and dicts
assertTrue(x) / assertFalse(x)truthiness — prefer a more specific assertion when one exists
assertIs(a, b) / assertIsNone(x)identity
assertIn(x, xs) / assertNotInmembership
assertIsInstance(x, cls)type
assertAlmostEqual(a, b, places=7)floats, rounded difference
assertGreater, assertLess, assertGreaterEqual, …comparisons
assertRaises(Exc)as a context manager: the block raises Exc
assertRaisesRegex(Exc, pattern)and its message matches
assertCountEqual(a, b)same elements in any order
assertDictEqual, assertListEqual, …explicit, with diffs

Every method takes a final msg= argument added to the failure output. The specific methods matter because their messages are specific: assertEqual([1, 2], [1, 3]) shows where the lists differ; assertTrue([1, 2] == [1, 3]) says only "False is not true".

def test_rejects_empty_name(self):
    with self.assertRaises(ValueError) as ctx:
        User(name="")
    self.assertIn("name", str(ctx.exception))

Fixtures: setUp, tearDown and class-level

class TestInventory(unittest.TestCase):
    @classmethod
    def setUpClass(cls):                    # once per class — expensive shared state
        cls.catalogue = load_catalogue()

    def setUp(self):                        # before EVERY test — fresh state
        self.inv = Inventory(self.catalogue)
        self.inv.add("bolt", 10)

    def tearDown(self):                     # after every test, even a failing one
        self.inv.close()

    def test_remove(self):
        self.inv.remove("bolt", 4)
        self.assertEqual(self.inv.count("bolt"), 6)

setUp runs before each test method and tearDown after, so every test starts from the same state and no test sees another's leftovers. setUpClass/tearDownClass bracket the whole class; setUpModule the module. self.addCleanup(fn) registers a callback run after tearDown — the safest way to release something acquired mid-test. Temporary files belong in tempfile.TemporaryDirectory() opened in setUp and cleaned in tearDown.

subTest: tables without a method per row

def test_parse_durations(self):
    cases = [("1h", 3600), ("30m", 1800), ("1h30m", 5400), ("45s", 45)]
    for text, expected in cases:
        with self.subTest(text=text):
            self.assertEqual(parse_duration(text), expected)

Without subTest, the loop stops at the first failing row; with it, every row is checked and each failure is reported with its parameters. The method still counts as one test in testsRun, but each failing subtest appears as its own entry in the failures list.

Skipping and expected failures

@unittest.skip("reason"), @unittest.skipIf(cond, "reason"), @unittest.skipUnless(cond, "reason") skip a test or a whole class; self.skipTest("reason") skips from inside. @unittest.expectedFailure marks a known bug: the test passing becomes an "unexpected success". Skips are reported in the summary and are not failures — a suite that silently skips half its tests on CI because a dependency is missing is a common way to ship a regression.

Running in-process

import io, unittest

suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestSlugify)
result = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
print("ran", result.testsRun)
print("failures", len(result.failures), "errors", len(result.errors))
print("ok", result.wasSuccessful())
for test, _traceback in result.failures:
    print("failed", test.id().rsplit(".", 1)[-1])

unittest.main() calls sys.exit with the result, which is right for a test file and wrong for a program that wants to keep going; loading a suite and running it with a TextTestRunner on a StringIO stream keeps the report ("Ran 3 tests in 0.001s", whose timing is not reproducible) off stdout and gives you the TestResult to print from. unittest.main(argv=["prog"], exit=False) is the shorter form when the module-level discovery is wanted. result.failures and result.errors are lists of (test, traceback_text); test.id() is module.Class.method.

Organising a suite

One test module per source module (test_slugify.py beside or under tests/ for slugify.py), one TestCase per class or cohesive function group, and names that read as sentences: TestSlugify.test_collapses_whitespace. python -m unittest discover -s tests -p "test_*.py" finds them; -v prints each name; -k slug runs the ones whose names match; --failfast stops at the first failure. Keep the tests importable without side effects — no network calls or file writes at module level — because discovery imports every test module before running anything.

Pitfalls

  • assertTrue(a == b) instead of assertEqual(a, b) — the failure message says nothing.
  • State created in __init__ or at class level instead of setUp, shared between tests.
  • A test method not starting with test_ — silently never run.
  • assertRaises(Exception) — too broad; name the exception.
  • unittest.main() inside a program that must continue.
  • Comparing floats with assertEqual.

Key takeaways

  • Tests are test_* methods on a TestCase; run with python -m unittest or unittest.main().
  • Use the specific assertion (assertEqual, assertIn, assertRaises as a context manager, assertAlmostEqual) for a message that explains the failure.
  • setUp/tearDown give every test fresh state; setUpClass shares expensive state; addCleanup releases resources.
  • subTest checks every row of a table and reports each failure with its parameters.
  • Run in-process with TextTestRunner(stream=StringIO()) and read testsRun, failures, errors, wasSuccessful() from the result.

Common questions

Why use assertEqual instead of assertTrue(a == b)?

The specific assertion gives a specific message: assertEqual([1, 2], [1, 3]) shows where the lists differ, while assertTrue on the comparison reports only 'False is not true'. Every assertion method also takes a msg= argument for extra context.

What is the difference between setUp and setUpClass?

setUp runs before every test method, so each test starts from fresh state; setUpClass, a classmethod, runs once for the whole class and suits expensive shared state. tearDown and tearDownClass are their counterparts, and addCleanup registers a release step that runs even after a failure.

What does subTest do in unittest?

It checks every row of a table inside one test method. Without it, a loop of assertions stops at the first failing row; inside with self.subTest(text=text):, every row runs and each failure is reported with its parameters.

How do you test that an exception is raised in unittest?

Use assertRaises as a context manager, with self.assertRaises(ValueError) as ctx:, around the call, then inspect ctx.exception. assertRaisesRegex also checks the message; name the specific exception rather than Exception.

Why is my unittest test not running?

Test methods must start with test and test files must match the discovery pattern, test*.py by default. A method named check_total is silently never run; python -m unittest -v lists every test that did run.

Exercises

Catch the injected bug

The starter defines Stack with a bug injected according to the first input line: none, pop (pops from the wrong end), peek (peeks at the wrong end) or empty (pop on an empty stack returns None instead of raising). Write TestStack(unittest.TestCase) with exactly these tests: test_push_then_pop (push 1 and 2, expect pop() to give 2 then 1), test_peek_does_not_remove (push 1 and 2, expect peek() 2 and len 2), test_pop_empty_raises (assertRaises(IndexError) around pop() on a new stack) and test_len_and_is_empty (new stack: is_empty() and len 0; after push(5): len 1 and not empty). Run the suite with a TextTestRunner on a StringIO stream and print ran <n>, failures <f>, errors <e>, ok <True/False>, then failed <method name> for each failure, sorted.

Input: the bug name. Output: four lines, then any failed test names.

none

prints

ran 4
failures 0
errors 0
ok True

A subTest table

Write checked_div(a, b) returning a / b and raising ZeroDivisionError when b is 0. Read rows a b expected until EOF, where expected is a number or the word raises. Write a TestCase with test_table, which loops over the numeric rows inside with self.subTest(a=a, b=b): asserting checked_div(a, b) == float(expected), and test_raises, which loops over the raises rows inside subTest asserting assertRaises(ZeroDivisionError). Run the suite on a StringIO stream and print ran <n>, failures <f>, errors <e>, ok <True/False>.

Input: one row per line. Output: four lines.

10 2 5
7 2 3.5
1 0 raises
9 3 4

prints

ran 2
failures 1
errors 0
ok False

In this module: Testing and debugging

← The testing mindset — what to test, how to arrange it, and designing for testability · pytest in outline — plain asserts, fixtures, parametrize and the command line →