Skip to main content

Python TDD Principles Practice Problems & Exercises

Practice: TDD Principles

11 problems4 Easy4 Medium3 Hard55–70 min
← Back to lesson

Easy

#1Red Phase — Write a Failing Test FirstEasy
TDDred-phasetest-first

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.


#2Green Phase — Minimum Code to PassEasy
TDDgreen-phaseminimum-implementation

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\nOK
Hints

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.


#3Triangulation — Adding Tests to Drive ImplementationEasy
TDDtriangulationiterative

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\nOK
Hints

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.


#4YAGNI — Resist Adding Untested BehaviourEasy
TDDYAGNIscope-control

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\nOK
Hints

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

#5Full Red-Green-Refactor CycleMedium
TDDrefactorred-green-refactor

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\nOK
Hints

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.


#6Test-Driving a Calculator ClassMedium
TDDclass-designtest-first

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\nOK
Hints

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.


#7Boundary Value Tests — Driving Edge-Case HandlingMedium
TDDboundary-valuesedge-cases

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\nOK
Hints

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.


#8Fake It Till You Make ItMedium
TDDfake-itincrementalgeneralize

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\nOK
Hints

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

#9TDD a Linked List — Outside-In DesignHard
TDDlinked-listoutside-inclass-design

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\nOK
Hints

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.


#10Test-Driving Error Handling and InvariantsHard
TDDerror-handlinginvariantsdefensive

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\nOK
Hints

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.


#11Acceptance Test-Driven Development (ATDD)Hard
TDDATDDacceptance-testsend-to-end

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):
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")

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 passed
Hints

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.

© 2026 EngineersOfAI. All rights reserved.