3 Writing Tests
John McCardle edited this page 2026-02-07 23:47:32 +00:00

Writing Tests

Guide to creating automated tests for McRogueFace using Python and the automation API.

Quick Reference

Test Location: tests/ directory (NOT build/tests/ - that gets shipped!)

Test Types:

  1. Direct Execution - No game loop, immediate results
  2. Timer-Based - Requires rendering/game loop

Key Tools:

  • mcrfpy.automation - Screenshot and input automation
  • --headless flag - Run without display
  • --exec flag - Execute specific script

Format: See CLAUDE.md "Testing Guidelines" section


Test Type 1: Direct Execution

For tests that don't need rendering or game state.

Template

"""Test description goes here."""
import mcrfpy
import sys

def test_feature():
    # Setup
    scene = mcrfpy.Scene("test")
    frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
    scene.children.append(frame)

    # Test
    frame.x = 42
    assert frame.x == 42, f"Expected 42, got {frame.x}"

    return True

if __name__ == "__main__":
    try:
        if test_feature():
            print("PASS")
            sys.exit(0)
    except Exception as e:
        print(f"FAIL: {e}")
        import traceback
        traceback.print_exc()
        sys.exit(1)

Running

cd build
./mcrogueface --headless --exec ../tests/unit/test_myfeature.py

Test Type 2: Timer-Based Tests

For tests requiring rendering, screenshots, or elapsed time.

Template

"""Test requiring game loop."""
import mcrfpy
from mcrfpy import automation
import sys

# Setup scene BEFORE game loop starts
scene = mcrfpy.Scene("test")
ui = scene.children

frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150),
                      fill_color=mcrfpy.Color(255, 0, 0))
ui.append(frame)
mcrfpy.current_scene = scene

def run_test(timer, runtime):
    """Timer callback receives (timer_object, runtime_ms)."""
    automation.screenshot("test_output.png")

    # Verify results
    assert frame.x == 100

    print("PASS")
    sys.exit(0)  # MUST exit!

# Schedule test to run after game loop starts
test_timer = mcrfpy.Timer("test_runner", run_test, 100)

Important: Timer callbacks receive (timer, runtime_ms). Screenshots only work after rendering starts.

Example: Testing Click Events

import mcrfpy
from mcrfpy import automation
import sys

clicks_received = []

scene = mcrfpy.Scene("test")
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150),
                      fill_color=mcrfpy.Color(0, 255, 0))

def on_click(pos, button, action):
    clicks_received.append((pos, button, action))

frame.on_click = on_click
scene.children.append(frame)
mcrfpy.current_scene = scene

def run_test(timer, runtime):
    # Simulate click on frame center
    automation.click(200, 175)
    # Give it a frame to process
    verify_timer = mcrfpy.Timer("verify", verify_results, 32)

def verify_results(timer, runtime):
    assert len(clicks_received) > 0, "No clicks received!"
    pos, button, action = clicks_received[0]
    print(f"Click received at ({pos.x}, {pos.y})")
    print("PASS")
    sys.exit(0)

test_timer = mcrfpy.Timer("test", run_test, 100)

Automation API

Screenshots

from mcrfpy import automation

# Take screenshot (synchronous in headless mode)
automation.screenshot("output.png")

Mouse Input

from mcrfpy import automation

automation.click(x, y)         # Left click at position
automation.move(x, y)          # Move mouse to position

Keyboard Input

from mcrfpy import automation

automation.keypress(key_code)  # Simulate key press

Testing Patterns

Pattern 1: Property Round-Trip

def test_property_roundtrip():
    obj = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
    test_values = [0, 50, 100, 255, 127]
    for value in test_values:
        obj.x = value
        assert obj.x == value, f"Failed for {value}"

Pattern 2: Exception Testing

def test_invalid_input():
    grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160))
    try:
        grid.at(-1, -1)
        assert False, "Should have raised exception"
    except Exception:
        pass  # Expected

Pattern 3: Grid Operations

def test_grid_walkable():
    grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160))
    grid.at(5, 5).walkable = True
    assert grid.at(5, 5).walkable == True

    grid.at(5, 5).walkable = False
    assert grid.at(5, 5).walkable == False

Pattern 4: Entity Lifecycle

def test_entity_lifecycle():
    grid = mcrfpy.Grid(grid_size=(20, 20), pos=(0, 0), size=(320, 320))

    # Create and add
    entity = mcrfpy.Entity(grid_pos=(5, 5), sprite_index=0)
    assert entity.grid is None
    grid.entities.append(entity)
    assert entity.grid is not None

    # Move
    entity.grid_x = 10
    assert entity.grid_x == 10

    # Remove
    entity.die()
    assert entity.grid is None

Pattern 5: Callback Setup

def test_click_callbacks():
    frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150))
    clicks = []

    # on_click receives (pos: Vector, button: MouseButton, action: InputState)
    def on_click(pos, button, action):
        if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.PRESSED:
            clicks.append((pos.x, pos.y))

    frame.on_click = on_click
    # on_enter/on_exit receive (pos: Vector)
    frame.on_enter = lambda pos: None
    frame.on_exit = lambda pos: None

Pattern 6: Timer Usage

def test_timer():
    fired = [False]

    def on_timer(timer, runtime):
        fired[0] = True

    t = mcrfpy.Timer("test_timer", on_timer, 100)

    # In headless mode, use step() to advance time
    mcrfpy.step(0.2)  # 200ms
    assert fired[0], "Timer should have fired"

    t.stop()  # Clean up

Pattern 7: Animation Testing

def test_animation():
    frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
    frame.animate("x", 500.0, 2.0, mcrfpy.Easing.EASE_IN_OUT)

    # In headless mode, advance time to test
    mcrfpy.step(1.0)  # Halfway through
    assert frame.x > 0  # Should have moved

    mcrfpy.step(1.0)  # Complete
    # frame.x should be at or near 500

Test-Driven Development (TDD)

TDD Workflow

  1. Write failing test - Demonstrates the bug/missing feature
  2. Run test - Verify it fails
  3. Implement fix - Make minimum change to pass
  4. Run test - Verify it passes
  5. Refactor - Clean up
  6. Run full suite - cd tests && python3 run_tests.py

Testing Best Practices

DO:

  • Test one thing at a time - Each test function covers one behavior
  • Use descriptive names - test_entity_moves_to_valid_position()
  • Always exit - Use sys.exit(0) for pass, sys.exit(1) for fail
  • Test edge cases - Boundaries, empty states, invalid input
  • Clean up timers - Call timer.stop() when done

DON'T:

  • Rely on timing - Use step() in headless mode, not time.sleep()
  • Forget sys.exit() in timer tests - Will hang indefinitely
  • Test multiple unrelated things in one test function
  • Put tests in build/ - They get shipped with the game

Running the Test Suite

# Full suite
cd tests && python3 run_tests.py

# Single test
cd build && ./mcrogueface --headless --exec ../tests/unit/my_test.py

# Inline test
cd build && ./mcrogueface --headless -c "import mcrfpy; print('OK')"


Last updated: 2026-02-07