Python testing exercises — master unittest, black box and TDD
Python testing exercises are where testing discipline becomes a natural part of how you write code. You’ve seen the theory and built three complete test suites. Now it’s time to design your own — a black box suite from a specification, identifying and fixing badly designed tests, and a complete TDD implementation.
As always: try to solve it yourself, use the hint if stuck for more than 10 minutes, and compare with the commented solution. Run your tests with python -m unittest test_file.py -v.
Table of Contents
Python testing exercises — Basic Level
Exercise 1 — Black box test suite from specification
Design a complete black box test suite for the following function. You will not look at the implementation — only the specification. Apply equivalence partitioning and boundary value analysis.
Specification
convert_temperature(value, from_unit, to_unit)
Supported units: 'C' (Celsius), 'F' (Fahrenheit), 'K' (Kelvin)
Valid conversions:
C → F: F = (C × 9/5) + 32
C → K: K = C + 273.15
F → C: C = (F - 32) × 5/9
F → K: K = (F - 32) × 5/9 + 273.15
K → C: C = K - 273.15
K → F: F = (K - 273.15) × 9/5 + 32
X → X: same unit → return value unchanged (rounded to 2dp)
Physical constraint:
Kelvin cannot be negative (absolute zero = 0 K = -273.15°C = -459.67°F)
If result would be below 0 K → raise ValueError
If input is below absolute zero for its unit → raise ValueError
Raises:
ValueError: unit not in ('C', 'F', 'K')
ValueError: temperature below absolute zero for its unit
TypeError: value is not numeric (int or float)
Returns: float rounded to 2 decimal places
Your tasks
- Identify all equivalence partitions (valid and invalid)
- Identify all boundary values
- Write a
unittest.TestCaseclass with at least 20 tests - Make sure every branch in the specification is covered
Expected test categories:
- Valid conversions: one test per conversion pair (6 pairs + same unit) - Known values: 0°C = 32°F = 273.15K, 100°C = 212°F, -273.15°C = 0K - Boundary: values at absolute zero for each unit - Invalid unit: non-existent unit string, empty string, number as unit - Invalid type: string value, None, list - Below absolute zero for each unit - Return type and precision
💡 Hints:
assertAlmostEqual(result, expected, places=2)for float comparisons- Test the same-unit case:
convert_temperature(25, 'C', 'C')should return25.0 - Absolute zero boundaries: C = -273.15, F = -459.67, K = 0
assertRaisesas context manager for exception message checking
Python testing exercises — Intermediate Level
Exercise 2 — Identify and fix badly designed tests
The following test suite has 8 problems. Some tests are wrong (they would pass even with a buggy implementation), some are poorly named, some test the wrong thing, and some are missing. Your task is to identify every problem and rewrite the suite correctly.
The function being tested
def find_maximum(numbers):
"""
Find the maximum value in a non-empty list of numbers.
Raises ValueError if list is empty.
Raises TypeError if input is not a list or contains non-numbers.
Returns the maximum value as the same type (int if all int, float otherwise).
"""
if not isinstance(numbers, list):
raise TypeError(f'Expected list, got {type(numbers).__name__}')
if len(numbers) == 0:
raise ValueError('Cannot find maximum of empty list')
for item in numbers:
if not isinstance(item, (int, float)):
raise TypeError(f'All elements must be numeric, got {type(item).__name__}')
maximum = numbers[0]
for n in numbers[1:]:
if n > maximum:
maximum = n
return maximum
The badly designed test suite to fix
import unittest
class TestFindMaximum(unittest.TestCase):
# Test 1
def test_1(self):
self.assertEqual(find_maximum([1, 2, 3]), 3)
# Test 2
def test_basic(self):
result = find_maximum([5, 3, 8, 1])
self.assertTrue(result > 0)
# Test 3
def test_empty(self):
try:
find_maximum([])
except:
pass
# Test 4
def test_negative(self):
self.assertEqual(find_maximum([-1, -2, -3]), -1)
# Test 5
def test_type(self):
result = find_maximum([1, 2, 3])
self.assertTrue(isinstance(result, (int, float)))
# Test 6
def test_single_element(self):
self.assertEqual(find_maximum([42]), 42)
self.assertEqual(find_maximum([42]), 42)
self.assertEqual(find_maximum([42]), 42)
# Test 7
def test_string_input(self):
with self.assertRaises(Exception):
find_maximum('hello')
# Test 8
def test_mixed_list(self):
self.assertEqual(find_maximum([1, 2, 'three']), 2)
Identify the 8 problems and rewrite each test correctly.
💡 Hints — problems to find:
- Test names that don’t describe what they test
- An assertion that passes for any positive result (not specific enough)
- A
try/exceptthat swallows the exception instead of asserting on it - A test that asserts the wrong thing (incorrect expected value)
- A test with duplicate assertions (testing the same thing 3 times)
- A test catching
Exceptioninstead of the specific type - A test expecting the function to succeed when it should raise
- Missing tests: duplicates in list, all same value, floats, max is first element, max is last element
Python testing exercises — Final Challenge
Exercise 3 — TDD for a Playlist class
Implement a Playlist class using Test-Driven Development. Write each test, watch it fail, then write the minimum code to make it pass. Continue until all 20+ tests pass.
Specification
Playlist(name)
Songs are dicts: {'title': str, 'artist': str, 'duration': int (seconds)}
Methods:
add_song(song) — add song to end, raise ValueError if duplicate title
remove_song(title) — remove by title, raise ValueError if not found
find_by_artist(artist) — return list of songs by that artist (case-insensitive)
total_duration() — total seconds of all songs
shuffle() — randomise order (in place)
most_played_artist() — artist with most songs (tie → alphabetically first)
Properties:
name — playlist name
song_count — number of songs
Magic:
__len__ — number of songs
__contains__ — 'Song Title' in playlist (by title, case-insensitive)
__iter__ — iterate songs in order
__str__ — "Playlist 'Name' (N songs, M:SS total)"
__bool__ — False if no songs
TDD requirements
Write tests in this order (each builds on the previous):
Group 1 — Empty playlist test_new_playlist_has_name test_new_playlist_is_empty test_new_playlist_bool_is_false test_empty_playlist_total_duration Group 2 — Adding songs test_add_song_increases_count test_add_song_in_contains test_add_duplicate_raises test_add_non_dict_raises Group 3 — Removing songs test_remove_existing_song test_remove_nonexistent_raises test_remove_decreases_count Group 4 — Finding by artist test_find_by_artist_returns_matching test_find_by_artist_case_insensitive test_find_by_artist_not_found_returns_empty Group 5 — Statistics test_total_duration_correct test_most_played_artist_single_winner test_most_played_artist_tie_alphabetical Group 6 — Magic methods test_len_equals_song_count test_iter_yields_all_songs test_str_format
💡 Hints:
- Start with
class Playlist: passand add each feature only when a test requires it most_played_artist():max(counts, key=lambda a: (counts[a], [-ord(c) for c in a]))— count ties → alphabetical__contains__: comparetitle.lower()with each song’s title lowercasedtotal_duration()on empty playlist should return 0, not raiseshuffle()can userandom.shuffle(self._songs)— test that it’s still the same songs, not the same order
Commented solutions
Solution Exercise 1
import unittest
# The function (would normally be in a separate module)
def convert_temperature(value, from_unit, to_unit):
if not isinstance(value, (int, float)):
raise TypeError(f'Value must be numeric, got {type(value).__name__}')
valid_units = ('C', 'F', 'K')
if from_unit not in valid_units:
raise ValueError(f'Unknown unit: {from_unit}')
if to_unit not in valid_units:
raise ValueError(f'Unknown unit: {to_unit}')
# Check absolute zero
abs_zero = {'C': -273.15, 'F': -459.67, 'K': 0}
if value < abs_zero[from_unit]:
raise ValueError(
f'{value}°{from_unit} is below absolute zero'
)
# Convert to Celsius first
if from_unit == 'C': celsius = value
elif from_unit == 'F': celsius = (value - 32) * 5/9
else: celsius = value - 273.15
# Convert to target
if to_unit == 'C': result = celsius
elif to_unit == 'F': result = celsius * 9/5 + 32
else: result = celsius + 273.15
return round(result, 2)
class TestConvertTemperatureBlackBox(unittest.TestCase):
# ─── Valid conversions — one test per pair ───────
def test_c_to_f_boiling(self):
"""100°C = 212°F"""
self.assertAlmostEqual(convert_temperature(100, 'C', 'F'), 212.0, places=2)
def test_c_to_f_freezing(self):
"""0°C = 32°F"""
self.assertAlmostEqual(convert_temperature(0, 'C', 'F'), 32.0, places=2)
def test_c_to_k_freezing(self):
"""0°C = 273.15K"""
self.assertAlmostEqual(convert_temperature(0, 'C', 'K'), 273.15, places=2)
def test_c_to_k_boiling(self):
"""100°C = 373.15K"""
self.assertAlmostEqual(convert_temperature(100, 'C', 'K'), 373.15, places=2)
def test_f_to_c_freezing(self):
"""32°F = 0°C"""
self.assertAlmostEqual(convert_temperature(32, 'F', 'C'), 0.0, places=2)
def test_f_to_c_boiling(self):
"""212°F = 100°C"""
self.assertAlmostEqual(convert_temperature(212, 'F', 'C'), 100.0, places=2)
def test_f_to_k(self):
"""32°F = 273.15K"""
self.assertAlmostEqual(convert_temperature(32, 'F', 'K'), 273.15, places=2)
def test_k_to_c_absolute_zero(self):
"""0K = -273.15°C"""
self.assertAlmostEqual(convert_temperature(0, 'K', 'C'), -273.15, places=2)
def test_k_to_f(self):
"""373.15K = 212°F"""
self.assertAlmostEqual(convert_temperature(373.15, 'K', 'F'), 212.0, places=2)
def test_k_to_c(self):
"""273.15K = 0°C"""
self.assertAlmostEqual(convert_temperature(273.15, 'K', 'C'), 0.0, places=2)
# ─── Same unit conversions ───────────────────────
def test_c_to_c_unchanged(self):
self.assertEqual(convert_temperature(25, 'C', 'C'), 25.0)
def test_f_to_f_unchanged(self):
self.assertEqual(convert_temperature(98.6, 'F', 'F'), 98.6)
def test_k_to_k_unchanged(self):
self.assertEqual(convert_temperature(300, 'K', 'K'), 300.0)
# ─── Boundary: absolute zero ─────────────────────
def test_c_at_absolute_zero(self):
"""Boundary: -273.15°C is valid (absolute zero)"""
result = convert_temperature(-273.15, 'C', 'K')
self.assertAlmostEqual(result, 0.0, places=2)
def test_k_at_zero(self):
"""Boundary: 0K is valid (absolute zero)"""
result = convert_temperature(0, 'K', 'C')
self.assertAlmostEqual(result, -273.15, places=2)
def test_f_at_absolute_zero(self):
"""Boundary: -459.67°F is valid (absolute zero)"""
result = convert_temperature(-459.67, 'F', 'K')
self.assertAlmostEqual(result, 0.0, places=1)
# ─── Below absolute zero ─────────────────────────
def test_c_below_absolute_zero_raises(self):
with self.assertRaises(ValueError):
convert_temperature(-274, 'C', 'F')
def test_k_negative_raises(self):
with self.assertRaises(ValueError):
convert_temperature(-1, 'K', 'C')
def test_f_below_absolute_zero_raises(self):
with self.assertRaises(ValueError):
convert_temperature(-460, 'F', 'C')
# ─── Invalid units ───────────────────────────────
def test_unknown_from_unit_raises(self):
with self.assertRaises(ValueError):
convert_temperature(100, 'X', 'C')
def test_unknown_to_unit_raises(self):
with self.assertRaises(ValueError):
convert_temperature(100, 'C', 'celsius')
def test_lowercase_unit_raises(self):
"""Units are case-sensitive — 'c' is not 'C'"""
with self.assertRaises(ValueError):
convert_temperature(100, 'c', 'F')
def test_empty_unit_raises(self):
with self.assertRaises(ValueError):
convert_temperature(100, '', 'C')
# ─── Invalid value type ──────────────────────────
def test_string_value_raises(self):
with self.assertRaises(TypeError):
convert_temperature('100', 'C', 'F')
def test_none_value_raises(self):
with self.assertRaises(TypeError):
convert_temperature(None, 'C', 'F')
def test_list_value_raises(self):
with self.assertRaises(TypeError):
convert_temperature([100], 'C', 'F')
# ─── Return type and precision ───────────────────
def test_returns_float(self):
result = convert_temperature(100, 'C', 'F')
self.assertIsInstance(result, float)
def test_result_rounded_to_2dp(self):
"""Result has at most 2 decimal places"""
result = convert_temperature(37, 'C', 'F') # 98.6°F exactly
self.assertEqual(result, round(result, 2))
if __name__ == '__main__':
unittest.main(verbosity=2)
Solution Exercise 2 — Identified problems and fixed tests
import unittest
def find_maximum(numbers):
if not isinstance(numbers, list):
raise TypeError(f'Expected list, got {type(numbers).__name__}')
if len(numbers) == 0:
raise ValueError('Cannot find maximum of empty list')
for item in numbers:
if not isinstance(item, (int, float)):
raise TypeError(f'All elements must be numeric, got {type(item).__name__}')
maximum = numbers[0]
for n in numbers[1:]:
if n > maximum:
maximum = n
return maximum
# PROBLEMS IDENTIFIED:
# 1. test_1 — name is meaningless — renamed
# 2. test_basic — assertTrue(result > 0) passes for any positive result
# should assertEqual(result, 8)
# 3. test_empty — bare except swallows everything, no assertion
# should use assertRaises
# 4. test_negative — WRONG: max([-1,-2,-3]) is -1, but assertEqual checks -1
# actually this is correct! But the name is misleading
# 5. test_type — tests that it returns int OR float — too broad
# should test the specific return type
# 6. test_single_element — same assertion repeated 3 times — remove duplicates
# 7. test_string_input — catches Exception instead of specific TypeError
# 8. test_mixed_list — expects success when it should raise TypeError
class TestFindMaximumFixed(unittest.TestCase):
# Fix 1: meaningful name
def test_maximum_of_ascending_list(self):
"""Maximum of [1,2,3] is 3 (last element)"""
self.assertEqual(find_maximum([1, 2, 3]), 3)
# Fix 2: specific assertion — not just > 0
def test_maximum_of_unsorted_list(self):
"""Maximum of [5,3,8,1] is 8"""
result = find_maximum([5, 3, 8, 1])
self.assertEqual(result, 8)
# Fix 3: proper assertRaises — not bare except
def test_empty_list_raises_value_error(self):
"""Empty list raises ValueError"""
with self.assertRaises(ValueError):
find_maximum([])
def test_empty_list_error_message(self):
"""ValueError message mentions 'empty'"""
with self.assertRaises(ValueError) as ctx:
find_maximum([])
self.assertIn('empty', str(ctx.exception).lower())
# Fix 4: rename to describe what it tests
def test_maximum_of_all_negatives(self):
"""Maximum of all negative numbers is the least negative"""
self.assertEqual(find_maximum([-1, -2, -3]), -1)
# Fix 5: test specific return type (int when all int)
def test_returns_int_when_all_int(self):
"""Returns int when all inputs are int"""
result = find_maximum([1, 2, 3])
self.assertIsInstance(result, int)
def test_returns_float_when_float_present(self):
"""Returns float when any input is float"""
result = find_maximum([1, 2.5, 3])
self.assertIsInstance(result, float)
# Fix 6: remove duplicate assertions — test one thing per test
def test_single_element_list(self):
"""Single element list returns that element"""
self.assertEqual(find_maximum([42]), 42)
# Fix 7: catch specific TypeError, not generic Exception
def test_non_list_input_raises_type_error(self):
"""Non-list input raises TypeError specifically"""
with self.assertRaises(TypeError):
find_maximum('hello')
def test_tuple_input_raises_type_error(self):
"""Tuple raises TypeError (only list accepted)"""
with self.assertRaises(TypeError):
find_maximum((1, 2, 3))
# Fix 8: mixed list should RAISE, not return a value
def test_list_with_string_raises_type_error(self):
"""List containing strings raises TypeError"""
with self.assertRaises(TypeError):
find_maximum([1, 2, 'three'])
# Missing tests that should be added:
def test_maximum_is_first_element(self):
"""Maximum at first position"""
self.assertEqual(find_maximum([9, 3, 1, 5]), 9)
def test_maximum_is_last_element(self):
"""Maximum at last position"""
self.assertEqual(find_maximum([1, 3, 5, 9]), 9)
def test_list_with_duplicates(self):
"""Handles duplicate values correctly"""
self.assertEqual(find_maximum([5, 5, 5]), 5)
def test_all_same_value(self):
"""All same values → return that value"""
self.assertEqual(find_maximum([7, 7, 7, 7]), 7)
def test_list_with_floats(self):
"""Handles float values"""
self.assertAlmostEqual(find_maximum([1.1, 2.2, 3.3]), 3.3, places=10)
def test_mixed_int_and_float(self):
"""Handles mixed int and float"""
self.assertEqual(find_maximum([1, 2.5, 2]), 2.5)
def test_none_in_list_raises_type_error(self):
"""None in list raises TypeError"""
with self.assertRaises(TypeError):
find_maximum([1, None, 3])
def test_none_input_raises_type_error(self):
"""None as input raises TypeError"""
with self.assertRaises(TypeError):
find_maximum(None)
if __name__ == '__main__':
unittest.main(verbosity=2)
Solution Exercise 3 — TDD Playlist
import unittest
import random
from collections import Counter
class Playlist:
"""FIFO-ordered playlist built using TDD."""
def __init__(self, name):
self._name = name
self._songs = []
@property
def name(self):
return self._name
@property
def song_count(self):
return len(self._songs)
def add_song(self, song):
if not isinstance(song, dict):
raise TypeError(f'Song must be a dict, got {type(song).__name__}')
required = {'title', 'artist', 'duration'}
if not required.issubset(song.keys()):
raise ValueError(f'Song must have keys: {required}')
# Check for duplicate title (case-insensitive)
title_lower = song['title'].lower()
for existing in self._songs:
if existing['title'].lower() == title_lower:
raise ValueError(f'Duplicate song title: {song["title"]}')
self._songs.append(song)
def remove_song(self, title):
title_lower = title.lower()
for i, song in enumerate(self._songs):
if song['title'].lower() == title_lower:
self._songs.pop(i)
return
raise ValueError(f'Song not found: {title}')
def find_by_artist(self, artist):
artist_lower = artist.lower()
return [s for s in self._songs
if s['artist'].lower() == artist_lower]
def total_duration(self):
return sum(s['duration'] for s in self._songs)
def shuffle(self):
random.shuffle(self._songs)
def most_played_artist(self):
if not self._songs:
return None
counts = Counter(s['artist'] for s in self._songs)
max_count = max(counts.values())
# Among tied artists, return alphabetically first
tied = [artist for artist, count in counts.items()
if count == max_count]
return sorted(tied)[0]
def __len__(self):
return self.song_count
def __contains__(self, title):
title_lower = title.lower()
return any(s['title'].lower() == title_lower for s in self._songs)
def __iter__(self):
return iter(self._songs)
def __bool__(self):
return bool(self._songs)
def __str__(self):
total = self.total_duration()
minutes = total // 60
seconds = total % 60
return (f"Playlist '{self._name}' "
f"({self.song_count} songs, {minutes}:{seconds:02d} total)")
class TestPlaylistTDD(unittest.TestCase):
# ─── Helper ─────────────────────────────────────
def make_song(self, title='Song', artist='Artist', duration=180):
return {'title': title, 'artist': artist, 'duration': duration}
# ─── Group 1: Empty playlist ─────────────────────
def test_new_playlist_has_name(self):
p = Playlist('My Mix')
self.assertEqual(p.name, 'My Mix')
def test_new_playlist_is_empty(self):
p = Playlist('Empty')
self.assertEqual(p.song_count, 0)
def test_new_playlist_bool_is_false(self):
p = Playlist('Empty')
self.assertFalse(bool(p))
def test_empty_playlist_total_duration(self):
p = Playlist('Empty')
self.assertEqual(p.total_duration(), 0)
# ─── Group 2: Adding songs ───────────────────────
def test_add_song_increases_count(self):
p = Playlist('Test')
p.add_song(self.make_song('Song A'))
self.assertEqual(p.song_count, 1)
p.add_song(self.make_song('Song B'))
self.assertEqual(p.song_count, 2)
def test_add_song_in_contains(self):
p = Playlist('Test')
p.add_song(self.make_song('Bohemian Rhapsody'))
self.assertIn('Bohemian Rhapsody', p)
def test_add_duplicate_raises(self):
p = Playlist('Test')
p.add_song(self.make_song('Song A'))
with self.assertRaises(ValueError):
p.add_song(self.make_song('Song A'))
def test_add_duplicate_case_insensitive(self):
p = Playlist('Test')
p.add_song(self.make_song('Song A'))
with self.assertRaises(ValueError):
p.add_song(self.make_song('song a'))
def test_add_non_dict_raises(self):
p = Playlist('Test')
with self.assertRaises(TypeError):
p.add_song('not a dict')
# ─── Group 3: Removing songs ─────────────────────
def test_remove_existing_song(self):
p = Playlist('Test')
p.add_song(self.make_song('Song A'))
p.remove_song('Song A')
self.assertNotIn('Song A', p)
def test_remove_nonexistent_raises(self):
p = Playlist('Test')
with self.assertRaises(ValueError):
p.remove_song('Ghost Song')
def test_remove_decreases_count(self):
p = Playlist('Test')
p.add_song(self.make_song('Song A'))
p.add_song(self.make_song('Song B'))
p.remove_song('Song A')
self.assertEqual(p.song_count, 1)
# ─── Group 4: Finding by artist ──────────────────
def test_find_by_artist_returns_matching(self):
p = Playlist('Test')
p.add_song(self.make_song('Song A', 'Queen'))
p.add_song(self.make_song('Song B', 'Beatles'))
p.add_song(self.make_song('Song C', 'Queen'))
results = p.find_by_artist('Queen')
self.assertEqual(len(results), 2)
self.assertTrue(all(s['artist'] == 'Queen' for s in results))
def test_find_by_artist_case_insensitive(self):
p = Playlist('Test')
p.add_song(self.make_song('Song A', 'Queen'))
results = p.find_by_artist('queen')
self.assertEqual(len(results), 1)
def test_find_by_artist_not_found_returns_empty(self):
p = Playlist('Test')
p.add_song(self.make_song('Song A', 'Queen'))
results = p.find_by_artist('Beatles')
self.assertEqual(results, [])
# ─── Group 5: Statistics ─────────────────────────
def test_total_duration_correct(self):
p = Playlist('Test')
p.add_song(self.make_song('A', duration=120))
p.add_song(self.make_song('B', duration=180))
p.add_song(self.make_song('C', duration=240))
self.assertEqual(p.total_duration(), 540)
def test_most_played_artist_single_winner(self):
p = Playlist('Test')
p.add_song(self.make_song('A', 'Queen'))
p.add_song(self.make_song('B', 'Queen'))
p.add_song(self.make_song('C', 'Beatles'))
self.assertEqual(p.most_played_artist(), 'Queen')
def test_most_played_artist_tie_alphabetical(self):
p = Playlist('Test')
p.add_song(self.make_song('A', 'Queen'))
p.add_song(self.make_song('B', 'Beatles'))
# Tie between Queen and Beatles → Beatles first alphabetically
self.assertEqual(p.most_played_artist(), 'Beatles')
# ─── Group 6: Magic methods ──────────────────────
def test_len_equals_song_count(self):
p = Playlist('Test')
p.add_song(self.make_song('A'))
p.add_song(self.make_song('B'))
self.assertEqual(len(p), p.song_count)
self.assertEqual(len(p), 2)
def test_iter_yields_all_songs(self):
p = Playlist('Test')
songs = [self.make_song('A'), self.make_song('B'), self.make_song('C')]
for s in songs:
p.add_song(s)
result = list(p)
self.assertEqual(len(result), 3)
self.assertEqual(result[0]['title'], 'A')
self.assertEqual(result[2]['title'], 'C')
def test_str_format(self):
p = Playlist('Chill Mix')
p.add_song(self.make_song('A', duration=90))
p.add_song(self.make_song('B', duration=90))
result = str(p)
self.assertIn('Chill Mix', result)
self.assertIn('2 songs', result)
self.assertIn('3:00', result) # 180 seconds = 3:00
def test_shuffle_preserves_all_songs(self):
p = Playlist('Test')
songs = [self.make_song(f'Song {i}') for i in range(5)]
for s in songs:
p.add_song(s)
titles_before = {s['title'] for s in p}
p.shuffle()
titles_after = {s['title'] for s in p}
self.assertEqual(titles_before, titles_after)
self.assertEqual(len(p), 5)
if __name__ == '__main__':
unittest.main(verbosity=2)
Visualise with Python Tutor
Copy this code into pythontutor.com and step through it:
import unittest
def find_max(numbers):
if not numbers:
raise ValueError('Empty list')
return max(numbers)
class TestFindMax(unittest.TestCase):
def test_normal(self):
self.assertEqual(find_max([3, 1, 4, 1, 5]), 5)
def test_empty_raises(self):
with self.assertRaises(ValueError):
find_max([])
def test_single(self):
self.assertEqual(find_max([42]), 42)
suite = unittest.TestLoader().loadTestsFromTestCase(TestFindMax)
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)
Step through and observe the context manager pattern for assertRaises. When with self.assertRaises(ValueError): is entered, unittest sets up a context that catches ValueError — if the exception is raised, the test passes; if no exception is raised, unittest raises AssertionError (test fails); if a different exception is raised, it propagates and the test errors. Watch how runner.run(suite) calls each test_* method independently — a failure in test_empty_raises would not prevent test_single from running. This independence is the most important property of a test suite.
Cheat sheet — Python testing
# ============================================
# CHEAT SHEET — Python Testing
# Sergio Learns · sergiolearns.com
# ============================================
# STRUCTURE
import unittest
class TestMyClass(unittest.TestCase):
def setUp(self): # before each test
self.obj = MyClass()
def tearDown(self): # after each test
pass
def test_behaviour(self):
# Arrange — set up
value = 5
# Act — call
result = self.obj.method(value)
# Assert — verify
self.assertEqual(result, 25)
# 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.assertIsNotNone(x) # x is not None
self.assertIn(x, container) # x in container
self.assertNotIn(x, container) # x not in container
self.assertIsInstance(x, Type) # isinstance(x, Type)
self.assertAlmostEqual(a, b, places=2) # float comparison
self.assertGreater(a, b) # a > b
self.assertRaises(Error, func, args) # func(args) raises Error
# assertRaises — context manager (preferred)
with self.assertRaises(ValueError) as ctx:
my_func(-1)
self.assertIn('message', str(ctx.exception))
# RUN TESTS
# python -m unittest test_file.py -v
# python -m unittest discover tests/
# EQUIVALENCE PARTITIONING (black box)
# 1. Identify valid partitions (one representative each)
# 2. Identify invalid partitions (one representative each)
# 3. Always test boundary values (at, just below, just above)
# FLOW GRAPH (white box)
# Draw control flow: if/elif/else, try/except, loops
# One test per path — cover every branch
# Path: sequence of nodes from entry to exit
# TDD CYCLE
# RED: write failing test
# GREEN: minimum code to pass
# REFACTOR: clean up (tests still pass)
# TEST QUALITY CHECKLIST
# ✓ Name describes exactly what it tests
# ✓ Tests one thing per test method
# ✓ Uses specific assertEqual, not just assertTrue
# ✓ Tests specific exception types, not bare Exception
# ✓ Uses assertRaises — never bare try/except
# ✓ Each test is independent (setUp creates fresh objects)
# ✓ Tests boundary values around every condition
# COMMON MISTAKES
# ✗ test_1, test_basic (non-descriptive names)
# ✗ assertTrue(result > 0) when you know the exact value
# ✗ try: func(); except: pass (swallows errors)
# ✗ assertRaises(Exception) instead of ValueError/TypeError
# ✗ Testing private implementation details
# ✗ Tests that depend on execution order
# ✗ Same assertion repeated 3 times in one test
# WHAT TO TEST
# ✓ Normal cases (one per equivalence class)
# ✓ Boundaries (at, just below, just above each condition)
# ✓ Edge cases (empty, zero, None, single element)
# ✓ Exception cases (all invalid input types/values)
# ✓ Return type and format
# ✗ Python built-ins (don't test list.append)
# ✗ Private methods directly
# ✗ Implementation details (test behaviour, not code structure)

One Comment