Python TDD Principles Practice Problems & Exercises
Practice: TDD Principles
← Back to lessonEasy
Task: Run the code as-is and observe the failure. This is the RED phase of TDD. Then in the solution, add just enough code to make it pass (GREEN), without over-engineering.
Why it matters: The red phase proves your test is actually testing something. If the test passes before you write any code, the test is worthless.
Solution:
import unittest
class TestGreet(unittest.TestCase):
def test_greet_returns_string(self):
result = greet("World")
self.assertEqual(result, "Hello, World!")
# GREEN: simplest possible implementation that passes
def greet(name):
return f"Hello, {name}!"
if __name__ == '__main__':
unittest.main(exit=False)
# Output: . (1 test passed)
import unittest
# Step 1: write the test BEFORE implementing greet()
class TestGreet(unittest.TestCase):
def test_greet_returns_string(self):
result = greet("World")
self.assertEqual(result, "Hello, World!")
# Step 2: stub — just enough for the test to run (but fail)
def greet(name):
pass # intentionally returns None → RED
if __name__ == '__main__':
unittest.main(exit=False)
Expected Output
FAIL: test_greet_returns_string\nAssertionError: None != 'Hello, World!'Hints
Hint 1: In TDD the test always comes first — before any implementation exists.
Hint 2: A failing test (red) confirms your test actually checks something.
Hint 3: The function stub should exist but return nothing useful yet.
Task: Implement fizzbuzz with only what is needed to pass test_regular_number. No Fizz, no Buzz — those tests don't exist yet. This is the green phase discipline.
Solution:
import unittest
class TestFizzBuzz(unittest.TestCase):
def test_regular_number(self):
self.assertEqual(fizzbuzz(1), "1")
# Minimum implementation: convert number to string
def fizzbuzz(n):
return str(n)
if __name__ == '__main__':
unittest.main()
import unittest
class TestFizzBuzz(unittest.TestCase):
def test_regular_number(self):
self.assertEqual(fizzbuzz(1), "1")
# Implement fizzbuzz with ONLY enough logic to pass test_regular_number.
# Do NOT implement Fizz/Buzz/FizzBuzz yet — no test requires them.
def fizzbuzz(n):
pass
if __name__ == '__main__':
unittest.main()
Expected Output
.\n----------------------------------------------------------------------\nRan 1 test in 0.001s\n\nOKHints
Hint 1: Write the MINIMUM code needed to pass. If the test only checks one case, a hard-coded return value is valid initially.
Hint 2: You expand the implementation only when a new failing test forces you to.
Hint 3: This discipline prevents over-engineering.
Task: Implement fizzbuzz to handle all four existing tests. Do not add FizzBuzz (15) logic — there is no test for it yet.
Solution:
import unittest
class TestFizzBuzz(unittest.TestCase):
def test_one(self):
self.assertEqual(fizzbuzz(1), "1")
def test_two(self):
self.assertEqual(fizzbuzz(2), "2")
def test_fizz(self):
self.assertEqual(fizzbuzz(3), "Fizz")
def test_buzz(self):
self.assertEqual(fizzbuzz(5), "Buzz")
def fizzbuzz(n):
if n % 3 == 0:
return "Fizz"
if n % 5 == 0:
return "Buzz"
return str(n)
if __name__ == '__main__':
unittest.main()
import unittest
class TestFizzBuzz(unittest.TestCase):
def test_one(self):
self.assertEqual(fizzbuzz(1), "1")
def test_two(self):
self.assertEqual(fizzbuzz(2), "2")
def test_fizz(self):
self.assertEqual(fizzbuzz(3), "Fizz")
def test_buzz(self):
self.assertEqual(fizzbuzz(5), "Buzz")
# Now a hard-coded return value won't work.
# Implement only what these 4 tests require.
def fizzbuzz(n):
pass
if __name__ == '__main__':
unittest.main()
Expected Output
....\n----------------------------------------------------------------------\nRan 4 tests in 0.001s\n\nOKHints
Hint 1: Triangulation: add a second failing test that the hard-coded solution cannot pass — this forces a general implementation.
Hint 2: Each test you add constrains the solution more precisely, like triangulating a GPS position.
Hint 3: Never generalise until at least two tests force you to.
Task: Implement Stack with only push and pop. Any additional method is a YAGNI violation — leave it out.
Solution:
import unittest
class TestStack(unittest.TestCase):
def test_push_and_pop(self):
s = Stack()
s.push(10)
self.assertEqual(s.pop(), 10)
class Stack:
def __init__(self):
self._data = []
def push(self, item):
self._data.append(item)
def pop(self):
return self._data.pop()
if __name__ == '__main__':
unittest.main()
import unittest
class TestStack(unittest.TestCase):
def test_push_and_pop(self):
s = Stack()
s.push(10)
self.assertEqual(s.pop(), 10)
# Implement Stack with ONLY push and pop.
# Do NOT add peek, size, is_empty, or clear — no tests demand them.
class Stack:
pass
if __name__ == '__main__':
unittest.main()
Expected Output
.\n----------------------------------------------------------------------\nRan 1 test in 0.001s\n\nOKHints
Hint 1: YAGNI = You Are Not Gonna Need It. Only implement what a current test demands.
Hint 2: Adding "obvious" features without a failing test is scope creep inside production code.
Hint 3: TDD enforces YAGNI structurally: no test = no code.
Medium
Task: First write a naive implementation (GREEN). Then refactor it to the cleaner form below while keeping all 5 tests passing.
Naive (GREEN):
def fizzbuzz(n):
if n % 15 == 0:
return "FizzBuzz"
elif n % 3 == 0:
return "Fizz"
elif n % 5 == 0:
return "Buzz"
else:
return str(n)
Refactored (still GREEN):
import unittest
class TestFizzBuzz(unittest.TestCase):
def test_one(self):
self.assertEqual(fizzbuzz(1), "1")
def test_fizz(self):
self.assertEqual(fizzbuzz(3), "Fizz")
def test_buzz(self):
self.assertEqual(fizzbuzz(5), "Buzz")
def test_fizzbuzz(self):
self.assertEqual(fizzbuzz(15), "FizzBuzz")
def test_twenty(self):
self.assertEqual(fizzbuzz(20), "Buzz")
def fizzbuzz(n):
parts = ""
if n % 3 == 0:
parts += "Fizz"
if n % 5 == 0:
parts += "Buzz"
return parts or str(n)
if __name__ == '__main__':
unittest.main()
import unittest
# Tests are pre-written — implement the full FizzBuzz including FizzBuzz(15)
class TestFizzBuzz(unittest.TestCase):
def test_one(self):
self.assertEqual(fizzbuzz(1), "1")
def test_fizz(self):
self.assertEqual(fizzbuzz(3), "Fizz")
def test_buzz(self):
self.assertEqual(fizzbuzz(5), "Buzz")
def test_fizzbuzz(self):
self.assertEqual(fizzbuzz(15), "FizzBuzz")
def test_twenty(self):
self.assertEqual(fizzbuzz(20), "Buzz")
# Step 1: GREEN — write the naive if/elif chain
# Step 2: REFACTOR — can you improve readability without breaking tests?
def fizzbuzz(n):
pass
if __name__ == '__main__':
unittest.main()
Expected Output
.....\n----------------------------------------------------------------------\nRan 5 tests in 0.001s\n\nOKHints
Hint 1: Refactor only when all tests are GREEN. Refactoring on red hides bugs.
Hint 2: Refactoring means improving structure without changing behaviour — the tests must still pass after.
Hint 3: Common refactors: extract method, rename variable, remove duplication.
Task: Implement Calculator with add, subtract, multiply, divide, and a history list that records each operation result.
Solution:
import unittest
class TestCalculator(unittest.TestCase):
def setUp(self):
self.calc = Calculator()
def test_add(self):
self.assertEqual(self.calc.add(2, 3), 5)
def test_subtract(self):
self.assertEqual(self.calc.subtract(10, 4), 6)
def test_multiply(self):
self.assertEqual(self.calc.multiply(3, 7), 21)
def test_divide(self):
self.assertAlmostEqual(self.calc.divide(10, 3), 3.333, places=3)
def test_divide_by_zero(self):
with self.assertRaises(ZeroDivisionError):
self.calc.divide(5, 0)
def test_history(self):
self.calc.add(1, 1)
self.calc.add(2, 2)
self.assertEqual(len(self.calc.history), 2)
class Calculator:
def __init__(self):
self.history = []
def _record(self, result):
self.history.append(result)
return result
def add(self, a, b):
return self._record(a + b)
def subtract(self, a, b):
return self._record(a - b)
def multiply(self, a, b):
return self._record(a * b)
def divide(self, a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero")
return self._record(a / b)
if __name__ == '__main__':
unittest.main()
import unittest
class TestCalculator(unittest.TestCase):
def setUp(self):
self.calc = Calculator()
def test_add(self):
self.assertEqual(self.calc.add(2, 3), 5)
def test_subtract(self):
self.assertEqual(self.calc.subtract(10, 4), 6)
def test_multiply(self):
self.assertEqual(self.calc.multiply(3, 7), 21)
def test_divide(self):
self.assertAlmostEqual(self.calc.divide(10, 3), 3.333, places=3)
def test_divide_by_zero(self):
with self.assertRaises(ZeroDivisionError):
self.calc.divide(5, 0)
def test_history(self):
self.calc.add(1, 1)
self.calc.add(2, 2)
self.assertEqual(len(self.calc.history), 2)
# Implement Calculator to pass all 6 tests
class Calculator:
pass
if __name__ == '__main__':
unittest.main()
Expected Output
......\n----------------------------------------------------------------------\nRan 6 tests in 0.001s\n\nOKHints
Hint 1: Write one test at a time, then implement just enough to pass it before moving to the next.
Hint 2: Let the tests drive the class API — method names, parameter types, and return types emerge from what you need to assert.
Hint 3: Each failing test is a specification.
Task: Implement clamp_age to satisfy all five boundary tests. The tests already exist — this shows how TDD boundary tests drive correct edge-case handling.
Solution:
import unittest
class TestClampAge(unittest.TestCase):
def test_normal(self):
self.assertEqual(clamp_age(25), 25)
def test_at_minimum(self):
self.assertEqual(clamp_age(0), 0)
def test_below_minimum(self):
self.assertEqual(clamp_age(-1), 0)
def test_at_maximum(self):
self.assertEqual(clamp_age(120), 120)
def test_above_maximum(self):
self.assertEqual(clamp_age(121), 120)
def clamp_age(age, min_age=0, max_age=120):
return max(min_age, min(age, max_age))
if __name__ == '__main__':
unittest.main()
import unittest
class TestClampAge(unittest.TestCase):
def test_normal(self):
self.assertEqual(clamp_age(25), 25)
def test_at_minimum(self):
self.assertEqual(clamp_age(0), 0)
def test_below_minimum(self):
self.assertEqual(clamp_age(-1), 0)
def test_at_maximum(self):
self.assertEqual(clamp_age(120), 120)
def test_above_maximum(self):
self.assertEqual(clamp_age(121), 120)
# Implement clamp_age(age, min_age=0, max_age=120)
def clamp_age(age, min_age=0, max_age=120):
pass
if __name__ == '__main__':
unittest.main()
Expected Output
.....\n----------------------------------------------------------------------\nRan 5 tests in 0.001s\n\nOKHints
Hint 1: Boundary value analysis: test at exactly the boundary (0, max, min), one below, and one above.
Hint 2: TDD encourages writing the boundary tests first — they reveal edge cases the implementation must handle.
Hint 3: Off-by-one errors are most common at boundaries.
Task: Implement sum_evens to pass all five tests. In your comments, note how you would have progressed incrementally from fake to generalised implementation.
Solution:
import unittest
class TestSumEvens(unittest.TestCase):
def test_empty(self):
self.assertEqual(sum_evens([]), 0)
def test_all_odds(self):
self.assertEqual(sum_evens([1, 3, 5]), 0)
def test_single_even(self):
self.assertEqual(sum_evens([4]), 4)
def test_mixed(self):
self.assertEqual(sum_evens([1, 2, 3, 4, 5, 6]), 12)
def test_negatives(self):
self.assertEqual(sum_evens([-2, -3, -4]), -6)
# Progression:
# Fake: return 0 → passes test_empty, test_all_odds only
# Step: return numbers[0] if even else 0 → test_single_even
# Generalise:
def sum_evens(numbers):
return sum(n for n in numbers if n % 2 == 0)
if __name__ == '__main__':
unittest.main()
import unittest
# All five tests are provided. Implement sum_evens incrementally:
# After test 1 only: return 0 (fake it)
# After test 2: return the single number if it's even
# After test 3+: generalise to filter + sum
class TestSumEvens(unittest.TestCase):
def test_empty(self):
self.assertEqual(sum_evens([]), 0)
def test_all_odds(self):
self.assertEqual(sum_evens([1, 3, 5]), 0)
def test_single_even(self):
self.assertEqual(sum_evens([4]), 4)
def test_mixed(self):
self.assertEqual(sum_evens([1, 2, 3, 4, 5, 6]), 12)
def test_negatives(self):
self.assertEqual(sum_evens([-2, -3, -4]), -6)
def sum_evens(numbers):
pass
if __name__ == '__main__':
unittest.main()
Expected Output
.....\n----------------------------------------------------------------------\nRan 5 tests in 0.001s\n\nOKHints
Hint 1: "Fake it" means start with a hard-coded return value that passes the first test, then generalise as more tests are added.
Hint 2: This controlled progression reveals whether you really understand the algorithm, rather than guessing at a general solution.
Hint 3: The discipline: never write more general code than the current tests demand.
Hard
Task: Implement LinkedList with append, prepend, find, remove, __len__, and __iter__. The internal node structure is your choice — the tests only care about the public API.
Solution:
import unittest
class TestLinkedList(unittest.TestCase):
def setUp(self):
self.ll = LinkedList()
def test_empty_length(self):
self.assertEqual(len(self.ll), 0)
def test_append_increases_length(self):
self.ll.append(1)
self.assertEqual(len(self.ll), 1)
def test_prepend(self):
self.ll.append(2)
self.ll.prepend(1)
self.assertEqual(list(self.ll), [1, 2])
def test_find_existing(self):
self.ll.append(10)
self.assertTrue(self.ll.find(10))
def test_find_missing(self):
self.assertFalse(self.ll.find(99))
def test_remove(self):
self.ll.append(1)
self.ll.append(2)
self.ll.remove(1)
self.assertEqual(list(self.ll), [2])
class _Node:
def __init__(self, value):
self.value = value
self.next = None
class LinkedList:
def __init__(self):
self._head = None
self._size = 0
def append(self, value):
node = _Node(value)
if self._head is None:
self._head = node
else:
current = self._head
while current.next:
current = current.next
current.next = node
self._size += 1
def prepend(self, value):
node = _Node(value)
node.next = self._head
self._head = node
self._size += 1
def find(self, value):
current = self._head
while current:
if current.value == value:
return True
current = current.next
return False
def remove(self, value):
if self._head is None:
return
if self._head.value == value:
self._head = self._head.next
self._size -= 1
return
current = self._head
while current.next:
if current.next.value == value:
current.next = current.next.next
self._size -= 1
return
current = current.next
def __len__(self):
return self._size
def __iter__(self):
current = self._head
while current:
yield current.value
current = current.next
if __name__ == '__main__':
unittest.main()
import unittest
class TestLinkedList(unittest.TestCase):
def setUp(self):
self.ll = LinkedList()
def test_empty_length(self):
self.assertEqual(len(self.ll), 0)
def test_append_increases_length(self):
self.ll.append(1)
self.assertEqual(len(self.ll), 1)
def test_prepend(self):
self.ll.append(2)
self.ll.prepend(1)
self.assertEqual(list(self.ll), [1, 2])
def test_find_existing(self):
self.ll.append(10)
self.assertTrue(self.ll.find(10))
def test_find_missing(self):
self.assertFalse(self.ll.find(99))
def test_remove(self):
self.ll.append(1)
self.ll.append(2)
self.ll.remove(1)
self.assertEqual(list(self.ll), [2])
# Implement LinkedList (and an internal Node) to pass all 6 tests
class LinkedList:
pass
if __name__ == '__main__':
unittest.main()
Expected Output
......\n----------------------------------------------------------------------\nRan 6 tests in 0.001s\n\nOKHints
Hint 1: Outside-in TDD: start with the highest-level test (the public API) and work inward.
Hint 2: Write tests for `append`, `prepend`, `find`, and `remove` before implementing the node structure.
Hint 3: The tests define the contract — the internal Node class is an implementation detail that tests never directly touch.
Task: Implement BoundedQueue with enqueue, dequeue, is_empty, and size. The capacity arg is set at construction time.
Solution:
import unittest
class TestBoundedQueue(unittest.TestCase):
def setUp(self):
self.q = BoundedQueue(capacity=3)
def test_empty_on_init(self):
self.assertTrue(self.q.is_empty())
def test_enqueue(self):
self.q.enqueue(1)
self.assertEqual(self.q.size(), 1)
def test_dequeue(self):
self.q.enqueue(42)
self.assertEqual(self.q.dequeue(), 42)
def test_dequeue_empty_raises(self):
with self.assertRaises(IndexError):
self.q.dequeue()
def test_capacity_exceeded_raises(self):
self.q.enqueue(1)
self.q.enqueue(2)
self.q.enqueue(3)
with self.assertRaises(OverflowError):
self.q.enqueue(4)
def test_fifo_order(self):
for i in [10, 20, 30]:
self.q.enqueue(i)
self.assertEqual(self.q.dequeue(), 10)
self.assertEqual(self.q.dequeue(), 20)
def test_size_never_negative(self):
self.q.enqueue(1)
self.q.dequeue()
self.assertGreaterEqual(self.q.size(), 0)
from collections import deque
class BoundedQueue:
def __init__(self, capacity):
self._capacity = capacity
self._data = deque()
def enqueue(self, item):
if len(self._data) >= self._capacity:
raise OverflowError(f"Queue is full (capacity={self._capacity})")
self._data.append(item)
def dequeue(self):
if not self._data:
raise IndexError("Dequeue from empty queue")
return self._data.popleft()
def is_empty(self):
return len(self._data) == 0
def size(self):
return len(self._data)
if __name__ == '__main__':
unittest.main()
import unittest
class TestBoundedQueue(unittest.TestCase):
def setUp(self):
self.q = BoundedQueue(capacity=3)
def test_empty_on_init(self):
self.assertTrue(self.q.is_empty())
def test_enqueue(self):
self.q.enqueue(1)
self.assertEqual(self.q.size(), 1)
def test_dequeue(self):
self.q.enqueue(42)
self.assertEqual(self.q.dequeue(), 42)
def test_dequeue_empty_raises(self):
with self.assertRaises(IndexError):
self.q.dequeue()
def test_capacity_exceeded_raises(self):
self.q.enqueue(1)
self.q.enqueue(2)
self.q.enqueue(3)
with self.assertRaises(OverflowError):
self.q.enqueue(4)
def test_fifo_order(self):
for i in [10, 20, 30]:
self.q.enqueue(i)
self.assertEqual(self.q.dequeue(), 10)
self.assertEqual(self.q.dequeue(), 20)
def test_size_never_negative(self):
self.q.enqueue(1)
self.q.dequeue()
self.assertGreaterEqual(self.q.size(), 0)
class BoundedQueue:
pass
if __name__ == '__main__':
unittest.main()
Expected Output
.......\n----------------------------------------------------------------------\nRan 7 tests in 0.001s\n\nOKHints
Hint 1: Write the error-case tests first — they define the contract for invalid inputs before happy paths.
Hint 2: Invariants are conditions that must always be true (e.g., queue size is never negative).
Hint 3: TDD forces you to think about failure modes upfront rather than as an afterthought.
Task: Implement UserRegistry with register and get_user methods. register returns a dict with success bool (and error key on failure). This simulates ATDD — the acceptance tests define the entire feature specification.
Solution:
import unittest
import hashlib
class TestUserRegistration(unittest.TestCase):
def setUp(self):
self.registry = UserRegistry()
def test_register_new_user(self):
self.assertTrue(result["success"])
print("Acceptance: user registered")
def test_duplicate_email_rejected(self):
self.assertFalse(result["success"])
self.assertIn("exists", result["error"])
print("Acceptance: duplicate rejected")
def test_retrieve_registered_user(self):
print("Acceptance: user retrieved")
class UserRegistry:
def __init__(self):
self._users = {}
def register(self, email, password):
if email in self._users:
return {"success": False, "error": f"Email already exists: {email}"}
password_hash = hashlib.sha256(password.encode()).hexdigest()
self._users[email] = {"email": email, "password_hash": password_hash}
return {"success": True}
def get_user(self, email):
return self._users.get(email)
if __name__ == '__main__':
result = unittest.main(verbosity=0, exit=False)
if result.result.wasSuccessful():
print("All acceptance tests passed")
import unittest
# Acceptance tests — written first, before any implementation
class TestUserRegistration(unittest.TestCase):
def setUp(self):
self.registry = UserRegistry()
def test_register_new_user(self):
result = self.registry.register("[email protected]", "secret")
self.assertTrue(result["success"])
print("Acceptance: user registered")
def test_duplicate_email_rejected(self):
self.registry.register("[email protected]", "pass1")
result = self.registry.register("[email protected]", "pass2")
self.assertFalse(result["success"])
self.assertIn("exists", result["error"])
print("Acceptance: duplicate rejected")
def test_retrieve_registered_user(self):
self.registry.register("[email protected]", "pw")
user = self.registry.get_user("[email protected]")
self.assertEqual(user["email"], "[email protected]")
print("Acceptance: user retrieved")
# Implement UserRegistry to pass the acceptance tests
class UserRegistry:
pass
if __name__ == '__main__':
unittest.main(verbosity=0)
print("All acceptance tests passed")
Expected Output
Acceptance: user registered\nAcceptance: duplicate rejected\nAcceptance: user retrieved\nAll acceptance tests passedHints
Hint 1: ATDD writes high-level acceptance tests first (what the system does from the user perspective) before unit tests.
Hint 2: Acceptance tests are coarser — they test a whole feature, not a single function.
Hint 3: The acceptance test stays RED until the full feature is implemented. Unit tests guide individual components.
