Skip to content
aviral gupta

// I5.1 · ~30 min · Intermediate

Testing with unittest

After this lesson you can write unittest test cases that check results and exceptions, give each test a fresh fixture with setUp, and run a suite from code and read what failed.

Lesson 1 of 5 in I5 Testing and project tooling

Start of the module

You will be able to

  • Write a TestCase whose test_ methods use assertEqual, assertTrue and assertRaises
  • Use setUp so that every test starts from a fresh fixture
  • Run a suite from code and read testsRun, failures, errors and wasSuccessful()
  1. Warm-up · Activity 1 of 7

    Warm-up from the exceptions module: which of these calls raise ValueError? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you read on: how many tests does this suite run?

    import unittest
    
    
    class TestText(unittest.TestCase):
        def test_upper(self):
            self.assertEqual("abc".upper(), "ABC")
    
        def check_lower(self):
            self.assertEqual("ABC".lower(), "abc")
    
    
    suite = unittest.TestLoader().loadTestsFromTestCase(TestText)
    result = unittest.TestResult()
    suite.run(result)
    print(result.testsRun)
  3. Practice · Activity 3 of 7

    Fill in the assert method so that the test passes exactly when int("ten") raises ValueError.

            with self.____(ValueError):
                int("ten")
    with self.(ValueError):
  4. Practice · Activity 4 of 7

    setUp counts its calls on the class. What does this print?

    import unittest
    
    
    class TestCounter(unittest.TestCase):
        calls = 0
    
        def setUp(self):
            TestCounter.calls += 1
            self.items = []
    
        def test_a(self):
            self.items.append(1)
            self.assertEqual(len(self.items), 1)
    
        def test_b(self):
            self.items.append(2)
            self.assertEqual(len(self.items), 1)
    
        def test_c(self):
            self.assertEqual(self.items, [])
    
    
    suite = unittest.TestLoader().loadTestsFromTestCase(TestCounter)
    result = unittest.TestResult()
    suite.run(result)
    print(TestCounter.calls, result.wasSuccessful())
  5. Practice · Activity 5 of 7

    A suite ran three tests: one passed, one assertEqual failed, one raised KeyError. Match each part of the result to its value.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. add() has a bug. What does this print?

    import unittest
    
    
    def add(a, b):
        return a - b  # bug
    
    
    class TestAdd(unittest.TestCase):
        def test_add(self):
            self.assertTrue(add(2, 3), 5)
    
    
    result = unittest.TestResult()
    unittest.TestLoader().loadTestsFromTestCase(TestAdd).run(result)
    print(result.wasSuccessful())
  7. Apply · Activity 7 of 7

    Mini-task. Here is word_count(text), which returns len(text.split()). Write a TestCase with at least four tests: a normal sentence, the empty string, extra spaces, and a number instead of text (split does not exist on int, so AttributeError). Run the suite and print testsRun and wasSuccessful().

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Testing a shopping basket

A Basket stores item prices in cents. Its tests share a fixture from setUp, check totals with assertEqual and exceptions with assertRaises. remove() has a bug: it ignores an item that is not there instead of raising KeyError. The program runs the suite into a TestResult and prints what it found, including the failing test. Fix remove() with del self.items[name] and run it again.

main.py

import unittest


class Basket:
    """A shopping basket: item names mapped to prices in cents."""

    def __init__(self) -> None:
        self.items: dict[str, int] = {}

    def add(self, name: str, cents: int) -> None:
        if cents < 0:
            raise ValueError(f"negative price: {cents}")
        self.items[name] = cents

    def remove(self, name: str) -> None:
        self.items.pop(name, None)  # bug: a missing item is ignored silently

    def total(self) -> int:
        return sum(self.items.values())


class TestBasket(unittest.TestCase):
    def setUp(self) -> None:
        # Runs before every test: each test gets a new basket with one item.
        self.basket = Basket()
        self.basket.add("tea", 350)

    def test_total(self) -> None:
        self.basket.add("cake", 425)
        self.assertEqual(self.basket.total(), 775)

    def test_remove(self) -> None:
        self.basket.remove("tea")
        self.assertEqual(self.basket.total(), 0)

    def test_negative_price(self) -> None:
        with self.assertRaises(ValueError):
            self.basket.add("refund", -100)

    def test_remove_missing(self) -> None:
        with self.assertRaises(KeyError):
            self.basket.remove("coffee")


suite = unittest.TestLoader().loadTestsFromTestCase(TestBasket)
result = unittest.TestResult()
suite.run(result)

print("tests run:", result.testsRun)
print("failures:", len(result.failures))
print("errors:", len(result.errors))
for test, trace in result.failures:
    print("FAIL:", test.id())
    print("  reason:", trace.strip().splitlines()[-1])
print("all passed:", result.wasSuccessful())

Run it with

python main.py

Output

tests run: 4
failures: 1
errors: 0
FAIL: __main__.TestBasket.test_remove_missing
  reason: AssertionError: KeyError not raised
all passed: False
  • test_remove sees a total of 0 after removing tea: the cake from test_total is not in its basket, because setUp built a new one.
  • The failing test is named by test.id(): module, class and method.
  • The last line of the traceback, "KeyError not raised", is the message assertRaises gives when the block ends normally.
  • failures is 1 and errors is 0: the bug makes an assert method fail, it does not crash the test.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

Tests that catch bugs

apply_discount is correct. Your job is its tests: add at least three test methods to TestDiscount. Check that 50 percent off 80.0 is 40.0, and that a percent above 100 and a negative percent both raise ValueError. The checks run your tests against the real function, which must pass, and against three broken versions, which your tests must catch.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    A new test is a new method in the class whose name starts with test, for example def test_half_price(self) -> None:.

  2. Hint 2

    For the value: self.assertEqual(apply_discount(80.0, 50), 40.0).

  3. Hint 3

    For each error case: with self.assertRaises(ValueError): and, indented under it, the call that must raise.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import unittest


def apply_discount(price: float, percent: float) -> float:
    """Returns price reduced by percent, rounded to cents."""
    if not 0 <= percent <= 100:
        raise ValueError(f"percent must be 0-100, got {percent}")
    return round(price * (100 - percent) / 100, 2)


class TestDiscount(unittest.TestCase):
    def test_no_discount(self) -> None:
        self.assertEqual(apply_discount(80.0, 0), 80.0)

    def test_half_price(self) -> None:
        self.assertEqual(apply_discount(80.0, 50), 40.0)

    def test_percent_too_high(self) -> None:
        with self.assertRaises(ValueError):
            apply_discount(80.0, 120)

    def test_negative_percent(self) -> None:
        with self.assertRaises(ValueError):
            apply_discount(80.0, -5)


if __name__ == "__main__":
    suite = unittest.TestLoader().loadTestsFromTestCase(TestDiscount)
    unittest.TextTestRunner(verbosity=2).run(suite)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import unittest


def apply_discount(price: float, percent: float) -> float:
    """Returns price reduced by percent, rounded to cents."""
    if not 0 <= percent <= 100:
        raise ValueError(f"percent must be 0-100, got {percent}")
    return round(price * (100 - percent) / 100, 2)


class TestDiscount(unittest.TestCase):
    def test_no_discount(self) -> None:
        self.assertEqual(apply_discount(80.0, 0), 80.0)

    # Add three more tests:
    # - 50 percent off 80.0 is 40.0
    # - a percent above 100 raises ValueError
    # - a negative percent raises ValueError


if __name__ == "__main__":
    suite = unittest.TestLoader().loadTestsFromTestCase(TestDiscount)
    unittest.TextTestRunner(verbosity=2).run(suite)

test_main.py

import unittest

import main


def run_against(implementation):
    saved = main.apply_discount
    main.apply_discount = implementation
    try:
        result = unittest.TestResult()
        unittest.TestLoader().loadTestsFromTestCase(main.TestDiscount).run(result)
    finally:
        main.apply_discount = saved
    return result


def subtracts_percent(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError(percent)
    return round(price - percent, 2)


def no_upper_limit(price, percent):
    if percent < 0:
        raise ValueError(percent)
    return round(price * (100 - percent) / 100, 2)


def no_lower_limit(price, percent):
    if percent > 100:
        raise ValueError(percent)
    return round(price * (100 - percent) / 100, 2)


def test_correct_code_passes():
    """Your tests pass on the correct apply_discount"""
    result = run_against(main.apply_discount)
    failing = [test.id() for test, _ in result.failures + result.errors]
    assert result.wasSuccessful(), f"these tests fail on correct code: {failing}"
    assert result.testsRun >= 4, f"{result.testsRun} tests ran; write at least 4"


def test_catches_wrong_formula():
    """Your tests catch a formula that subtracts the percent as money"""
    result = run_against(subtracts_percent)
    assert not result.wasSuccessful(), "every test passes when apply_discount(80.0, 50) returns 30.0; check a real discount"


def test_catches_missing_upper_limit():
    """Your tests catch a missing check for a percent above 100"""
    result = run_against(no_upper_limit)
    assert not result.wasSuccessful(), "every test passes when apply_discount(80.0, 120) returns a price; check that it raises ValueError"


def test_catches_missing_lower_limit():
    """Your tests catch a missing check for a negative percent"""
    result = run_against(no_lower_limit)
    assert not result.wasSuccessful(), "every test passes when apply_discount(80.0, -5) returns a price; check that it raises ValueError"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

A one-line test report

Write summarize(case), which takes a TestCase class, runs all its tests and returns one line such as "2 run, 1 failed, 0 errors: FAILED". Use OK instead of FAILED when every test passed. Load the tests with unittest.TestLoader().loadTestsFromTestCase and run them into a unittest.TestResult. Run prints the report for TestDemo.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    suite = unittest.TestLoader().loadTestsFromTestCase(case), then result = unittest.TestResult() and suite.run(result).

  2. Hint 2

    result.failures and result.errors are lists: count them with len().

  3. Hint 3

    status = "OK" if result.wasSuccessful() else "FAILED", then build the line with an f-string.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import unittest


def summarize(case: type[unittest.TestCase]) -> str:
    suite = unittest.TestLoader().loadTestsFromTestCase(case)
    result = unittest.TestResult()
    suite.run(result)
    status = "OK" if result.wasSuccessful() else "FAILED"
    return f"{result.testsRun} run, {len(result.failures)} failed, {len(result.errors)} errors: {status}"


class TestDemo(unittest.TestCase):
    def test_ok(self) -> None:
        self.assertEqual(1 + 1, 2)

    def test_wrong(self) -> None:
        self.assertEqual(1 + 1, 3)


if __name__ == "__main__":
    print(summarize(TestDemo))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import unittest


def summarize(case: type[unittest.TestCase]) -> str:
    # 1. Load the tests of case with unittest.TestLoader().loadTestsFromTestCase.
    # 2. Run the suite into a unittest.TestResult.
    # 3. Return "<run> run, <failed> failed, <errors> errors: OK",
    #    with FAILED instead of OK when a test did not pass.
    return ""


class TestDemo(unittest.TestCase):
    def test_ok(self) -> None:
        self.assertEqual(1 + 1, 2)

    def test_wrong(self) -> None:
        self.assertEqual(1 + 1, 3)


if __name__ == "__main__":
    print(summarize(TestDemo))

test_main.py

import unittest

from main import summarize


class AllPass(unittest.TestCase):
    def test_one(self):
        self.assertEqual(len("ab"), 2)

    def test_two(self):
        self.assertTrue("abc".startswith("a"))


class Mixed(unittest.TestCase):
    def test_pass(self):
        self.assertEqual(3 * 3, 9)

    def test_fail(self):
        self.assertEqual(3 * 3, 6)

    def test_error(self):
        int("nine")


class NoTests(unittest.TestCase):
    def check_something(self):
        self.assertEqual(1, 2)


def test_all_pass():
    """Two passing tests give OK"""
    got = summarize(AllPass)
    assert got == "2 run, 0 failed, 0 errors: OK", f"summarize returned {got!r}"


def test_mixed():
    """A failure and an error are counted separately"""
    got = summarize(Mixed)
    assert got == "3 run, 1 failed, 1 errors: FAILED", f"summarize returned {got!r}"


def test_no_tests():
    """A class without test_ methods runs nothing"""
    got = summarize(NoTests)
    assert got == "0 run, 0 failed, 0 errors: OK", f"summarize returned {got!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Using the old name assertEquals

import sys
import unittest


class TestTotal(unittest.TestCase):
    def test_sum(self):
        self.assertEquals(sum([1, 2, 3]), 6)


suite = unittest.TestLoader().loadTestsFromTestCase(TestTotal)
result = unittest.TextTestRunner().run(suite)
sys.exit(0 if result.wasSuccessful() else 1)

What Python prints

AttributeError: 'TestTotal' object has no attribute 'assertEquals'. Did you mean: 'assertEqual'?

Why, and the fix

Old tutorials use assertEquals, an alias that newer Python versions no longer have. The method is assertEqual, without the s. The runner reports the test as an error rather than a failure, because the test crashed before it could compare anything.

Calling the function inside assertRaises

import sys
import unittest


class TestParse(unittest.TestCase):
    def test_bad_number(self):
        self.assertRaises(ValueError, int("x"))


suite = unittest.TestLoader().loadTestsFromTestCase(TestParse)
result = unittest.TextTestRunner().run(suite)
sys.exit(0 if result.wasSuccessful() else 1)

What Python prints

ValueError: invalid literal for int() with base 10: 'x'

Why, and the fix

int("x") runs before assertRaises is even called, so the exception escapes and the test is an error. Either pass the function and its arguments separately, self.assertRaises(ValueError, int, "x"), so assertRaises makes the call, or use the with form: with self.assertRaises(ValueError): int("x").

A typo in TestCase

import unittest


class TestUpper(unittest.Testcase):
    def test_upper(self):
        self.assertEqual("abc".upper(), "ABC")

What Python prints

AttributeError: module 'unittest' has no attribute 'Testcase'. Did you mean: 'TestCase'?

Why, and the fix

Names in Python are case-sensitive, and the class is TestCase with a capital C. The error appears as soon as the class statement runs, before any test, which is why Python can suggest the right name.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

A TestCase and its test_ methods

A test case is a class derived from unittest.TestCase. Every method whose name starts with test is one test; other methods are helpers and never run on their own. Inside a test you check results with assert methods: assertEqual(a, b) for equal values, assertTrue(x) for a true condition, and assertRaises for an exception. with self.assertRaises(ValueError): wraps the code that must raise. A failed assert method makes the test a failure; any other exception makes it an error.

setUp gives every test a fresh fixture

The preparation a test needs, such as a new basket with one item, is its fixture. Put it in a setUp(self) method: unittest calls it before every single test method, on a new instance, so one test cannot leave data behind for the next. tearDown(self) runs after each test, for cleanup. If setUp itself raises, the test counts as an error and its method does not run. A list stored on the class instead is shared by all tests.

Running a suite from code

On your machine, python -m unittest finds and runs test files. From code, unittest.TestLoader().loadTestsFromTestCase(TestX) builds a suite of every test in the class. Run it into a unittest.TestResult, or with unittest.TextTestRunner().run(suite), which prints a report to sys.stderr and returns the result. result.testsRun counts the tests, result.failures and result.errors hold (test, traceback) pairs, and result.wasSuccessful() is True when nothing failed. sys.exit(0 if result.wasSuccessful() else 1) turns that into an exit status.

Sources

Last reviewed September 29, 2026