Testing in Python — white box, black box, TDD and unittest from scratch
Testing in Python is the FP2 topic that takes longest to get started with — not because it’s technically difficult, but because at first it feels like you’re learning to write code about code. Why write tests when you can just run the program and see if it works?
This article answers that question properly, then covers everything you need: white box and black box testing, unittest from scratch, test organisation and TDD (Test-Driven Development).
Table of Contents
Why testing exists — what it actually solves
Imagine you’ve built a 500-line program. It works perfectly. Then you add a new feature and now something else breaks that was working before. You spend two hours hunting the bug. That’s the problem testing solves.
Tests are a safety net. They don’t prevent you from breaking things — they tell you immediately when you’ve broken something, what you broke, and where. Without tests you discover breakages when running the program manually. With tests you discover them in seconds after every change.
The other thing tests give you: confidence. When you refactor code (rewrite it cleaner without changing what it does), tests confirm the behaviour hasn’t changed. Without tests, refactoring is gambling.
White box vs black box testing
These are two strategies for designing tests — not two different tools.
Black box testing treats the function as a black box. You don’t care how it’s implemented — you only care about inputs and outputs. You test the function’s contract: given these inputs, what outputs should it produce? This is what most beginners do instinctively:
# Black box — test the contract, not the implementation
def test_add():
assert add(2, 3) == 5 # basic case
assert add(-1, 1) == 0 # negatives
assert add(0, 0) == 0 # edge case: both zero
assert add(1000, 1000) == 2000 # large numbers
White box testing (also called glass box or structural testing) tests with knowledge of the implementation. You look at the code and test the branches, conditions and paths inside it. Every if, every except, every loop edge case gets a test:
# White box — we know the implementation has branches
def classify_grade(grade):
if grade >= 9:
return 'Outstanding'
elif grade >= 7:
return 'Merit'
elif grade >= 5:
return 'Passed'
else:
return 'Failed'
# Test every branch
def test_classify_grade():
assert classify_grade(9.5) == 'Outstanding' # first branch
assert classify_grade(9.0) == 'Outstanding' # boundary: exactly 9
assert classify_grade(8.9) == 'Merit' # second branch
assert classify_grade(7.0) == 'Merit' # boundary: exactly 7
assert classify_grade(6.9) == 'Passed' # third branch
assert classify_grade(5.0) == 'Passed' # boundary: exactly 5
assert classify_grade(4.9) == 'Failed' # fourth branch (else)
assert classify_grade(0) == 'Failed' # boundary: zero
In practice you use both — black box for the overall contract and white box to make sure every code path is covered. The boundaries are always the most important test cases: values right at the edge of a condition (5.0, 4.9, 9.0, 8.9) catch the off-by-one errors that slip through casual testing.
The unittest module
unittest is Python’s standard testing library — it’s included with Python, you don’t need to install anything. Every test file follows the same structure:
import unittest
class TestMyFunction(unittest.TestCase):
def test_something(self):
# Arrange
input_value = 5
# Act
result = my_function(input_value)
# Assert
self.assertEqual(result, 25)
if __name__ == '__main__':
unittest.main()
Three elements:
Test class — inherits from unittest.TestCase. Groups related tests together. By convention named Test + the thing you’re testing.
Test methods — must start with test_. Each method is one test. Convention: test_ + what behaviour you’re verifying.
Assertions — methods inherited from TestCase that check conditions.
The assertion methods
unittest has many assertion methods. These are the ones you’ll use most:
self.assertEqual(actual, expected) # actual == expected self.assertNotEqual(actual, expected) # actual != expected self.assertTrue(condition) # bool(condition) is True self.assertFalse(condition) # bool(condition) is False self.assertIsNone(value) # value is None self.assertIsNotNone(value) # value is not None self.assertIn(member, container) # member in container self.assertNotIn(member, container) # member not in container self.assertIsInstance(obj, type) # isinstance(obj, type) self.assertRaises(ExceptionType, func, *args) # func(*args) raises ExceptionType self.assertAlmostEqual(a, b, places=7) # |a-b| < 10^(-places) self.assertGreater(a, b) # a > b self.assertGreaterEqual(a, b) # a >= b self.assertLess(a, b) # a < b self.assertLessEqual(a, b) # a <= b
The most important: assertEqual for most things, assertRaises for exceptions, assertAlmostEqual for floats (because 0.1 + 0.2 != 0.3 in floating point).
A complete first test suite
import unittest
import math
def circle_area(radius):
if radius < 0:
raise ValueError(f'Radius cannot be negative: {radius}')
if not isinstance(radius, (int, float)):
raise TypeError(f'Radius must be a number: {type(radius).__name__}')
return math.pi * radius ** 2
def circle_perimeter(radius):
if radius < 0:
raise ValueError(f'Radius cannot be negative: {radius}')
return 2 * math.pi * radius
class TestCircle(unittest.TestCase):
# Test normal cases
def test_area_positive_radius(self):
result = circle_area(5)
self.assertAlmostEqual(result, 78.53981633974483, places=5)
def test_area_radius_one(self):
self.assertAlmostEqual(circle_area(1), math.pi, places=10)
def test_area_radius_zero(self):
self.assertEqual(circle_area(0), 0)
def test_perimeter_positive(self):
result = circle_perimeter(3)
self.assertAlmostEqual(result, 18.84955592153876, places=5)
# Test edge cases
def test_area_float_radius(self):
result = circle_area(2.5)
self.assertAlmostEqual(result, math.pi * 6.25, places=10)
# Test exception cases
def test_area_negative_radius(self):
with self.assertRaises(ValueError):
circle_area(-1)
def test_area_negative_radius_message(self):
with self.assertRaises(ValueError) as context:
circle_area(-5)
self.assertIn('-5', str(context.exception))
def test_area_string_radius(self):
with self.assertRaises(TypeError):
circle_area('5')
def test_area_none_radius(self):
with self.assertRaises(TypeError):
circle_area(None)
def test_perimeter_negative_radius(self):
with self.assertRaises(ValueError):
circle_perimeter(-3)
if __name__ == '__main__':
unittest.main(verbosity=2)
Run with:
python -m unittest test_circle.py -v
Output:
test_area_float_radius ... ok test_area_negative_radius ... ok test_area_negative_radius_message ... ok test_area_none_radius ... ok test_area_positive_radius ... ok test_area_radius_one ... ok test_area_radius_zero ... ok test_area_string_radius ... ok test_perimeter_negative_radius ... ok test_perimeter_positive ... ok ---------------------------------------------------------------------- Ran 10 tests in 0.002s OK
Each dot is a passing test. F would be a failing test (assertion failed). E would be an error (exception raised unexpectedly).
setUp and tearDown — test fixtures
setUp runs before every test method. tearDown runs after every test method. Use them to create objects that every test needs — so you don’t repeat the setup code in every test:
class TestBankAccount(unittest.TestCase):
def setUp(self):
"""Called before every test — creates a fresh account."""
self.account = BankAccount('Sergio', 1000)
def tearDown(self):
"""Called after every test — cleanup if needed."""
pass # nothing to clean up here
def test_initial_balance(self):
self.assertEqual(self.account.balance, 1000)
def test_deposit(self):
self.account.deposit(500)
self.assertEqual(self.account.balance, 1500)
def test_withdraw(self):
self.account.withdraw(200)
self.assertEqual(self.account.balance, 800)
def test_withdraw_insufficient_funds(self):
with self.assertRaises(ValueError):
self.account.withdraw(2000)
def test_deposit_negative_raises(self):
with self.assertRaises(ValueError):
self.account.deposit(-100)
Each test gets a fresh BankAccount with balance 1000. The tests are fully independent — the order you run them doesn’t matter, and a failure in one doesn’t affect the others.
setUpClass and tearDownClass
For expensive setup (database connections, file creation) that only needs to happen once for the whole class:
class TestWithExpensiveSetup(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Runs once before all tests in this class."""
cls.shared_resource = create_expensive_resource()
@classmethod
def tearDownClass(cls):
"""Runs once after all tests in this class."""
cls.shared_resource.close()
Test-Driven Development (TDD)
TDD is a development methodology where you write the test before the code. The cycle is:
1. Write a failing test (RED) 2. Write the minimum code to make it pass (GREEN) 3. Refactor the code without breaking the test (REFACTOR) 4. Repeat
Example — building a Stack class using TDD:
Step 1 — RED: write a test for behaviour that doesn’t exist yet
class TestStack(unittest.TestCase):
def test_new_stack_is_empty(self):
s = Stack()
self.assertTrue(s.is_empty) # Stack doesn't exist yet → test fails
Step 2 — GREEN: write the minimum code to make it pass
class Stack:
def __init__(self):
self._items = []
@property
def is_empty(self):
return len(self._items) == 0
Step 3 — REFACTOR: clean up if needed, test still passes
No refactoring needed here. Add the next test:
def test_push_increases_size(self):
s = Stack()
s.push(1)
self.assertEqual(s.size, 1)
s.push(2)
self.assertEqual(s.size, 2)
This fails — add push and size:
def push(self, item):
self._items.append(item)
@property
def size(self):
return len(self._items)
Continue adding tests one by one — pop, peek, pop on empty stack — and only writing code to make each one pass. By the time you’ve written all the tests, you have a fully tested Stack class.
TDD feels slow at first. The insight is that it actually makes you faster overall — because the time you save debugging is much greater than the time you spend writing tests.
Organising tests
For a real project, keep tests in a separate file or directory:
my_project/ ├── stack.py ← production code ├── bank_account.py ← production code ├── test_stack.py ← tests for stack.py ├── test_bank.py ← tests for bank_account.py
Or for larger projects:
my_project/
├── src/
│ ├── stack.py
│ └── bank_account.py
└── tests/
├── test_stack.py
└── test_bank.py
Run all tests in a directory:
python -m unittest discover tests/
What makes a good test
A test is good when:
Isolated — doesn't depend on other tests or external state Repeatable — same result every time, on any machine Fast — runs in milliseconds, not seconds Clear — the test name describes exactly what it verifies Specific — one assertion per test (or closely related assertions)
The test name is documentation. test_withdraw_insufficient_funds_raises_ValueError is better than test_withdraw_2 — someone reading it knows exactly what it verifies without reading the body.
What not to test
# Don't test the language itself
def test_list_append():
lst = []
lst.append(1)
self.assertEqual(lst, [1]) # you're testing Python's append — not your code
# Don't test implementation details — test behaviour
# Wrong: test that self._balance is a Decimal
# Right: test that balance is correct after operations
# Don't test private methods directly
# Wrong: test _validate_amount
# Right: test that deposit(-1) raises ValueError
Visualise with Python Tutor
Copy this code into pythontutor.com and step through it:
import unittest
def add(a, b):
return a + b
class TestAdd(unittest.TestCase):
def test_positive(self):
self.assertEqual(add(2, 3), 5)
def test_negative(self):
self.assertEqual(add(-1, -1), -2)
def test_zero(self):
self.assertEqual(add(0, 0), 0)
suite = unittest.TestLoader().loadTestsFromTestCase(TestAdd)
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)
Step through and observe what happens when runner.run(suite) is called. Python calls each test_* method in turn. For each one: setUp runs (here it does nothing), then the test method runs, then tearDown runs. Inside test_positive, assertEqual(add(2, 3), 5) calls add(2, 3) → returns 5 → compares with 5 → equal → test passes. If the assertion fails, unittest catches the AssertionError internally and records the test as failed — the other tests still run.
Quick summary
# STRUCTURE
import unittest
class TestMyClass(unittest.TestCase):
def setUp(self): # runs before each test
self.obj = MyClass()
def tearDown(self): # runs after each test
pass
def test_behaviour(self):
# Arrange — set up
input_val = 5
# Act — execute
result = self.obj.method(input_val)
# Assert — verify
self.assertEqual(result, 25)
# RUN
# python -m unittest test_file.py -v
# python -m unittest discover tests/
# ASSERTIONS
self.assertEqual(a, b) # a == b
self.assertNotEqual(a, b) # a != b
self.assertTrue(x) # bool(x) is True
self.assertFalse(x) # bool(x) is False
self.assertIsNone(x) # x is None
self.assertIn(x, container) # x in container
self.assertIsInstance(x, Type) # isinstance(x, Type)
self.assertAlmostEqual(a, b, places=7) # for floats
self.assertRaises(Error, func, args) # func(args) raises Error
# assertRaises as context manager
with self.assertRaises(ValueError) as ctx:
my_func(-1)
self.assertIn('message', str(ctx.exception))
# WHITE BOX vs BLACK BOX
# Black box: test inputs → outputs without caring about implementation
# White box: test every branch, condition, edge case using code knowledge
# BOUNDARY VALUES — always test these
# value = threshold → on the boundary
# value = threshold - 1 → just below
# value = threshold + 1 → just above
# empty input, zero, None → special cases
# TDD CYCLE
# 1. Write failing test (RED)
# 2. Write minimum code to pass (GREEN)
# 3. Refactor without breaking tests (REFACTOR)
# 4. Repeat
# GOOD TEST NAME
# test_withdraw_negative_amount_raises_ValueError ← clear
# test_withdraw_2 ← bad
# setUpClass / tearDownClass — once for the whole class
@classmethod
def setUpClass(cls):
cls.shared = create_expensive_resource()
@classmethod
def tearDownClass(cls):
cls.shared.close()
In the next article we practice testing with real test suites — testing the BankAccount, Stack and Fraction classes from previous articles.

2 Comments