Skip to content

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:

  1. Unit Tests: Test individual components and their methods
  2. System Tests: Test the combat system in isolation
  3. Integration Tests: Test the full combat flow end-to-end
  4. 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:

  1. Part 1: Set up the project structure and content pack
  2. Part 2: Created combat components (Attack, Defense)
  3. Part 3: Built the CombatSystem with event-driven architecture
  4. Part 4: Implemented player commands (attack, consider)
  5. 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:

  1. Extend the system: Add auto-attack, damage over time, combo attacks
  2. Add persistence: Save combat statistics to the document store
  3. Create UI integration: Build a combat log component
  4. Add AI: Create intelligent enemy targeting
  5. Build the magic system: See the Magic System Tutorial

Additional Resources