Combat System Tutorial - Part 5: Testing¶
In this final part of the combat system tutorial, you will learn how to thoroughly test your content pack using pytest and MAID's testing utilities.
Overview¶
Testing a combat system requires multiple levels:
- Unit Tests: Test individual components and their methods
- System Tests: Test the combat system in isolation
- Integration Tests: Test the full combat flow end-to-end
- Scenario Tests: Test realistic combat situations
Testing Philosophy¶
Good tests for a combat system should:
- Be deterministic when possible (control randomness)
- Test both success and failure cases
- Cover edge cases (zero health, missing components)
- Use fixtures for common setups
- Be readable and document expected behavior
Step 1: Organize Test Structure¶
Create a comprehensive test structure:
tests/
__init__.py
conftest.py # Shared fixtures
test_components.py # Component unit tests
test_events.py # Event tests
test_system.py # CombatSystem tests
test_commands.py # Command tests
test_integration.py # Full integration tests
test_scenarios.py # Realistic combat scenarios
Step 2: Enhanced Test Fixtures¶
Expand your conftest.py with more fixtures:
# tests/conftest.py
"""Comprehensive pytest fixtures for combat system testing."""
from dataclasses import dataclass, field
from typing import Any
from uuid import UUID, uuid4
import pytest
from maid_engine.core.world import World
from maid_engine.commands.registry import CommandContext
from maid_stdlib.components import (
DescriptionComponent,
HealthComponent,
PositionComponent,
)
from maid_combat_system.components import AttackComponent, DefenseComponent
from maid_combat_system.systems import CombatSystem
# --- Basic World Fixtures ---
@pytest.fixture
def world() -> World:
"""Create a fresh World for testing."""
return World()
@pytest.fixture
def room_id() -> UUID:
"""A room UUID for testing."""
return uuid4()
@pytest.fixture
def second_room_id() -> UUID:
"""A second room UUID for multi-room tests."""
return uuid4()
# --- Session Mock ---
@dataclass
class MockSession:
"""Mock session for testing commands.
Captures messages and provides helper methods for assertions.
"""
messages: list[str] = field(default_factory=list)
player_id: UUID | None = None
metadata: dict[str, Any] = field(default_factory=dict)
async def send(self, message: str) -> None:
"""Record a message sent to the session."""
self.messages.append(message)
def clear(self) -> None:
"""Clear recorded messages."""
self.messages.clear()
def get_last_message(self) -> str | None:
"""Get the most recent message."""
return self.messages[-1] if self.messages else None
def contains_message(self, substring: str) -> bool:
"""Check if any message contains the substring."""
return any(substring.lower() in msg.lower() for msg in self.messages)
def get_all_messages(self) -> str:
"""Get all messages as a single string."""
return "\n".join(self.messages)
@pytest.fixture
def mock_session() -> MockSession:
"""Create a mock session."""
return MockSession()
# --- Entity Factory Fixtures ---
class EntityFactory:
"""Factory for creating test entities with common patterns."""
def __init__(self, world: World, room_id: UUID):
self.world = world
self.room_id = room_id
def create_player(
self,
name: str = "Hero",
health: int = 100,
attack_power: int = 10,
defense: int = 5,
**kwargs
):
"""Create a player entity with combat components."""
entity = self.world.entities.create()
entity.add(PositionComponent(room_id=self.room_id))
entity.add(HealthComponent(current=health, maximum=health))
entity.add(DescriptionComponent(
name=name,
short_desc=f"A brave {name.lower()}",
keywords=[name.lower()],
))
entity.add(AttackComponent(
attack_power=attack_power,
accuracy=kwargs.get("accuracy", 80),
critical_chance=kwargs.get("critical_chance", 5),
))
entity.add(DefenseComponent(
defense=defense,
evasion=kwargs.get("evasion", 10),
))
entity.add_tag("player")
return entity
def create_monster(
self,
name: str = "Goblin",
health: int = 50,
attack_power: int = 8,
defense: int = 3,
**kwargs
):
"""Create a monster entity."""
entity = self.world.entities.create()
entity.add(PositionComponent(room_id=self.room_id))
entity.add(HealthComponent(current=health, maximum=health))
entity.add(DescriptionComponent(
name=name,
short_desc=f"A fearsome {name.lower()}",
keywords=[name.lower()],
))
entity.add(AttackComponent(
attack_power=attack_power,
accuracy=kwargs.get("accuracy", 70),
critical_chance=kwargs.get("critical_chance", 5),
))
entity.add(DefenseComponent(
defense=defense,
evasion=kwargs.get("evasion", 5),
))
entity.add_tag("hostile")
return entity
def create_npc(
self,
name: str = "Villager",
health: int = 30,
):
"""Create a non-combatant NPC (no attack component)."""
entity = self.world.entities.create()
entity.add(PositionComponent(room_id=self.room_id))
entity.add(HealthComponent(current=health, maximum=health))
entity.add(DescriptionComponent(
name=name,
short_desc=f"A friendly {name.lower()}",
keywords=[name.lower()],
))
entity.add_tag("npc")
return entity
@pytest.fixture
def entity_factory(world, room_id) -> EntityFactory:
"""Create an entity factory."""
return EntityFactory(world, room_id)
# --- Pre-built Entity Fixtures ---
@pytest.fixture
def player_entity(entity_factory):
"""Create a standard player entity."""
return entity_factory.create_player()
@pytest.fixture
def enemy_entity(entity_factory):
"""Create a standard enemy entity."""
return entity_factory.create_monster()
@pytest.fixture
def strong_enemy(entity_factory):
"""Create a powerful enemy."""
return entity_factory.create_monster(
name="Ogre",
health=200,
attack_power=30,
defense=15,
)
@pytest.fixture
def weak_enemy(entity_factory):
"""Create a weak enemy."""
return entity_factory.create_monster(
name="Rat",
health=10,
attack_power=2,
defense=0,
)
# --- Combat System Fixtures ---
@pytest.fixture
def combat_system(world) -> CombatSystem:
"""Create a combat system."""
return CombatSystem(world)
@pytest.fixture
async def started_combat_system(combat_system) -> CombatSystem:
"""Create and start a combat system."""
await combat_system.startup()
return combat_system
# --- Command Context Fixtures ---
@pytest.fixture
def command_context(world, player_entity, mock_session) -> CommandContext:
"""Create a command context for testing."""
mock_session.player_id = player_entity.id
return CommandContext(
session=mock_session,
player_id=player_entity.id,
command="",
args=[],
raw_input="",
world=world,
)
# --- Event Capture Utilities ---
class EventCapture:
"""Utility for capturing and asserting on events."""
def __init__(self):
self.events: list[Any] = []
async def capture(self, event):
"""Async handler that captures events."""
self.events.append(event)
def clear(self):
"""Clear captured events."""
self.events.clear()
def count(self) -> int:
"""Get number of captured events."""
return len(self.events)
def get_last(self):
"""Get the last captured event."""
return self.events[-1] if self.events else None
def find(self, event_type):
"""Find all events of a specific type."""
return [e for e in self.events if isinstance(e, event_type)]
@pytest.fixture
def event_capture() -> EventCapture:
"""Create an event capture utility."""
return EventCapture()
Step 3: Integration Tests¶
Create comprehensive integration tests:
# tests/test_integration.py
"""Integration tests for the complete combat system."""
import pytest
from uuid import uuid4
from maid_engine.core.engine import GameEngine
from maid_engine.config.settings import Settings
from maid_stdlib.pack import StdlibContentPack
from maid_stdlib.components import HealthComponent
from maid_combat_system import CombatSystemPack
from maid_combat_system.components import AttackComponent, DefenseComponent
from maid_combat_system.events import (
AttackRequestEvent,
DamageDealtEvent,
EntityDeathEvent,
)
from maid_combat_system.commands import attack_command
class TestContentPackIntegration:
"""Test that the content pack loads correctly."""
@pytest.mark.asyncio
async def test_pack_loads_without_errors(self):
"""Test content pack can be loaded."""
pack = CombatSystemPack()
assert pack.manifest.name == "combat-system"
assert "stdlib" in pack.get_dependencies()
@pytest.mark.asyncio
async def test_pack_provides_systems(self, world):
"""Test pack provides expected systems."""
pack = CombatSystemPack()
systems = pack.get_systems(world)
assert len(systems) >= 1
system_names = [s.__class__.__name__ for s in systems]
assert "CombatSystem" in system_names
@pytest.mark.asyncio
async def test_pack_provides_events(self):
"""Test pack provides expected events."""
pack = CombatSystemPack()
events = pack.get_events()
event_names = [e.__name__ for e in events]
assert "AttackRequestEvent" in event_names
assert "DamageDealtEvent" in event_names
assert "EntityDeathEvent" in event_names
class TestCombatFlow:
"""Test the complete combat flow from command to events."""
@pytest.mark.asyncio
async def test_full_attack_flow(
self,
world,
started_combat_system,
player_entity,
enemy_entity,
command_context,
event_capture
):
"""Test complete attack flow from command to damage."""
# Set up deterministic combat (100% hit, no crit)
attack = player_entity.get(AttackComponent)
attack.accuracy = 100
attack.critical_chance = 0
defense = enemy_entity.get(DefenseComponent)
defense.evasion = 0
# Capture events
world.events.subscribe(DamageDealtEvent, event_capture.capture)
# Execute attack command
command_context.command = "attack"
command_context.args = ["goblin"]
command_context.raw_input = "attack goblin"
result = await attack_command(command_context)
# Verify command succeeded
assert result is True
# Verify damage event was emitted
assert event_capture.count() == 1
damage_event = event_capture.get_last()
assert damage_event.source_id == player_entity.id
assert damage_event.target_id == enemy_entity.id
# Verify health was reduced
enemy_health = enemy_entity.get(HealthComponent)
assert enemy_health.current < enemy_health.maximum
@pytest.mark.asyncio
async def test_kill_flow(
self,
world,
started_combat_system,
player_entity,
weak_enemy,
command_context,
event_capture
):
"""Test killing an enemy triggers death event."""
# Set up guaranteed kill
attack = player_entity.get(AttackComponent)
attack.accuracy = 100
attack.attack_power = 100 # Overkill
defense = weak_enemy.get(DefenseComponent)
defense.evasion = 0
defense.defense = 0
# Capture death events
world.events.subscribe(EntityDeathEvent, event_capture.capture)
# Execute attack
command_context.args = ["rat"]
await attack_command(command_context)
# Verify death event
assert event_capture.count() == 1
death_event = event_capture.get_last()
assert death_event.entity_id == weak_enemy.id
assert death_event.killer_id == player_entity.id
@pytest.mark.asyncio
async def test_miss_flow(
self,
world,
started_combat_system,
player_entity,
enemy_entity,
command_context,
event_capture
):
"""Test that misses don't deal damage."""
# Set up guaranteed miss
attack = player_entity.get(AttackComponent)
attack.accuracy = 0
enemy_health = enemy_entity.get(HealthComponent)
initial_health = enemy_health.current
# Capture damage events
world.events.subscribe(DamageDealtEvent, event_capture.capture)
# Execute attack
command_context.args = ["goblin"]
await attack_command(command_context)
# Verify no damage
assert event_capture.count() == 0
assert enemy_health.current == initial_health
class TestCombatScenarios:
"""Test realistic combat scenarios."""
@pytest.mark.asyncio
async def test_mutual_combat(
self,
world,
started_combat_system,
entity_factory,
event_capture
):
"""Test two entities fighting each other."""
# Create two combatants
fighter1 = entity_factory.create_player(name="Fighter1", health=100)
fighter2 = entity_factory.create_player(name="Fighter2", health=100)
# Ensure hits
fighter1.get(AttackComponent).accuracy = 100
fighter2.get(AttackComponent).accuracy = 100
fighter1.get(DefenseComponent).evasion = 0
fighter2.get(DefenseComponent).evasion = 0
# Capture events
world.events.subscribe(DamageDealtEvent, event_capture.capture)
# Fighter1 attacks Fighter2
await world.events.emit(AttackRequestEvent(
attacker_id=fighter1.id,
target_id=fighter2.id,
))
# Fighter2 attacks Fighter1
await world.events.emit(AttackRequestEvent(
attacker_id=fighter2.id,
target_id=fighter1.id,
))
# Both should have taken damage
assert event_capture.count() == 2
assert fighter1.get(HealthComponent).current < 100
assert fighter2.get(HealthComponent).current < 100
@pytest.mark.asyncio
async def test_combat_to_death(
self,
world,
started_combat_system,
entity_factory,
event_capture
):
"""Test combat until one entity dies."""
attacker = entity_factory.create_player(
name="Killer",
attack_power=50,
accuracy=100,
critical_chance=0,
)
victim = entity_factory.create_monster(
name="Victim",
health=100,
defense=0,
evasion=0,
)
# Track events
damage_events = []
death_events = []
async def track_damage(e):
damage_events.append(e)
async def track_death(e):
death_events.append(e)
world.events.subscribe(DamageDealtEvent, track_damage)
world.events.subscribe(EntityDeathEvent, track_death)
# Attack until dead
victim_health = victim.get(HealthComponent)
attack_count = 0
max_attacks = 10
while victim_health.is_alive and attack_count < max_attacks:
await world.events.emit(AttackRequestEvent(
attacker_id=attacker.id,
target_id=victim.id,
))
attack_count += 1
# Verify death occurred
assert not victim_health.is_alive
assert len(death_events) == 1
assert len(damage_events) >= 1 # At least one hit
@pytest.mark.asyncio
async def test_high_defense_reduces_damage(
self,
world,
started_combat_system,
entity_factory
):
"""Test that high defense significantly reduces damage."""
attacker = entity_factory.create_player(
attack_power=20,
accuracy=100,
critical_chance=0,
)
# Low defense target
low_def_target = entity_factory.create_monster(
name="Weak",
health=200,
defense=0,
evasion=0,
)
# High defense target
high_def_target = entity_factory.create_monster(
name="Tank",
health=200,
defense=15,
evasion=0,
)
# Attack both
await world.events.emit(AttackRequestEvent(
attacker_id=attacker.id,
target_id=low_def_target.id,
))
await world.events.emit(AttackRequestEvent(
attacker_id=attacker.id,
target_id=high_def_target.id,
))
low_def_health = low_def_target.get(HealthComponent)
high_def_health = high_def_target.get(HealthComponent)
# High defense should have more health remaining
assert high_def_health.current > low_def_health.current
Step 4: Scenario-Based Tests¶
Create tests for specific game scenarios:
# tests/test_scenarios.py
"""Scenario-based tests for realistic combat situations."""
import pytest
from uuid import uuid4
from maid_stdlib.components import HealthComponent, PositionComponent
from maid_combat_system.components import AttackComponent, DefenseComponent
from maid_combat_system.events import AttackRequestEvent, DamageDealtEvent
class TestBossEncounter:
"""Test a boss encounter scenario."""
@pytest.fixture
def boss_entity(self, entity_factory):
"""Create a boss monster."""
return entity_factory.create_monster(
name="Dragon",
health=500,
attack_power=50,
defense=20,
accuracy=90,
critical_chance=15,
)
@pytest.fixture
def party(self, entity_factory):
"""Create a party of adventurers."""
return [
entity_factory.create_player(
name="Warrior",
health=150,
attack_power=20,
defense=15,
),
entity_factory.create_player(
name="Rogue",
health=80,
attack_power=15,
defense=5,
critical_chance=25,
),
entity_factory.create_player(
name="Mage",
health=60,
attack_power=35,
defense=0,
),
]
@pytest.mark.asyncio
async def test_party_vs_boss(
self,
world,
started_combat_system,
boss_entity,
party,
event_capture
):
"""Test a party attacking a boss."""
world.events.subscribe(DamageDealtEvent, event_capture.capture)
# Each party member attacks the boss
for member in party:
member.get(AttackComponent).accuracy = 100 # Ensure hits
member.get(DefenseComponent).evasion = 0
await world.events.emit(AttackRequestEvent(
attacker_id=member.id,
target_id=boss_entity.id,
))
# All party members should have hit
assert event_capture.count() == 3
# Boss should have taken damage from all
boss_health = boss_entity.get(HealthComponent)
assert boss_health.current < boss_health.maximum
class TestPvPCombat:
"""Test player vs player combat scenarios."""
@pytest.mark.asyncio
async def test_duel_to_death(
self,
world,
started_combat_system,
entity_factory
):
"""Test a duel between two evenly matched players."""
player1 = entity_factory.create_player(
name="Duelist1",
health=100,
attack_power=15,
accuracy=75,
defense=5,
evasion=10,
)
player2 = entity_factory.create_player(
name="Duelist2",
health=100,
attack_power=15,
accuracy=75,
defense=5,
evasion=10,
)
round_count = 0
max_rounds = 50
while round_count < max_rounds:
p1_health = player1.get(HealthComponent)
p2_health = player2.get(HealthComponent)
if not p1_health.is_alive or not p2_health.is_alive:
break
# Each player attacks
await world.events.emit(AttackRequestEvent(
attacker_id=player1.id,
target_id=player2.id,
))
await world.events.emit(AttackRequestEvent(
attacker_id=player2.id,
target_id=player1.id,
))
round_count += 1
# One player should be dead or we hit max rounds
p1_health = player1.get(HealthComponent)
p2_health = player2.get(HealthComponent)
assert not p1_health.is_alive or not p2_health.is_alive or round_count == max_rounds
class TestAmbushScenario:
"""Test ambush scenarios with positioning."""
@pytest.mark.asyncio
async def test_cannot_attack_from_different_room(
self,
world,
started_combat_system,
entity_factory,
second_room_id,
event_capture
):
"""Test that attacks across rooms fail."""
attacker = entity_factory.create_player()
target = entity_factory.create_monster()
# Move target to different room
target.get(PositionComponent).room_id = second_room_id
world.events.subscribe(DamageDealtEvent, event_capture.capture)
# Try to attack
await world.events.emit(AttackRequestEvent(
attacker_id=attacker.id,
target_id=target.id,
))
# No damage should occur
assert event_capture.count() == 0
class TestResistanceScenario:
"""Test damage resistance scenarios."""
@pytest.mark.asyncio
async def test_fire_resistance_reduces_fire_damage(
self,
world,
started_combat_system,
entity_factory,
event_capture
):
"""Test that fire resistance reduces fire damage."""
fire_mage = entity_factory.create_player(name="FireMage")
fire_mage.get(AttackComponent).damage_type = "fire"
fire_mage.get(AttackComponent).attack_power = 20
fire_mage.get(AttackComponent).accuracy = 100
fire_mage.get(AttackComponent).critical_chance = 0
# Target with fire resistance
fire_resistant = entity_factory.create_monster(name="FireDrake")
fire_resistant.get(DefenseComponent).defense = 0
fire_resistant.get(DefenseComponent).evasion = 0
fire_resistant.get(DefenseComponent).resistances = {"fire": 50}
world.events.subscribe(DamageDealtEvent, event_capture.capture)
await world.events.emit(AttackRequestEvent(
attacker_id=fire_mage.id,
target_id=fire_resistant.id,
))
# Should take half damage (50% resistance)
assert event_capture.count() == 1
damage_event = event_capture.get_last()
assert damage_event.damage == 10 # 20 * 0.5 = 10
Step 5: Running Tests¶
Run all tests with coverage:
# Run all tests
uv run pytest tests/ -v
# Run with coverage
uv run pytest tests/ --cov=maid_combat_system --cov-report=html
# Run specific test file
uv run pytest tests/test_scenarios.py -v
# Run tests matching a pattern
uv run pytest tests/ -k "boss" -v
# Run with detailed output on failures
uv run pytest tests/ -v --tb=long
Step 6: Test Best Practices¶
Use Deterministic Values¶
When testing damage calculation, set accuracy to 100% and critical chance to 0%:
attack = player_entity.get(AttackComponent)
attack.accuracy = 100 # Always hits
attack.critical_chance = 0 # Never crits
defense = enemy_entity.get(DefenseComponent)
defense.evasion = 0 # Never evades
Test Edge Cases¶
def test_zero_health_target():
"""Test attacking a target with 0 health."""
...
def test_max_defense():
"""Test target with maximum defense."""
...
def test_missing_components():
"""Test attacking target without HealthComponent."""
...
Use Fixtures Appropriately¶
Create specific fixtures for specific tests:
@pytest.fixture
def glass_cannon(entity_factory):
"""Entity with high attack, no defense."""
return entity_factory.create_player(
attack_power=50,
defense=0,
health=30,
)
@pytest.fixture
def tank(entity_factory):
"""Entity with high defense, low attack."""
return entity_factory.create_player(
attack_power=5,
defense=30,
health=200,
)
Summary¶
In this tutorial series, you have:
- Part 1: Set up the project structure and content pack
- Part 2: Created combat components (Attack, Defense)
- Part 3: Built the CombatSystem with event-driven architecture
- Part 4: Implemented player commands (attack, consider)
- Part 5: Created comprehensive tests for all components
Complete Project Structure¶
Your final project should look like this:
maid-combat-system/
src/
maid_combat_system/
__init__.py
pack.py
components/
__init__.py
combat.py
systems/
__init__.py
combat_system.py
combat_messages.py
commands/
__init__.py
combat_commands.py
events/
__init__.py
combat_events.py
tests/
__init__.py
conftest.py
test_components.py
test_events.py
test_system.py
test_commands.py
test_integration.py
test_scenarios.py
pyproject.toml
README.md
Next Steps¶
Now that you have a complete combat system, consider:
- Extend the system: Add auto-attack, damage over time, combo attacks
- Add persistence: Save combat statistics to the document store
- Create UI integration: Build a combat log component
- Add AI: Create intelligent enemy targeting
- Build the magic system: See the Magic System Tutorial