Project Goals
3 min- Test class behaviour: HP, attack, inventory add/remove.
- Test interactions: a Hero attacking a Monster.
- Make randomness deterministic with a seed.
- Test the battle loop's end conditions.
Warm-Up · Randomness Breaks Tests
5 min# the engine (simplified) import random class Hero: def __init__(self, hp=30): self.hp = hp def attack(self): return random.randint(5, 10) # ← random! how do we test it?
A test that depends on random is flaky — sometimes passes, sometimes fails. The fix: seed the random generator in the test (random.seed(0)) so it produces the same sequence every run. Determinism is non-negotiable for tests.
New Concept · Testing OOP + Randomness
14 minThe engine
# rpg.py import random class Character: def __init__(self, name, hp, low, high): self.name = name self.hp = hp self.low, self.high = low, high def is_alive(self): return self.hp > 0 def attack(self): return random.randint(self.low, self.high) def take_damage(self, dmg): self.hp = max(0, self.hp - dmg) class Inventory: def __init__(self): self.items = [] def add(self, item): self.items.append(item) def remove(self, item): if item not in self.items: raise ValueError("no such item") self.items.remove(item) def __len__(self): return len(self.items)
Seed for determinism
import random def test_attack_is_seeded(): random.seed(42) hero = Character("Aisyah", 30, 5, 10) # with seed 42, the sequence is fixed and repeatable assert hero.attack() == 9 # whatever seed 42 gives — pin it once
Better: use a seed fixture so every test starts from a known state.
Test non-random behaviour directly
def test_take_damage_clamps_to_zero(): c = Character("Goblin", 10, 1, 3) c.take_damage(100) assert c.hp == 0 # never negative assert not c.is_alive()
Test interactions
def test_attack_reduces_target_hp(): random.seed(1) hero = Character("Hero", 30, 5, 5) # fixed range 5-5 = always 5! goblin = Character("Goblin", 10, 1, 1) goblin.take_damage(hero.attack()) assert goblin.hp == 5
Trick: set the damage range to a single value (5, 5) and randomness disappears entirely — the cleanest way to test attack effects without worrying about the dice.
Build · test_rpg.py
12 min# test_rpg.py import random, pytest from rpg import Character, Inventory @pytest.fixture(autouse=True) def seed_random(): random.seed(0) # autouse → runs before EVERY test automatically @pytest.fixture def hero(): return Character("Aisyah", hp=30, low=5, high=5) # fixed damage @pytest.fixture def goblin(): return Character("Goblin", hp=10, low=1, high=1) # --- Character --- def test_starts_alive(hero): assert hero.is_alive() assert hero.hp == 30 def test_take_damage(hero): hero.take_damage(12) assert hero.hp == 18 def test_damage_clamps(goblin): goblin.take_damage(999) assert goblin.hp == 0 and not goblin.is_alive() # --- interaction --- def test_hero_hits_goblin(hero, goblin): goblin.take_damage(hero.attack()) # fixed 5 damage assert goblin.hp == 5 # --- Inventory --- def test_inventory_add_remove(): inv = Inventory() inv.add("sword"); inv.add("potion") assert len(inv) == 2 inv.remove("sword") assert len(inv) == 1 def test_remove_missing_raises(): with pytest.raises(ValueError, match="no such item"): Inventory().remove("ghost") # --- battle loop --- def battle(a, b): while a.is_alive() and b.is_alive(): b.take_damage(a.attack()) if b.is_alive(): a.take_damage(b.attack()) return a if a.is_alive() else b def test_battle_has_a_winner(hero, goblin): winner = battle(hero, goblin) assert winner.is_alive() assert not (hero.is_alive() and goblin.is_alive())
$ pytest test_rpg.py -v test_starts_alive PASSED test_take_damage PASSED test_damage_clamps PASSED test_hero_hits_goblin PASSED test_inventory_add_remove PASSED test_remove_missing_raises PASSED test_battle_has_a_winner PASSED 7 passed
Read the diff
Two determinism tactics: an autouse fixture seeds random before every test, and fixed damage ranges (5, 5) make attacks predictable. With randomness tamed, you test exact HP values and the battle's end condition reliably — no flakiness. autouse=True means you don't even have to request the seed fixture; it just runs.
Extensions
13 minParametrize take_damage over several (start_hp, dmg, expected_hp) cases including overkill.
Seed random, call attack() 100 times, and assert every result is within [low, high]. This tests the property, not an exact value.
Hint
def test_attack_in_range(): random.seed(0) c = Character("X", 30, 5, 10) rolls = [c.attack() for _ in range(100)] assert all(5 <= r <= 10 for r in rolls)
Pull your actual Level-3 Dungeon Quest classes. Write tests against them. Did any rule (HP clamp, inventory dupes, battle end) have a bug?
Stretch · Property-Based Thinking
8 minSome truths hold for ALL inputs, not just examples. Write tests asserting invariants: "hp is never negative after any sequence of damage", "inventory length never goes below zero", "a battle always ends with exactly one survivor". These property tests catch bugs example-tests miss.
Recap
3 minTesting an OOP game: fixtures for characters, direct tests for deterministic behaviour (HP clamp, inventory), interaction tests for attacks, and the key trick — tame randomness by seeding (autouse fixture) or fixing ranges. Test properties (in-range, invariants) where exact values would be flaky. Next: a challenge that flips it around — find the hidden bugs.
Vocabulary Card
- flaky test
- A test that passes or fails unpredictably — often due to randomness or timing.
- seeding
- Fixing a random generator's state so output is repeatable.
- autouse fixture
- A fixture that runs automatically for every test without being requested.
- property test
- Asserting a truth that holds for all inputs (e.g., hp never negative).
Homework
4 minTest your real L3 Dungeon Quest (or the engine here). Cover characters, inventory, attacks, and the battle loop. Include a seeded determinism test, an in-range property test, and one invariant test. Report any bug your tests caught in the original code.
Use test_rpg.py as the base; add the in-range and invariant tests from the exercises. A common L3 bug: HP going negative because take_damage forgot the max(0, ...) clamp.