Update Writing Tests
parent
307b2615f4
commit
f05289abc2
1 changed files with 337 additions and 337 deletions
|
|
@ -1,338 +1,338 @@
|
||||||
# Writing Tests
|
# Writing Tests
|
||||||
|
|
||||||
Guide to creating automated tests for McRogueFace using Python and the automation API.
|
Guide to creating automated tests for McRogueFace using Python and the automation API.
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
**Test Location:** `tests/` directory (NOT `build/tests/` - that gets shipped!)
|
**Test Location:** `tests/` directory (NOT `build/tests/` - that gets shipped!)
|
||||||
|
|
||||||
**Test Types:**
|
**Test Types:**
|
||||||
1. **Direct Execution** - No game loop, immediate results
|
1. **Direct Execution** - No game loop, immediate results
|
||||||
2. **Timer-Based** - Requires rendering/game loop
|
2. **Timer-Based** - Requires rendering/game loop
|
||||||
|
|
||||||
**Key Tools:**
|
**Key Tools:**
|
||||||
- `mcrfpy.automation` - Screenshot and input automation
|
- `mcrfpy.automation` - Screenshot and input automation
|
||||||
- `--headless` flag - Run without display
|
- `--headless` flag - Run without display
|
||||||
- `--exec` flag - Execute specific script
|
- `--exec` flag - Execute specific script
|
||||||
|
|
||||||
**Format:** See CLAUDE.md "Testing Guidelines" section
|
**Format:** See CLAUDE.md "Testing Guidelines" section
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Test Type 1: Direct Execution
|
## Test Type 1: Direct Execution
|
||||||
|
|
||||||
For tests that don't need rendering or game state.
|
For tests that don't need rendering or game state.
|
||||||
|
|
||||||
### Template
|
### Template
|
||||||
|
|
||||||
```python
|
```python
|
||||||
"""Test description goes here."""
|
"""Test description goes here."""
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
def test_feature():
|
def test_feature():
|
||||||
# Setup
|
# Setup
|
||||||
scene = mcrfpy.Scene("test")
|
scene = mcrfpy.Scene("test")
|
||||||
frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||||
scene.children.append(frame)
|
scene.children.append(frame)
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
frame.x = 42
|
frame.x = 42
|
||||||
assert frame.x == 42, f"Expected 42, got {frame.x}"
|
assert frame.x == 42, f"Expected 42, got {frame.x}"
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
if test_feature():
|
if test_feature():
|
||||||
print("PASS")
|
print("PASS")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"FAIL: {e}")
|
print(f"FAIL: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running
|
### Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd build
|
cd build
|
||||||
./mcrogueface --headless --exec ../tests/unit/test_myfeature.py
|
./mcrogueface --headless --exec ../tests/unit/test_myfeature.py
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Test Type 2: Timer-Based Tests
|
## Test Type 2: Timer-Based Tests
|
||||||
|
|
||||||
For tests requiring rendering, screenshots, or elapsed time.
|
For tests requiring rendering, screenshots, or elapsed time.
|
||||||
|
|
||||||
### Template
|
### Template
|
||||||
|
|
||||||
```python
|
```python
|
||||||
"""Test requiring game loop."""
|
"""Test requiring game loop."""
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Setup scene BEFORE game loop starts
|
# Setup scene BEFORE game loop starts
|
||||||
scene = mcrfpy.Scene("test")
|
scene = mcrfpy.Scene("test")
|
||||||
ui = scene.children
|
ui = scene.children
|
||||||
|
|
||||||
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150),
|
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150),
|
||||||
fill_color=mcrfpy.Color(255, 0, 0))
|
fill_color=mcrfpy.Color(255, 0, 0))
|
||||||
ui.append(frame)
|
ui.append(frame)
|
||||||
mcrfpy.current_scene = scene
|
mcrfpy.current_scene = scene
|
||||||
|
|
||||||
def run_test(timer, runtime):
|
def run_test(timer, runtime):
|
||||||
"""Timer callback receives (timer_object, runtime_ms)."""
|
"""Timer callback receives (timer_object, runtime_ms)."""
|
||||||
automation.screenshot("test_output.png")
|
automation.screenshot("test_output.png")
|
||||||
|
|
||||||
# Verify results
|
# Verify results
|
||||||
assert frame.x == 100
|
assert frame.x == 100
|
||||||
|
|
||||||
print("PASS")
|
print("PASS")
|
||||||
sys.exit(0) # MUST exit!
|
sys.exit(0) # MUST exit!
|
||||||
|
|
||||||
# Schedule test to run after game loop starts
|
# Schedule test to run after game loop starts
|
||||||
test_timer = mcrfpy.Timer("test_runner", run_test, 100)
|
test_timer = mcrfpy.Timer("test_runner", run_test, 100)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important:** Timer callbacks receive `(timer, runtime_ms)`. Screenshots only work after rendering starts.
|
**Important:** Timer callbacks receive `(timer, runtime_ms)`. Screenshots only work after rendering starts.
|
||||||
|
|
||||||
### Example: Testing Click Events
|
### Example: Testing Click Events
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
clicks_received = []
|
clicks_received = []
|
||||||
|
|
||||||
scene = mcrfpy.Scene("test")
|
scene = mcrfpy.Scene("test")
|
||||||
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150),
|
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150),
|
||||||
fill_color=mcrfpy.Color(0, 255, 0))
|
fill_color=mcrfpy.Color(0, 255, 0))
|
||||||
|
|
||||||
def on_click(pos, button, action):
|
def on_click(pos, button, action):
|
||||||
clicks_received.append((pos, button, action))
|
clicks_received.append((pos, button, action))
|
||||||
|
|
||||||
frame.on_click = on_click
|
frame.on_click = on_click
|
||||||
scene.children.append(frame)
|
scene.children.append(frame)
|
||||||
mcrfpy.current_scene = scene
|
mcrfpy.current_scene = scene
|
||||||
|
|
||||||
def run_test(timer, runtime):
|
def run_test(timer, runtime):
|
||||||
# Simulate click on frame center
|
# Simulate click on frame center
|
||||||
automation.click(200, 175)
|
automation.click(200, 175)
|
||||||
# Give it a frame to process
|
# Give it a frame to process
|
||||||
verify_timer = mcrfpy.Timer("verify", verify_results, 32)
|
verify_timer = mcrfpy.Timer("verify", verify_results, 32)
|
||||||
|
|
||||||
def verify_results(timer, runtime):
|
def verify_results(timer, runtime):
|
||||||
assert len(clicks_received) > 0, "No clicks received!"
|
assert len(clicks_received) > 0, "No clicks received!"
|
||||||
pos, button, action = clicks_received[0]
|
pos, button, action = clicks_received[0]
|
||||||
print(f"Click received at ({pos.x}, {pos.y})")
|
print(f"Click received at ({pos.x}, {pos.y})")
|
||||||
print("PASS")
|
print("PASS")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
test_timer = mcrfpy.Timer("test", run_test, 100)
|
test_timer = mcrfpy.Timer("test", run_test, 100)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Automation API
|
## Automation API
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
|
|
||||||
# Take screenshot (synchronous in headless mode)
|
# Take screenshot (synchronous in headless mode)
|
||||||
automation.screenshot("output.png")
|
automation.screenshot("output.png")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mouse Input
|
### Mouse Input
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
|
|
||||||
automation.click(x, y) # Left click at position
|
automation.click(x, y) # Left click at position
|
||||||
automation.move(x, y) # Move mouse to position
|
automation.move(x, y) # Move mouse to position
|
||||||
```
|
```
|
||||||
|
|
||||||
### Keyboard Input
|
### Keyboard Input
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
|
|
||||||
automation.keypress(key_code) # Simulate key press
|
automation.keypress(key_code) # Simulate key press
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing Patterns
|
## Testing Patterns
|
||||||
|
|
||||||
### Pattern 1: Property Round-Trip
|
### Pattern 1: Property Round-Trip
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_property_roundtrip():
|
def test_property_roundtrip():
|
||||||
obj = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
obj = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||||
test_values = [0, 50, 100, 255, 127]
|
test_values = [0, 50, 100, 255, 127]
|
||||||
for value in test_values:
|
for value in test_values:
|
||||||
obj.x = value
|
obj.x = value
|
||||||
assert obj.x == value, f"Failed for {value}"
|
assert obj.x == value, f"Failed for {value}"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pattern 2: Exception Testing
|
### Pattern 2: Exception Testing
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_invalid_input():
|
def test_invalid_input():
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160))
|
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160))
|
||||||
try:
|
try:
|
||||||
grid.at(-1, -1)
|
grid.at(-1, -1)
|
||||||
assert False, "Should have raised exception"
|
assert False, "Should have raised exception"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Expected
|
pass # Expected
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pattern 3: Grid Operations
|
### Pattern 3: Grid Operations
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_grid_walkable():
|
def test_grid_walkable():
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160))
|
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160))
|
||||||
grid.at(5, 5).walkable = True
|
grid.at(5, 5).walkable = True
|
||||||
assert grid.at(5, 5).walkable == True
|
assert grid.at(5, 5).walkable == True
|
||||||
|
|
||||||
grid.at(5, 5).walkable = False
|
grid.at(5, 5).walkable = False
|
||||||
assert grid.at(5, 5).walkable == False
|
assert grid.at(5, 5).walkable == False
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pattern 4: Entity Lifecycle
|
### Pattern 4: Entity Lifecycle
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_entity_lifecycle():
|
def test_entity_lifecycle():
|
||||||
grid = mcrfpy.Grid(grid_size=(20, 20), pos=(0, 0), size=(320, 320))
|
grid = mcrfpy.Grid(grid_size=(20, 20), pos=(0, 0), size=(320, 320))
|
||||||
|
|
||||||
# Create and add
|
# Create and add
|
||||||
entity = mcrfpy.Entity(grid_pos=(5, 5), sprite_index=0)
|
entity = mcrfpy.Entity(grid_pos=(5, 5), sprite_index=0)
|
||||||
assert entity.grid is None
|
assert entity.grid is None
|
||||||
grid.entities.append(entity)
|
grid.entities.append(entity)
|
||||||
assert entity.grid is not None
|
assert entity.grid is not None
|
||||||
|
|
||||||
# Move
|
# Move
|
||||||
entity.grid_x = 10
|
entity.grid_x = 10
|
||||||
assert entity.grid_x == 10
|
assert entity.grid_x == 10
|
||||||
|
|
||||||
# Remove
|
# Remove
|
||||||
entity.die()
|
entity.die()
|
||||||
assert entity.grid is None
|
assert entity.grid is None
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pattern 5: Callback Setup
|
### Pattern 5: Callback Setup
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_click_callbacks():
|
def test_click_callbacks():
|
||||||
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150))
|
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150))
|
||||||
clicks = []
|
clicks = []
|
||||||
|
|
||||||
# on_click receives (pos: Vector, button: MouseButton, action: InputState)
|
# on_click receives (pos: Vector, button: MouseButton, action: InputState)
|
||||||
def on_click(pos, button, action):
|
def on_click(pos, button, action):
|
||||||
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.PRESSED:
|
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.PRESSED:
|
||||||
clicks.append((pos.x, pos.y))
|
clicks.append((pos.x, pos.y))
|
||||||
|
|
||||||
frame.on_click = on_click
|
frame.on_click = on_click
|
||||||
# on_enter/on_exit receive (pos: Vector)
|
# on_enter/on_exit receive (pos: Vector)
|
||||||
frame.on_enter = lambda pos: None
|
frame.on_enter = lambda pos: None
|
||||||
frame.on_exit = lambda pos: None
|
frame.on_exit = lambda pos: None
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pattern 6: Timer Usage
|
### Pattern 6: Timer Usage
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_timer():
|
def test_timer():
|
||||||
fired = [False]
|
fired = [False]
|
||||||
|
|
||||||
def on_timer(timer, runtime):
|
def on_timer(timer, runtime):
|
||||||
fired[0] = True
|
fired[0] = True
|
||||||
|
|
||||||
t = mcrfpy.Timer("test_timer", on_timer, 100)
|
t = mcrfpy.Timer("test_timer", on_timer, 100)
|
||||||
|
|
||||||
# In headless mode, use step() to advance time
|
# In headless mode, use step() to advance time
|
||||||
mcrfpy.step(0.2) # 200ms
|
mcrfpy.step(0.2) # 200ms
|
||||||
assert fired[0], "Timer should have fired"
|
assert fired[0], "Timer should have fired"
|
||||||
|
|
||||||
t.stop() # Clean up
|
t.stop() # Clean up
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pattern 7: Animation Testing
|
### Pattern 7: Animation Testing
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_animation():
|
def test_animation():
|
||||||
frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||||
frame.animate("x", 500.0, 2.0, mcrfpy.Easing.EASE_IN_OUT)
|
frame.animate("x", 500.0, 2.0, mcrfpy.Easing.EASE_IN_OUT)
|
||||||
|
|
||||||
# In headless mode, advance time to test
|
# In headless mode, advance time to test
|
||||||
mcrfpy.step(1.0) # Halfway through
|
mcrfpy.step(1.0) # Halfway through
|
||||||
assert frame.x > 0 # Should have moved
|
assert frame.x > 0 # Should have moved
|
||||||
|
|
||||||
mcrfpy.step(1.0) # Complete
|
mcrfpy.step(1.0) # Complete
|
||||||
# frame.x should be at or near 500
|
# frame.x should be at or near 500
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Test-Driven Development (TDD)
|
## Test-Driven Development (TDD)
|
||||||
|
|
||||||
### TDD Workflow
|
### TDD Workflow
|
||||||
|
|
||||||
1. **Write failing test** - Demonstrates the bug/missing feature
|
1. **Write failing test** - Demonstrates the bug/missing feature
|
||||||
2. **Run test** - Verify it fails
|
2. **Run test** - Verify it fails
|
||||||
3. **Implement fix** - Make minimum change to pass
|
3. **Implement fix** - Make minimum change to pass
|
||||||
4. **Run test** - Verify it passes
|
4. **Run test** - Verify it passes
|
||||||
5. **Refactor** - Clean up
|
5. **Refactor** - Clean up
|
||||||
6. **Run full suite** - `cd tests && python3 run_tests.py`
|
6. **Run full suite** - `cd tests && python3 run_tests.py`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing Best Practices
|
## Testing Best Practices
|
||||||
|
|
||||||
### DO:
|
### DO:
|
||||||
|
|
||||||
- **Test one thing at a time** - Each test function covers one behavior
|
- **Test one thing at a time** - Each test function covers one behavior
|
||||||
- **Use descriptive names** - `test_entity_moves_to_valid_position()`
|
- **Use descriptive names** - `test_entity_moves_to_valid_position()`
|
||||||
- **Always exit** - Use `sys.exit(0)` for pass, `sys.exit(1)` for fail
|
- **Always exit** - Use `sys.exit(0)` for pass, `sys.exit(1)` for fail
|
||||||
- **Test edge cases** - Boundaries, empty states, invalid input
|
- **Test edge cases** - Boundaries, empty states, invalid input
|
||||||
- **Clean up timers** - Call `timer.stop()` when done
|
- **Clean up timers** - Call `timer.stop()` when done
|
||||||
|
|
||||||
### DON'T:
|
### DON'T:
|
||||||
|
|
||||||
- **Rely on timing** - Use `step()` in headless mode, not `time.sleep()`
|
- **Rely on timing** - Use `step()` in headless mode, not `time.sleep()`
|
||||||
- **Forget sys.exit()** in timer tests - Will hang indefinitely
|
- **Forget sys.exit()** in timer tests - Will hang indefinitely
|
||||||
- **Test multiple unrelated things** in one test function
|
- **Test multiple unrelated things** in one test function
|
||||||
- **Put tests in build/** - They get shipped with the game
|
- **Put tests in build/** - They get shipped with the game
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running the Test Suite
|
## Running the Test Suite
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Full suite
|
# Full suite
|
||||||
cd tests && python3 run_tests.py
|
cd tests && python3 run_tests.py
|
||||||
|
|
||||||
# Single test
|
# Single test
|
||||||
cd build && ./mcrogueface --headless --exec ../tests/unit/my_test.py
|
cd build && ./mcrogueface --headless --exec ../tests/unit/my_test.py
|
||||||
|
|
||||||
# Inline test
|
# Inline test
|
||||||
cd build && ./mcrogueface --headless -c "import mcrfpy; print('OK')"
|
cd build && ./mcrogueface --headless -c "import mcrfpy; print('OK')"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [[Headless-Mode]] - step() function and headless operation
|
- [[Headless-Mode]] - step() function and headless operation
|
||||||
- [[Input-and-Events]] - Event handler signatures
|
- [[Input-and-Events]] - Event handler signatures
|
||||||
- [[Animation-System]] - Animation testing
|
- [[Animation-System]] - Animation testing
|
||||||
- CLAUDE.md - Testing guidelines section
|
- CLAUDE.md - Testing guidelines section
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Last updated: 2026-02-07*
|
*Last updated: 2026-02-07*
|
||||||
Loading…
Add table
Add a link
Reference in a new issue