Python testing exercises unittest black box TDD solutions cheat sheet FP2

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.


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

  1. Identify all equivalence partitions (valid and invalid)
  2. Identify all boundary values
  3. Write a unittest.TestCase class with at least 20 tests
  4. 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 return 25.0
  • Absolute zero boundaries: C = -273.15, F = -459.67, K = 0
  • assertRaises as 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/except that 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 Exception instead 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: pass and 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__: compare title.lower() with each song’s title lowercased
  • total_duration() on empty playlist should return 0, not raise
  • shuffle() can use random.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)

Similar Posts

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *