Unit Testing #

Code that isn’t tested is code you don’t dare refactor. Unit tests are the safety net that lets you change an implementation with confidence — as long as all tests stay green, the program’s behavior hasn’t changed. Python’s built-in unittest module is a complete tool for writing automated tests: you define the conditions that must hold, run the test runner, and Python reports which tests pass and which fail, along with the reasons.

Unit Test Anatomy #

Every unit test is built from three parts, often called the Arrange-Act-Assert pattern:

def test_something(self):
    # Arrange: prepare the data and initial conditions
    calculator = Calculator()

    # Act: run the code you want to test
    result = calculator.add(3, 7)

    # Assert: verify the result matches expectations
    self.assertEqual(result, 10)

This structure makes tests easy to read and understand — anyone looking at a test can immediately see what’s being tested, how, and what’s expected.

To visualize how a unit test is structured using the AAA (Arrange-Act-Assert) cycle, look at the diagram below:

flowchart TD
    Arrange["Arrange: Prepare the Test State/Data (Object Initialization, Mocking)"] --> Act["Act: Run the Code/Function Under Test"]
    Act --> Assert["Assert: Verify the Result (Check Return Values / State)"]

Basic Test Cases #

Each test is written as a method in a class inheriting from unittest.TestCase. Method names must start with test_ to be recognized by the test runner.

Here’s an example testing a real business-logic class — not just an add function:

import unittest

class ShoppingCart:
    def __init__(self):
        self.items = []

    def add_item(self, name, price, quantity=1):
        if price <= 0:
            raise ValueError("Price must be greater than 0")
        if quantity <= 0:
            raise ValueError("Quantity must be greater than 0")
        self.items.append({"name": name, "price": price, "quantity": quantity})

    def total(self):
        return sum(item["price"] * item["quantity"] for item in self.items)

    def item_count(self):
        return sum(item["quantity"] for item in self.items)

    def clear(self):
        self.items.clear()


class TestShoppingCart(unittest.TestCase):

    def test_new_cart_is_empty(self):
        cart = ShoppingCart()
        self.assertEqual(cart.total(), 0)
        self.assertEqual(cart.item_count(), 0)

    def test_add_one_item(self):
        cart = ShoppingCart()
        cart.add_item("Book", 50000)
        self.assertEqual(cart.total(), 50000)
        self.assertEqual(cart.item_count(), 1)

    def test_add_multiple_items(self):
        cart = ShoppingCart()
        cart.add_item("Book", 50000, quantity=2)
        cart.add_item("Pen", 5000, quantity=3)
        self.assertEqual(cart.total(), 115000)  # (50000*2) + (5000*3)
        self.assertEqual(cart.item_count(), 5)

    def test_clear_cart(self):
        cart = ShoppingCart()
        cart.add_item("Book", 50000)
        cart.clear()
        self.assertEqual(cart.total(), 0)

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

Choosing the Right Assertions #

unittest provides many assertion methods. Using a specific assertion produces far more informative error messages when a test fails.

import unittest

class TestAssertions(unittest.TestCase):

    def test_equality(self):
        self.assertEqual(2 + 2, 4)          # a == b
        self.assertNotEqual(2 + 2, 5)       # a != b

    def test_boolean(self):
        self.assertTrue(5 > 3)              # bool(x) is True
        self.assertFalse(3 > 5)             # bool(x) is False

    def test_identity(self):
        self.assertIsNone(None)             # x is None
        self.assertIsNotNone("exists")      # x is not None

    def test_membership(self):
        self.assertIn("a", ["a", "b", "c"])       # a in b
        self.assertNotIn("z", ["a", "b", "c"])    # a not in b

    def test_type(self):
        self.assertIsInstance(42, int)      # isinstance(a, b)
        self.assertIsInstance("hello", str)

    def test_float(self):
        # assertEqual fails for floats due to precision
        # ANTI-PATTERN:
        # self.assertEqual(0.1 + 0.2, 0.3)  → can fail!
        # CORRECT: use assertAlmostEqual
        self.assertAlmostEqual(0.1 + 0.2, 0.3, places=10)

    def test_collections(self):
        self.assertListEqual([1, 2, 3], [1, 2, 3])
        self.assertDictEqual({"a": 1}, {"a": 1})
        self.assertSetEqual({1, 2, 3}, {3, 2, 1})

Testing Exceptions #

One of the most important things often skipped: making sure the code raises the right exception under the wrong conditions.

import unittest

class TestCartExceptions(unittest.TestCase):

    def test_zero_price_should_error(self):
        cart = ShoppingCart()
        # assertRaises verifies the exception raised
        with self.assertRaises(ValueError):
            cart.add_item("Book", 0)

    def test_negative_price_should_error(self):
        cart = ShoppingCart()
        with self.assertRaises(ValueError):
            cart.add_item("Book", -1000)

    def test_zero_quantity_should_error(self):
        cart = ShoppingCart()
        with self.assertRaises(ValueError):
            cart.add_item("Book", 50000, quantity=0)

    def test_price_error_message(self):
        cart = ShoppingCart()
        # assertRaisesRegex: verify the exception message too
        with self.assertRaisesRegex(ValueError, "Price must be greater than 0"):
            cart.add_item("Book", -100)

Fixtures: setUp and tearDown #

Fixtures are code run before and after each test to prepare consistent initial conditions. Without fixtures, you’d repeat the same preparation code in every test method.

import unittest

class TestCartWithFixture(unittest.TestCase):

    def setUp(self):
        """Runs before EVERY test method."""
        self.cart = ShoppingCart()
        # every test starts with a clean cart + initial items
        self.cart.add_item("Python Book", 120000, quantity=1)
        self.cart.add_item("Pen", 5000, quantity=3)

    def tearDown(self):
        """Runs after EVERY test method — clean up resources."""
        # for simple objects tearDown isn't needed
        # but it's important for closing files, DB connections, etc.
        pass

    def test_initial_total(self):
        # setUp already added items, assert directly
        self.assertEqual(self.cart.total(), 135000)  # 120000 + (5000*3)

    def test_add_new_item(self):
        self.cart.add_item("Ruler", 8000)
        self.assertEqual(self.cart.total(), 143000)

    def test_clear(self):
        self.cart.clear()
        self.assertEqual(self.cart.total(), 0)
        # setUp resets for the next test — changes here don't affect other tests

Class-Level Fixtures #

For expensive setup (database connections, server initialization) that doesn’t need repeating per test:

import unittest

class TestWithClassSetup(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        """Runs ONCE before all tests in this class."""
        print("\n[Setup] Opening a database connection...")
        cls.db_connection = {"status": "connected"}  # simulated DB connection

    @classmethod
    def tearDownClass(cls):
        """Runs ONCE after all tests in this class finish."""
        print("\n[Teardown] Closing the database connection...")
        cls.db_connection = None

    def setUp(self):
        """Runs before each test — use the existing connection."""
        self.cursor = {"db": self.db_connection}

    def test_first_query(self):
        self.assertIsNotNone(self.cursor["db"])

    def test_second_query(self):
        self.assertEqual(self.cursor["db"]["status"], "connected")

Fixture execution order:

setUpClass()          ← once at the start
  setUp()             ← before test_first
    test_first()
  tearDown()          ← after test_first
  setUp()             ← before test_second
    test_second()
  tearDown()          ← after test_second
tearDownClass()       ← once at the end

subTest — Many Inputs in One Test #

When you need to test a function with many input combinations, subTest lets all cases run even if one fails — you get a complete report at once, instead of stopping at the first failure.

import unittest

def discount(total, code):
    """Compute the total after discount based on the code."""
    discount_map = {
        "SAVE10": 0.10,
        "SAVE20": 0.20,
        "SAVE50": 0.50,
    }
    percent = discount_map.get(code, 0)
    return total * (1 - percent)

class TestDiscount(unittest.TestCase):

    def test_all_discount_codes(self):
        cases = [
            # (total, code, expected_result)
            (100000, "SAVE10",  90000),
            (100000, "SAVE20",  80000),
            (100000, "SAVE50",  50000),
            (100000, "INVALID", 100000),  # unknown code → no discount
            (0,      "SAVE10",      0),  # zero total → stays zero
        ]
        for total, code, expected in cases:
            with self.subTest(total=total, code=code):
                self.assertEqual(discount(total, code), expected)

Organizing Test Files #

For real projects, separate tests into their own directory with a structure mirroring the source code:

project/
  ├── src/
  │   ├── __init__.py
  │   ├── cart.py
  │   ├── product.py
  │   └── payment.py
  └── tests/
      ├── __init__.py
      ├── test_cart.py      ← tests for cart.py
      ├── test_product.py   ← tests for product.py
      └── test_payment.py   ← tests for payment.py

Naming conventions: test files start with test_, test classes start with Test, test methods start with test_.


Running Tests from the Command Line #

# Run one test file
python -m unittest test_cart.py

# Run one specific test class
python -m unittest test_cart.TestShoppingCart

# Run one specific test method
python -m unittest test_cart.TestShoppingCart.test_initial_total

# Discover and run all tests in the tests/ directory
python -m unittest discover -s tests/ -p "test_*.py"

# Verbose mode — show the name of every test run
python -m unittest discover -v

# Example verbose output:
# test_new_cart_is_empty (test_cart.TestShoppingCart) ... ok
# test_add_one_item (test_cart.TestShoppingCart) ... ok
# test_initial_total (test_cart.TestCartWithFixture) ... ok
# ----------------------------------------------------------------------
# Ran 3 tests in 0.001s
# OK

unittest vs pytest #

unittest is a built-in module available with no installation. However, many teams choose pytest because its syntax is more concise.

# unittest — needs a class and self.assertEqual
class TestAdd(unittest.TestCase):
    def test_add_positive(self):
        self.assertEqual(add(2, 3), 5)

# pytest — just a plain function + standard Python assert
def test_add_positive():
    assert add(2, 3) == 5
Featureunittestpytest
InstallationBuilt into Pythonpip install pytest
SyntaxVerbose (class-based)Concise (function-based)
Assertionsself.assertEqualPlain Python assert
FixturessetUp/tearDown@pytest.fixture (more flexible)
ParametrizesubTest@pytest.mark.parametrize
OutputLimitedMore informative on failure
CompatibilityCan also run unittest tests
pytest can run tests written with unittest without any changes. So you can start with unittest and switch to pytest anytime — or use both in the same project.

Summary #

  • The Arrange-Act-Assert pattern — every test has three parts: prepare the data, run the code, verify the result.
  • Test names should be descriptivetest_negative_price_should_error is better than test_error; tests are living documentation of your code.
  • Use specific assertionsassertRaises, assertIn, assertAlmostEqual, assertIsNone produce far more informative error messages than assertTrue alone.
  • assertRaises to test exceptions — make sure the code raises the right exception under wrong conditions, not just works under right ones.
  • setUp/tearDown for isolation — every test must start from a clean state; changes in one test must not affect another.
  • setUpClass/tearDownClass for expensive resources — database connections or servers that don’t need reopening for every test.
  • subTest for many input cases — all cases run even if one fails; you get a complete report at once.
  • Separate tests into a tests/ directory — test file structure mirrors the source code structure for easy navigation.

← Previous: Web Servers   Next: Mocking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact