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
| Feature | unittest | pytest |
|---|---|---|
| Installation | Built into Python | pip install pytest |
| Syntax | Verbose (class-based) | Concise (function-based) |
| Assertions | self.assertEqual | Plain Python assert |
| Fixtures | setUp/tearDown | @pytest.fixture (more flexible) |
| Parametrize | subTest | @pytest.mark.parametrize |
| Output | Limited | More informative on failure |
| Compatibility | — | Can also run unittest tests |
pytestcan run tests written withunittestwithout any changes. So you can start withunittestand switch topytestanytime — 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 descriptive —
test_negative_price_should_erroris better thantest_error; tests are living documentation of your code.- Use specific assertions —
assertRaises,assertIn,assertAlmostEqual,assertIsNoneproduce far more informative error messages thanassertTruealone.assertRaisesto test exceptions — make sure the code raises the right exception under wrong conditions, not just works under right ones.setUp/tearDownfor isolation — every test must start from a clean state; changes in one test must not affect another.setUpClass/tearDownClassfor expensive resources — database connections or servers that don’t need reopening for every test.subTestfor 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.