Testing with unittest

A test is a small program that checks another program. unittest ships with Python, finds your tests automatically and tells you exactly what broke.

Why test

def net_price(amount, tax_rate=0.18):
    return amount * (1 + tax_rate)


print(net_price(100))         # 118.0 - looks right

One check, run once, by hand, and never repeated. A test is that same check written down so it runs every time the code changes. The value is not proving the code works today; it is finding out the day it stops working.

The first test

# File: pricing.py
def net_price(amount, tax_rate=0.18):
    if amount < 0:
        raise ValueError("amount cannot be negative")
    return round(amount * (1 + tax_rate), 2)
# File: test_pricing.py
import unittest

from pricing import net_price


class TestNetPrice(unittest.TestCase):
    def test_default_rate(self):
        self.assertEqual(net_price(100), 118.0)

    def test_custom_rate(self):
        self.assertEqual(net_price(100, 0.05), 105.0)

    def test_zero(self):
        self.assertEqual(net_price(0), 0.0)

    def test_negative_raises(self):
        with self.assertRaises(ValueError):
            net_price(-1)


if __name__ == "__main__":
    unittest.main()
$ python -m unittest test_pricing -v
test_custom_rate (test_pricing.TestNetPrice) ... ok
test_default_rate (test_pricing.TestNetPrice) ... ok
test_negative_raises (test_pricing.TestNetPrice) ... ok
test_zero (test_pricing.TestNetPrice) ... ok

Ran 4 tests in 0.001s

OK

The rules unittest relies on

  • The file name starts with test_.
  • The class inherits from unittest.TestCase.
  • Each test method name starts with test_.
  • Each test uses an assert... method to state what should be true.

Running tests

python -m unittest                        discover and run everything
python -m unittest -v                     verbose
python -m unittest discover -s tests      look in a specific folder
python -m unittest test_pricing           one module
python -m unittest test_pricing.TestNetPrice          one class
python -m unittest test_pricing.TestNetPrice.test_zero  one test
python -m unittest -f                     stop at the first failure

The assertions

MethodChecks
assertEqual(a, b)a == b
assertNotEqual(a, b)a != b
assertTrue(x) / assertFalse(x)Truthiness
assertIs(a, b) / assertIsNotIdentity
assertIsNone(x) / assertIsNotNonex is None
assertIn(a, b) / assertNotInMembership
assertIsInstance(a, T)Type
assertAlmostEqual(a, b)Floats, to 7 places by default
assertGreater(a, b), assertLessOrdering
assertRaises(E)An exception is raised
assertCountEqual(a, b)Same items, any order
import unittest


class TestAssertions(unittest.TestCase):
    def test_floats(self):
        self.assertAlmostEqual(0.1 + 0.2, 0.3)          # not assertEqual
        self.assertAlmostEqual(1.23456, 1.23459, places=4)

    def test_collections(self):
        self.assertCountEqual([1, 2, 3], [3, 1, 2])     # order does not matter
        self.assertListEqual([1, 2], [1, 2])
        self.assertDictEqual({"a": 1}, {"a": 1})

    def test_exception_details(self):
        with self.assertRaises(ValueError) as context:
            int("abc")
        self.assertIn("invalid literal", str(context.exception))

        with self.assertRaisesRegex(ValueError, r"invalid literal"):
            int("abc")

    def test_message(self):
        total = 5
        self.assertEqual(total, 5, f"total was {total}, expected 5")
Use the specific assertion. assertEqual(a, b) prints both values when it fails; assertTrue(a == b) prints only "False is not true", which tells you nothing.

Setup and teardown

import unittest
import tempfile
from pathlib import Path


class TestFileProcessing(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        """Runs once, before every test in the class."""
        cls.folder = tempfile.TemporaryDirectory()
        cls.root = Path(cls.folder.name)

    @classmethod
    def tearDownClass(cls):
        cls.folder.cleanup()

    def setUp(self):
        """Runs before EACH test."""
        self.path = self.root / "sample.txt"
        self.path.write_text("line one\nline two\n", encoding="utf-8")

    def tearDown(self):
        """Runs after EACH test, even if it failed."""
        self.path.unlink(missing_ok=True)

    def test_line_count(self):
        self.assertEqual(len(self.path.read_text(encoding="utf-8").splitlines()), 2)

    def test_content(self):
        self.assertIn("line one", self.path.read_text(encoding="utf-8"))

Each test gets a fresh setUp, so tests never depend on each other or on the order they run in. That independence is what makes a failure meaningful.

Testing a class

class Stack:
    def __init__(self):
        self._items = []

    def push(self, item):
        self._items.append(item)

    def pop(self):
        if not self._items:
            raise IndexError("pop from an empty stack")
        return self._items.pop()

    def peek(self):
        if not self._items:
            raise IndexError("peek at an empty stack")
        return self._items[-1]

    def __len__(self):
        return len(self._items)
import unittest


class TestStack(unittest.TestCase):
    def setUp(self):
        self.stack = Stack()

    def test_new_stack_is_empty(self):
        self.assertEqual(len(self.stack), 0)
        self.assertFalse(self.stack)

    def test_push_increases_length(self):
        self.stack.push(1)
        self.assertEqual(len(self.stack), 1)

    def test_pop_returns_last_pushed(self):
        self.stack.push(1)
        self.stack.push(2)
        self.assertEqual(self.stack.pop(), 2)
        self.assertEqual(len(self.stack), 1)

    def test_peek_does_not_remove(self):
        self.stack.push(1)
        self.assertEqual(self.stack.peek(), 1)
        self.assertEqual(len(self.stack), 1)

    def test_pop_empty_raises(self):
        with self.assertRaises(IndexError):
            self.stack.pop()

    def test_order_is_last_in_first_out(self):
        for value in [1, 2, 3]:
            self.stack.push(value)
        self.assertEqual([self.stack.pop() for _ in range(3)], [3, 2, 1])

What to test

CaseExample
The normal pathnet_price(100)
The boundaries0, 1, the maximum, the minimum
Empty input"", [], {}
Invalid inputNegative, wrong type, None
ErrorsDoes it raise what it promises?
Known bugsA test for every bug you fix
import unittest


def split_name(full_name):
    parts = full_name.strip().split()
    if not parts:
        raise ValueError("name cannot be empty")
    if len(parts) == 1:
        return parts[0], ""
    return parts[0], parts[-1]


class TestSplitName(unittest.TestCase):
    def test_two_parts(self):
        self.assertEqual(split_name("Meera Nair"), ("Meera", "Nair"))

    def test_three_parts(self):
        self.assertEqual(split_name("Meera Sunita Nair"), ("Meera", "Nair"))

    def test_single_name(self):
        self.assertEqual(split_name("Meera"), ("Meera", ""))

    def test_extra_whitespace(self):
        self.assertEqual(split_name("  Meera   Nair  "), ("Meera", "Nair"))

    def test_empty_raises(self):
        for bad in ["", "   ", "\n"]:
            with self.subTest(value=bad):
                with self.assertRaises(ValueError):
                    split_name(bad)

subTest runs each case separately inside one test method, so all of them are reported rather than stopping at the first failure.

Table driven tests

import unittest


def grade(score):
    if not 0 <= score <= 100:
        raise ValueError("score must be between 0 and 100")
    for threshold, letter in [(90, "A"), (80, "B"), (70, "C"), (40, "D")]:
        if score >= threshold:
            return letter
    return "F"


class TestGrade(unittest.TestCase):
    CASES = [
        (100, "A"), (90, "A"), (89, "B"), (80, "B"),
        (79, "C"), (70, "C"), (69, "D"), (40, "D"),
        (39, "F"), (0, "F"),
    ]

    def test_grades(self):
        for score, expected in self.CASES:
            with self.subTest(score=score):
                self.assertEqual(grade(score), expected)

    def test_out_of_range(self):
        for score in [-1, 101]:
            with self.subTest(score=score):
                with self.assertRaises(ValueError):
                    grade(score)

Note that the cases sit exactly on every boundary. Off by one errors live at boundaries, so that is where the tests belong.

Mocking

import unittest
from unittest.mock import Mock, patch


def fetch_user(client, user_id):
    response = client.get(f"/users/{user_id}")
    if response["status"] != 200:
        raise LookupError(f"user {user_id} not found")
    return response["data"]


class TestFetchUser(unittest.TestCase):
    def test_success(self):
        client = Mock()
        client.get.return_value = {"status": 200, "data": {"name": "Meera"}}

        result = fetch_user(client, 1)

        self.assertEqual(result["name"], "Meera")
        client.get.assert_called_once_with("/users/1")

    def test_not_found(self):
        client = Mock()
        client.get.return_value = {"status": 404, "data": None}

        with self.assertRaises(LookupError):
            fetch_user(client, 99)
import unittest
from unittest.mock import patch
from datetime import date


def greeting(name):
    hour = __import__("datetime").datetime.now().hour
    part = "morning" if hour < 12 else "afternoon"
    return f"Good {part}, {name}"


class TestWithPatch(unittest.TestCase):
    @patch("builtins.input", return_value="Meera")
    def test_input(self, mock_input):
        self.assertEqual(input("name? "), "Meera")
        mock_input.assert_called_once()

    def test_print(self):
        with patch("builtins.print") as mock_print:
            print("hello")
            mock_print.assert_called_once_with("hello")

Mocking replaces something slow, external or unpredictable - a network, a clock, a database, user input - with a stand in you control. Use it for the boundaries of your program, not for its own logic.

Skipping and expected failures

import unittest
import sys


class TestPlatform(unittest.TestCase):
    @unittest.skip("not implemented yet")
    def test_future_feature(self):
        pass

    @unittest.skipIf(sys.platform == "win32", "posix only")
    def test_posix_paths(self):
        pass

    @unittest.skipUnless(sys.version_info >= (3, 10), "needs Python 3.10")
    def test_match_statement(self):
        pass

    @unittest.expectedFailure
    def test_known_bug(self):
        self.assertEqual(1, 2)

Reading a failure

FAIL: test_default_rate (test_pricing.TestNetPrice)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_pricing.py", line 8, in test_default_rate
    self.assertEqual(net_price(100), 118.0)
AssertionError: 120.0 != 118.0

Ran 4 tests in 0.001s

FAILED (failures=1)
  • FAIL means an assertion was false: the code ran and gave the wrong answer.
  • ERROR means an exception was raised: the code crashed.
  • The assertion message shows both values, which usually identifies the bug immediately.

Layout

project/
    notesapp/
        __init__.py
        pricing.py
        storage.py
    tests/
        __init__.py
        test_pricing.py
        test_storage.py
$ python -m unittest discover
$ python -m unittest discover -s tests -p "test_*.py" -v

Coverage of behaviour, not lines

import unittest


def apply_discount(price, percent):
    if percent < 0 or percent > 100:
        raise ValueError("percent must be between 0 and 100")
    return round(price * (1 - percent / 100), 2)


class TestDiscount(unittest.TestCase):
    def test_typical(self):
        self.assertEqual(apply_discount(100, 20), 80.0)

    def test_no_discount(self):
        self.assertEqual(apply_discount(100, 0), 100.0)

    def test_full_discount(self):
        self.assertEqual(apply_discount(100, 100), 0.0)

    def test_rounding(self):
        self.assertEqual(apply_discount(19.99, 15), 16.99)

    def test_invalid_percent(self):
        for percent in [-1, 101]:
            with self.subTest(percent=percent):
                with self.assertRaises(ValueError):
                    apply_discount(100, percent)

Every line of apply_discount is covered by the first test alone. The other four exist because coverage measures lines executed, not cases considered.

Common mistakes

  • Testing implementation details instead of behaviour, so every refactor breaks the tests.
  • Writing tests that depend on each other or on execution order.
  • Using assertTrue(a == b) instead of assertEqual.
  • Comparing floats with assertEqual.
  • Leaving files, directories or state behind, so the second run behaves differently.
  • Naming a method check_something, which is never discovered.
  • Testing only the happy path.

Best practices

  • One behaviour per test, and a name that states it: test_pop_empty_raises.
  • Arrange, act, assert - in that order, visibly.
  • Test boundaries, empty input and error cases.
  • Use setUp for shared preparation and clean up in tearDown.
  • Write a failing test for every bug before fixing it.
  • Keep tests fast; mock anything slow or external.

Practice

  1. Write tests for a function converting temperatures, including negative and zero.
  2. Write tests for a Queue class covering empty, one item and many.
  3. Use subTest to check ten boundary values in one test method.
  4. Mock input and test a function that prompts the user.
  5. Find a bug in your own code, write a failing test for it, then fix it.

Conclusion

A test states what the code should do and fails loudly when it stops doing it. Name each one after the behaviour it checks, keep it independent, test the boundaries and errors, and add one for every bug you find.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

Sorting Algorithms

Python sorts for you in n log n. Implementing bubble, insertion, merge and quick sort is still worth doing, because it teaches how algorithms are comp...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.