EthicsComputerCourses
Join training +91 90532 10052

Python unittest

Software Testing · Lesson 15 of 30 Intermediate 6 min read Updated

Python comes with a complete testing framework in its standard library: unittest. You do not need to install anything, and many company codebases and older projects use it, so every tester and developer working with Python should be able to read and write unittest tests.

In this lesson you will write test classes, use the most common assert methods, prepare fresh test data with setUp(), run data-driven checks with subTest(), skip tests, read a failure report and run tests from the terminal.

Your first test class

A unittest test is a method inside a class that inherits from unittest.TestCase. Every method whose name starts with test is a test. Inside it you call assert methods such as self.assertEqual(actual, expected).

PythonRun it yourself
import unittest

def add_gst(price, rate=18):
    if price < 0:
        raise ValueError("price cannot be negative")
    return round(price * (100 + rate) / 100, 2)

class TestAddGst(unittest.TestCase):
    def test_default_rate_is_18_percent(self):
        self.assertEqual(add_gst(100), 118.0)

    def test_custom_rate(self):
        self.assertEqual(add_gst(1299, rate=5), 1363.95)

    def test_zero_price(self):
        self.assertEqual(add_gst(0), 0)

    def test_negative_price_is_rejected(self):
        with self.assertRaises(ValueError):
            add_gst(-50)

suite = unittest.TestLoader().loadTestsFromTestCase(TestAddGst)
unittest.TextTestRunner(verbosity=2).run(suite)
Output
test_custom_rate (__main__.TestAddGst.test_custom_rate) ... ok
test_default_rate_is_18_percent (__main__.TestAddGst.test_default_rate_is_18_percent) ... ok
test_negative_price_is_rejected (__main__.TestAddGst.test_negative_price_is_rejected) ... ok
test_zero_price (__main__.TestAddGst.test_zero_price) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.032s

OK

The time in the "Ran 4 tests" line will be different every time you run it. Notice that tests run in alphabetical order of their names, not in the order you wrote them, which is a reminder that tests must not depend on each other. assertRaises used with with passes only if the code inside raises the named exception.

Common assert methods

MethodPasses when
assertEqual(a, b) / assertNotEqual(a, b)a == b / a != b
assertTrue(x) / assertFalse(x)x is truthy / falsy
assertIs(a, b) / assertIsNone(x)a is b / x is None
assertIn(a, b) / assertNotIn(a, b)a in b / a not in b
assertAlmostEqual(a, b, places=2)a and b are equal when rounded to 2 places
assertGreater(a, b) / assertLess(a, b)a > b / a < b
assertRaises(Error)the code raises Error
assertRaisesRegex(Error, "text")the code raises Error and its message matches text
assertCountEqual(a, b)a and b contain the same items in any order

Prefer the specific method over assertTrue(a == b): when it fails, assertEqual shows both values, while assertTrue only says False is not true.

setUp and tearDown

Most tests need some objects to work with. Put that preparation in setUp(), which unittest calls before every test, so each test gets a fresh copy and cannot be affected by another. tearDown() runs after every test for clean-up such as deleting temporary files. This example also contains a real defect; run it and read the failure report.

PythonRun it yourself
import unittest

class Cart:
    def __init__(self):
        self.items = {}

    def add(self, name, price, qty=1):
        if qty <= 0:
            raise ValueError("quantity must be at least 1")
        old_qty = self.items.get(name, (price, 0))[1]
        self.items[name] = (price, qty)          # defect: forgets old_qty

    def total(self):
        return sum(price * qty for price, qty in self.items.values())

class TestCart(unittest.TestCase):
    def setUp(self):
        self.cart = Cart()                       # fresh cart for every test
        self.cart.add("notebook", 60, qty=2)

    def test_total_of_one_line(self):
        self.assertEqual(self.cart.total(), 120)

    def test_adding_same_item_increases_quantity(self):
        self.cart.add("notebook", 60, qty=3)
        self.assertEqual(self.cart.items["notebook"], (60, 5))

    def test_zero_quantity_is_refused(self):
        with self.assertRaisesRegex(ValueError, "at least 1"):
            self.cart.add("pen", 10, qty=0)

suite = unittest.TestLoader().loadTestsFromTestCase(TestCart)
unittest.TextTestRunner(verbosity=2).run(suite)
Output
test_adding_same_item_increases_quantity (__main__.TestCart.test_adding_same_item_increases_quantity) ... FAIL
test_total_of_one_line (__main__.TestCart.test_total_of_one_line) ... ok
test_zero_quantity_is_refused (__main__.TestCart.test_zero_quantity_is_refused) ... ok

======================================================================
FAIL: test_adding_same_item_increases_quantity (__main__.TestCart.test_adding_same_item_increases_quantity)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<exec>", line 26, in test_adding_same_item_increases_quantity
AssertionError: Tuples differ: (60, 3) != (60, 5)

First differing element 1:
3
5

- (60, 3)
?      ^

+ (60, 5)
?      ^


----------------------------------------------------------------------
Ran 3 tests in 0.022s

FAILED (failures=1)

Read a failure report from the bottom of its block upwards: the AssertionError line says the actual tuple was (60, 3) but (60, 5) was expected, and the line above says which test and line failed. The fix is self.items[name] = (price, old_qty + qty). In a real file the traceback shows the file name and the source line instead of <exec>.

There are also class-level versions, setUpClass() and tearDownClass(), that run once for the whole class. Use them only for expensive, read-only set-up such as starting a test database, because shared objects can leak changes between tests.

subTest and skipping

When one test checks many values, a plain loop stops at the first failure and hides the others. with self.subTest(...) reports every failing value separately and labels it. You can also skip tests with decorators such as @unittest.skip("reason"), @unittest.skipIf(condition, "reason") and @unittest.expectedFailure.

PythonRun it yourself
import unittest

def grade(marks):
    if not 0 <= marks <= 100:
        raise ValueError("marks must be 0-100")
    if marks > 90:                 # defect: 90 should be an A
        return "A"
    if marks >= 75:
        return "B"
    if marks >= 40:
        return "C"
    return "Fail"

class TestGrade(unittest.TestCase):
    def test_boundaries(self):
        cases = [(100, "A"), (90, "A"), (89, "B"), (75, "B"),
                 (74, "C"), (40, "C"), (39, "Fail"), (0, "Fail")]
        for marks, expected in cases:
            with self.subTest(marks=marks):
                self.assertEqual(grade(marks), expected)

    @unittest.skip("grace marks rule not decided yet")
    def test_grace_marks(self):
        self.assertEqual(grade(38), "C")

suite = unittest.TestLoader().loadTestsFromTestCase(TestGrade)
unittest.TextTestRunner(verbosity=2).run(suite)
Output
test_boundaries (__main__.TestGrade.test_boundaries) ...
  test_boundaries (__main__.TestGrade.test_boundaries) (marks=90) ... FAIL
test_grace_marks (__main__.TestGrade.test_grace_marks) ... skipped 'grace marks rule not decided yet'

======================================================================
FAIL: test_boundaries (__main__.TestGrade.test_boundaries) (marks=90)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<exec>", line 20, in test_boundaries
AssertionError: 'B' != 'A'
- B
+ A


----------------------------------------------------------------------
Ran 2 tests in 0.020s

FAILED (failures=1, skipped=1)

The label (marks=90) points straight at the failing boundary, and the other seven values were still checked. Always give a reason when skipping, so nobody forgets why.

Tests in files and the terminal

In a real project, code and tests live in separate files. By convention test files are named test_*.py:

Python
# test_billing.py
import unittest

from billing import add_gst


class TestAddGst(unittest.TestCase):
    def test_default_rate_is_18_percent(self):
        self.assertEqual(add_gst(100), 118.0)

    def test_negative_price_is_rejected(self):
        with self.assertRaises(ValueError):
            add_gst(-50)


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

You can run this file directly with python test_billing.py, but the usual way is test discovery: from the project folder, python -m unittest finds and runs every test*.py file. Add -v to see each test by name:

Terminal
python -m unittest -v
Output
test_default_rate_is_18_percent (test_billing.TestAddGst.test_default_rate_is_18_percent) ... ok
test_negative_price_is_rejected (test_billing.TestAddGst.test_negative_price_is_rejected) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

Without -v, each passing test prints a single dot, F marks a failure, E an error (an unexpected exception inside the test) and s a skipped test. The command exits with a non-zero code when any test fails, which is how build servers know the build is broken.

Common mistakes

  • Method names without the test prefix. def check_total(self) is silently ignored.
  • Tests that depend on each other. Tests run in name order, and a test must never rely on another one having run first. Use setUp().
  • Comparing floats exactly. Use assertAlmostEqual or round the result.
  • Catching the exception yourself. A try/except inside a test that swallows errors makes the test pass when it should fail; use assertRaises.

Key points

  • unittest is built into Python; tests are test* methods in classes that inherit from unittest.TestCase.
  • Use specific assert methods such as assertEqual, assertIn and assertRaises for clear failure messages.
  • setUp() and tearDown() run before and after every test, giving each test a fresh state.
  • subTest() reports each failing value of a data-driven test separately; decorators skip tests with a reason.
  • Run tests with python -m unittest -v; a failed run exits with a non-zero code.

Exercise

Write a TestCase for a function split_bill(total, people) that divides a restaurant bill equally and rounds to 2 decimal places, raising ValueError if people is less than 1. Include tests for ₹1,200 among 4 people, ₹1,000 among 3 people, 1 person, and 0 people. Use assertAlmostEqual for one of the checks.

Show solution
Python
import unittest

def split_bill(total, people):
    if people < 1:
        raise ValueError("at least one person must pay")
    return round(total / people, 2)

class TestSplitBill(unittest.TestCase):
    def test_even_split(self):
        self.assertEqual(split_bill(1200, 4), 300.0)

    def test_uneven_split_is_rounded(self):
        self.assertAlmostEqual(split_bill(1000, 3), 333.33, places=2)

    def test_one_person_pays_everything(self):
        self.assertEqual(split_bill(850, 1), 850.0)

    def test_zero_people_is_refused(self):
        with self.assertRaises(ValueError):
            split_bill(500, 0)

suite = unittest.TestLoader().loadTestsFromTestCase(TestSplitBill)
unittest.TextTestRunner(verbosity=2).run(suite)

All four tests pass. The zero-people test protects against a ZeroDivisionError reaching the user: the function raises a clear ValueError before it ever divides.

Quick Quiz

1. Which method name will unittest run as a test?

2. When does setUp() run?

3. What is the benefit of self.subTest() inside a loop?

4. What does E mean in unittest's short output?

Learn Software Testing with a mentor

Free lessons get you started. Our Software Testing training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call