feat(bmgd): add E2E testing support for Godot and Unreal
Godot: - Add C# testing with xUnit/NSubstitute alongside GDScript GUT - Add E2E infrastructure: GameE2ETestFixture, ScenarioBuilder, InputSimulator, AsyncAssert (all GDScript) - Add example E2E tests and quick checklist Unreal: - Add E2E infrastructure extending AFunctionalTest - Add GameE2ETestBase, ScenarioBuilder, InputSimulator classes - Add AsyncTestHelpers with latent commands and macros - Add example E2E tests for combat and turn cycle - Add CLI commands for running E2E tests
This commit is contained in:
parent
04cc4ce55c
commit
c3c9dbd3c2
|
|
@ -374,3 +374,502 @@ test:
|
|||
| Signal not detected | Signal not watched | Call `watch_signals()` before action |
|
||||
| Physics not working | Missing frames | Await `physics_frame` |
|
||||
| Flaky tests | Timing issues | Use proper await/signals |
|
||||
|
||||
## C# Testing in Godot
|
||||
|
||||
Godot 4 supports C# via .NET 6+. You can use standard .NET testing frameworks alongside GUT.
|
||||
|
||||
### Project Setup for C#
|
||||
|
||||
```
|
||||
project/
|
||||
├── addons/
|
||||
│ └── gut/
|
||||
├── src/
|
||||
│ ├── Player/
|
||||
│ │ └── PlayerController.cs
|
||||
│ └── Combat/
|
||||
│ └── DamageCalculator.cs
|
||||
├── tests/
|
||||
│ ├── gdscript/
|
||||
│ │ └── test_integration.gd
|
||||
│ └── csharp/
|
||||
│ ├── Tests.csproj
|
||||
│ └── DamageCalculatorTests.cs
|
||||
└── project.csproj
|
||||
```
|
||||
|
||||
### C# Test Project Setup
|
||||
|
||||
Create a separate test project that references your game assembly:
|
||||
|
||||
```xml
|
||||
<!-- tests/csharp/Tests.csproj -->
|
||||
<Project Sdk="Godot.NET.Sdk/4.2.0">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
|
||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../project.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
### Basic C# Unit Tests
|
||||
|
||||
```csharp
|
||||
// tests/csharp/DamageCalculatorTests.cs
|
||||
using Xunit;
|
||||
using YourGame.Combat;
|
||||
|
||||
public class DamageCalculatorTests
|
||||
{
|
||||
private readonly DamageCalculator _calculator;
|
||||
|
||||
public DamageCalculatorTests()
|
||||
{
|
||||
_calculator = new DamageCalculator();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_BaseDamage_ReturnsCorrectValue()
|
||||
{
|
||||
var result = _calculator.Calculate(100f, 1f);
|
||||
Assert.Equal(100f, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_CriticalHit_DoublesDamage()
|
||||
{
|
||||
var result = _calculator.Calculate(100f, 2f);
|
||||
Assert.Equal(200f, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(100f, 0.5f, 50f)]
|
||||
[InlineData(100f, 1.5f, 150f)]
|
||||
[InlineData(50f, 2f, 100f)]
|
||||
public void Calculate_Parameterized_ReturnsExpected(
|
||||
float baseDamage, float multiplier, float expected)
|
||||
{
|
||||
var result = _calculator.Calculate(baseDamage, multiplier);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Godot Nodes in C#
|
||||
|
||||
For tests requiring Godot runtime, use a hybrid approach:
|
||||
|
||||
```csharp
|
||||
// tests/csharp/PlayerControllerTests.cs
|
||||
using Godot;
|
||||
using Xunit;
|
||||
using YourGame.Player;
|
||||
|
||||
public class PlayerControllerTests : IDisposable
|
||||
{
|
||||
private readonly SceneTree _sceneTree;
|
||||
private PlayerController _player;
|
||||
|
||||
public PlayerControllerTests()
|
||||
{
|
||||
// These tests must run within Godot runtime
|
||||
// Use GodotXUnit or similar adapter
|
||||
}
|
||||
|
||||
[GodotFact] // Custom attribute for Godot runtime tests
|
||||
public async Task Player_Move_ChangesPosition()
|
||||
{
|
||||
var startPos = _player.GlobalPosition;
|
||||
|
||||
_player.SetInput(new Vector2(1, 0));
|
||||
|
||||
await ToSignal(GetTree().CreateTimer(0.5f), "timeout");
|
||||
|
||||
Assert.True(_player.GlobalPosition.X > startPos.X);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_player?.QueueFree();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### C# Mocking with NSubstitute
|
||||
|
||||
```csharp
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
public class EnemyAITests
|
||||
{
|
||||
[Fact]
|
||||
public void Enemy_UsesPathfinding_WhenMoving()
|
||||
{
|
||||
var mockPathfinding = Substitute.For<IPathfinding>();
|
||||
mockPathfinding.FindPath(Arg.Any<Vector2>(), Arg.Any<Vector2>())
|
||||
.Returns(new[] { Vector2.Zero, new Vector2(10, 10) });
|
||||
|
||||
var enemy = new EnemyAI(mockPathfinding);
|
||||
|
||||
enemy.MoveTo(new Vector2(10, 10));
|
||||
|
||||
mockPathfinding.Received().FindPath(
|
||||
Arg.Any<Vector2>(),
|
||||
Arg.Is<Vector2>(v => v == new Vector2(10, 10)));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Running C# Tests
|
||||
|
||||
```bash
|
||||
# Run C# unit tests (no Godot runtime needed)
|
||||
dotnet test tests/csharp/Tests.csproj
|
||||
|
||||
# Run with coverage
|
||||
dotnet test tests/csharp/Tests.csproj --collect:"XPlat Code Coverage"
|
||||
|
||||
# Run specific test
|
||||
dotnet test tests/csharp/Tests.csproj --filter "FullyQualifiedName~DamageCalculator"
|
||||
```
|
||||
|
||||
### Hybrid Test Strategy
|
||||
|
||||
| Test Type | Framework | When to Use |
|
||||
| ------------- | ---------------- | ---------------------------------- |
|
||||
| Pure logic | xUnit/NUnit (C#) | Classes without Godot dependencies |
|
||||
| Node behavior | GUT (GDScript) | MonoBehaviour-like testing |
|
||||
| Integration | GUT (GDScript) | Scene and signal testing |
|
||||
| E2E | GUT (GDScript) | Full gameplay flows |
|
||||
|
||||
## End-to-End Testing
|
||||
|
||||
For comprehensive E2E testing patterns, infrastructure scaffolding, and
|
||||
scenario builders, see **knowledge/e2e-testing.md**.
|
||||
|
||||
### E2E Infrastructure for Godot
|
||||
|
||||
#### GameE2ETestFixture (GDScript)
|
||||
|
||||
```gdscript
|
||||
# tests/e2e/infrastructure/game_e2e_test_fixture.gd
|
||||
extends GutTest
|
||||
class_name GameE2ETestFixture
|
||||
|
||||
var game_state: GameStateManager
|
||||
var input_sim: InputSimulator
|
||||
var scenario: ScenarioBuilder
|
||||
var _scene_instance: Node
|
||||
|
||||
## Override to specify a different scene for specific test classes.
|
||||
func get_scene_path() -> String:
|
||||
return "res://scenes/game.tscn"
|
||||
|
||||
func before_each():
|
||||
# Load game scene
|
||||
var scene = load(get_scene_path())
|
||||
_scene_instance = scene.instantiate()
|
||||
add_child(_scene_instance)
|
||||
|
||||
# Get references
|
||||
game_state = _scene_instance.get_node("GameStateManager")
|
||||
assert_not_null(game_state, "GameStateManager not found in scene")
|
||||
|
||||
input_sim = InputSimulator.new()
|
||||
scenario = ScenarioBuilder.new(game_state)
|
||||
|
||||
# Wait for ready
|
||||
await wait_for_game_ready()
|
||||
|
||||
func after_each():
|
||||
if _scene_instance:
|
||||
_scene_instance.queue_free()
|
||||
_scene_instance = null
|
||||
input_sim = null
|
||||
scenario = null
|
||||
|
||||
func wait_for_game_ready(timeout: float = 10.0):
|
||||
var elapsed = 0.0
|
||||
while not game_state.is_ready and elapsed < timeout:
|
||||
await get_tree().process_frame
|
||||
elapsed += get_process_delta_time()
|
||||
assert_true(game_state.is_ready, "Game should be ready within timeout")
|
||||
```
|
||||
|
||||
#### ScenarioBuilder (GDScript)
|
||||
|
||||
```gdscript
|
||||
# tests/e2e/infrastructure/scenario_builder.gd
|
||||
extends RefCounted
|
||||
class_name ScenarioBuilder
|
||||
|
||||
var _game_state: GameStateManager
|
||||
var _setup_actions: Array[Callable] = []
|
||||
|
||||
func _init(game_state: GameStateManager):
|
||||
_game_state = game_state
|
||||
|
||||
## Load a pre-configured scenario from a save file.
|
||||
func from_save_file(file_name: String) -> ScenarioBuilder:
|
||||
_setup_actions.append(func(): await _load_save_file(file_name))
|
||||
return self
|
||||
|
||||
## Configure the current turn number.
|
||||
func on_turn(turn_number: int) -> ScenarioBuilder:
|
||||
_setup_actions.append(func(): _set_turn(turn_number))
|
||||
return self
|
||||
|
||||
## Spawn a unit at position.
|
||||
func with_unit(faction: int, position: Vector2, movement_points: int = 6) -> ScenarioBuilder:
|
||||
_setup_actions.append(func(): await _spawn_unit(faction, position, movement_points))
|
||||
return self
|
||||
|
||||
## Execute all configured setup actions.
|
||||
func build() -> void:
|
||||
for action in _setup_actions:
|
||||
await action.call()
|
||||
_setup_actions.clear()
|
||||
|
||||
## Clear pending actions without executing.
|
||||
func reset() -> void:
|
||||
_setup_actions.clear()
|
||||
|
||||
# Private implementation
|
||||
func _load_save_file(file_name: String) -> void:
|
||||
var path = "res://tests/e2e/test_data/%s" % file_name
|
||||
await _game_state.load_game(path)
|
||||
|
||||
func _set_turn(turn: int) -> void:
|
||||
_game_state.set_turn_number(turn)
|
||||
|
||||
func _spawn_unit(faction: int, pos: Vector2, mp: int) -> void:
|
||||
var unit = _game_state.spawn_unit(faction, pos)
|
||||
unit.movement_points = mp
|
||||
```
|
||||
|
||||
#### InputSimulator (GDScript)
|
||||
|
||||
```gdscript
|
||||
# tests/e2e/infrastructure/input_simulator.gd
|
||||
extends RefCounted
|
||||
class_name InputSimulator
|
||||
|
||||
## Click at a world position.
|
||||
func click_world_position(world_pos: Vector2) -> void:
|
||||
var viewport = Engine.get_main_loop().root.get_viewport()
|
||||
var camera = viewport.get_camera_2d()
|
||||
var screen_pos = camera.get_screen_center_position() + (world_pos - camera.global_position)
|
||||
await click_screen_position(screen_pos)
|
||||
|
||||
## Click at a screen position.
|
||||
func click_screen_position(screen_pos: Vector2) -> void:
|
||||
var press = InputEventMouseButton.new()
|
||||
press.button_index = MOUSE_BUTTON_LEFT
|
||||
press.pressed = true
|
||||
press.position = screen_pos
|
||||
|
||||
var release = InputEventMouseButton.new()
|
||||
release.button_index = MOUSE_BUTTON_LEFT
|
||||
release.pressed = false
|
||||
release.position = screen_pos
|
||||
|
||||
Input.parse_input_event(press)
|
||||
await Engine.get_main_loop().process_frame
|
||||
Input.parse_input_event(release)
|
||||
await Engine.get_main_loop().process_frame
|
||||
|
||||
## Click a UI button by name.
|
||||
func click_button(button_name: String) -> void:
|
||||
var root = Engine.get_main_loop().root
|
||||
var button = _find_button_recursive(root, button_name)
|
||||
assert(button != null, "Button '%s' not found in scene tree" % button_name)
|
||||
|
||||
if not button.visible:
|
||||
push_warning("[InputSimulator] Button '%s' is not visible" % button_name)
|
||||
if button.disabled:
|
||||
push_warning("[InputSimulator] Button '%s' is disabled" % button_name)
|
||||
|
||||
button.pressed.emit()
|
||||
await Engine.get_main_loop().process_frame
|
||||
|
||||
func _find_button_recursive(node: Node, button_name: String) -> Button:
|
||||
if node is Button and node.name == button_name:
|
||||
return node
|
||||
for child in node.get_children():
|
||||
var found = _find_button_recursive(child, button_name)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
## Press and release a key.
|
||||
func press_key(keycode: Key) -> void:
|
||||
var press = InputEventKey.new()
|
||||
press.keycode = keycode
|
||||
press.pressed = true
|
||||
|
||||
var release = InputEventKey.new()
|
||||
release.keycode = keycode
|
||||
release.pressed = false
|
||||
|
||||
Input.parse_input_event(press)
|
||||
await Engine.get_main_loop().process_frame
|
||||
Input.parse_input_event(release)
|
||||
await Engine.get_main_loop().process_frame
|
||||
|
||||
## Simulate an input action.
|
||||
func action_press(action_name: String) -> void:
|
||||
Input.action_press(action_name)
|
||||
await Engine.get_main_loop().process_frame
|
||||
|
||||
func action_release(action_name: String) -> void:
|
||||
Input.action_release(action_name)
|
||||
await Engine.get_main_loop().process_frame
|
||||
|
||||
## Reset all input state.
|
||||
func reset() -> void:
|
||||
Input.flush_buffered_events()
|
||||
```
|
||||
|
||||
#### AsyncAssert (GDScript)
|
||||
|
||||
```gdscript
|
||||
# tests/e2e/infrastructure/async_assert.gd
|
||||
extends RefCounted
|
||||
class_name AsyncAssert
|
||||
|
||||
## Wait until condition is true, or fail after timeout.
|
||||
static func wait_until(
|
||||
condition: Callable,
|
||||
description: String,
|
||||
timeout: float = 5.0
|
||||
) -> void:
|
||||
var elapsed := 0.0
|
||||
while not condition.call() and elapsed < timeout:
|
||||
await Engine.get_main_loop().process_frame
|
||||
elapsed += Engine.get_main_loop().root.get_process_delta_time()
|
||||
|
||||
assert(condition.call(),
|
||||
"Timeout after %.1fs waiting for: %s" % [timeout, description])
|
||||
|
||||
## Wait for a value to equal expected.
|
||||
static func wait_for_value(
|
||||
getter: Callable,
|
||||
expected: Variant,
|
||||
description: String,
|
||||
timeout: float = 5.0
|
||||
) -> void:
|
||||
await wait_until(
|
||||
func(): return getter.call() == expected,
|
||||
"%s to equal '%s' (current: '%s')" % [description, expected, getter.call()],
|
||||
timeout)
|
||||
|
||||
## Wait for a float value within tolerance.
|
||||
static func wait_for_value_approx(
|
||||
getter: Callable,
|
||||
expected: float,
|
||||
description: String,
|
||||
tolerance: float = 0.0001,
|
||||
timeout: float = 5.0
|
||||
) -> void:
|
||||
await wait_until(
|
||||
func(): return absf(expected - getter.call()) < tolerance,
|
||||
"%s to equal ~%s ±%s (current: %s)" % [description, expected, tolerance, getter.call()],
|
||||
timeout)
|
||||
|
||||
## Assert that condition does NOT become true within duration.
|
||||
static func assert_never_true(
|
||||
condition: Callable,
|
||||
description: String,
|
||||
duration: float = 1.0
|
||||
) -> void:
|
||||
var elapsed := 0.0
|
||||
while elapsed < duration:
|
||||
assert(not condition.call(),
|
||||
"Condition unexpectedly became true: %s" % description)
|
||||
await Engine.get_main_loop().process_frame
|
||||
elapsed += Engine.get_main_loop().root.get_process_delta_time()
|
||||
|
||||
## Wait for specified number of frames.
|
||||
static func wait_frames(count: int) -> void:
|
||||
for i in range(count):
|
||||
await Engine.get_main_loop().process_frame
|
||||
|
||||
## Wait for physics to settle.
|
||||
static func wait_for_physics(frames: int = 3) -> void:
|
||||
for i in range(frames):
|
||||
await Engine.get_main_loop().root.get_tree().physics_frame
|
||||
```
|
||||
|
||||
### Example E2E Test (GDScript)
|
||||
|
||||
```gdscript
|
||||
# tests/e2e/scenarios/test_combat_flow.gd
|
||||
extends GameE2ETestFixture
|
||||
|
||||
func test_player_can_attack_enemy():
|
||||
# GIVEN: Player and enemy in combat range
|
||||
await scenario \
|
||||
.with_unit(Faction.PLAYER, Vector2(100, 100)) \
|
||||
.with_unit(Faction.ENEMY, Vector2(150, 100)) \
|
||||
.build()
|
||||
|
||||
var enemy = game_state.get_units(Faction.ENEMY)[0]
|
||||
var initial_health = enemy.health
|
||||
|
||||
# WHEN: Player attacks
|
||||
await input_sim.click_world_position(Vector2(100, 100)) # Select player
|
||||
await AsyncAssert.wait_until(
|
||||
func(): return game_state.selected_unit != null,
|
||||
"Unit should be selected")
|
||||
|
||||
await input_sim.click_world_position(Vector2(150, 100)) # Attack enemy
|
||||
|
||||
# THEN: Enemy takes damage
|
||||
await AsyncAssert.wait_until(
|
||||
func(): return enemy.health < initial_health,
|
||||
"Enemy should take damage")
|
||||
|
||||
func test_turn_cycle_completes():
|
||||
# GIVEN: Game in progress
|
||||
await scenario.on_turn(1).build()
|
||||
var starting_turn = game_state.turn_number
|
||||
|
||||
# WHEN: Player ends turn
|
||||
await input_sim.click_button("EndTurnButton")
|
||||
await AsyncAssert.wait_until(
|
||||
func(): return game_state.current_faction == Faction.ENEMY,
|
||||
"Should switch to enemy turn")
|
||||
|
||||
# AND: Enemy turn completes
|
||||
await AsyncAssert.wait_until(
|
||||
func(): return game_state.current_faction == Faction.PLAYER,
|
||||
"Should return to player turn",
|
||||
30.0) # AI might take a while
|
||||
|
||||
# THEN: Turn number incremented
|
||||
assert_eq(game_state.turn_number, starting_turn + 1)
|
||||
```
|
||||
|
||||
### Quick E2E Checklist for Godot
|
||||
|
||||
- [ ] Create `GameE2ETestFixture` base class extending GutTest
|
||||
- [ ] Implement `ScenarioBuilder` for your game's domain
|
||||
- [ ] Create `InputSimulator` wrapping Godot Input
|
||||
- [ ] Add `AsyncAssert` utilities with proper await
|
||||
- [ ] Organize E2E tests under `tests/e2e/scenarios/`
|
||||
- [ ] Configure GUT to include E2E test directory
|
||||
- [ ] Set up CI with headless Godot execution
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue