From 16190f7a0495017fc9927748a385421e0b9f943d Mon Sep 17 00:00:00 2001 From: Scott Jennings Date: Tue, 13 Jan 2026 18:18:08 -0600 Subject: [PATCH] feat(bmgd): Add E2E testing methodology and scaffold workflow - Add comprehensive e2e-testing.md knowledge fragment - Add e2e-scaffold workflow for infrastructure generation - Update qa-index.csv with e2e-testing fragment reference - Update game-qa.agent.yaml with ES trigger - Update test-design and automate instructions with E2E guidance - Update unity-testing.md with E2E section reference --- src/modules/bmgd/agents/game-qa.agent.yaml | 6 + .../bmgd/gametest/knowledge/e2e-testing.md | 957 ++++++++++++++ .../bmgd/gametest/knowledge/unity-testing.md | 14 + src/modules/bmgd/gametest/qa-index.csv | 3 +- .../gametest/automate/instructions.md | 81 ++ .../gametest/e2e-scaffold/checklist.md | 94 ++ .../gametest/e2e-scaffold/instructions.md | 1095 +++++++++++++++++ .../gametest/e2e-scaffold/workflow.yaml | 101 ++ .../gametest/test-design/instructions.md | 57 +- 9 files changed, 2401 insertions(+), 7 deletions(-) create mode 100644 src/modules/bmgd/gametest/knowledge/e2e-testing.md create mode 100644 src/modules/bmgd/workflows/gametest/e2e-scaffold/checklist.md create mode 100644 src/modules/bmgd/workflows/gametest/e2e-scaffold/instructions.md create mode 100644 src/modules/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml diff --git a/src/modules/bmgd/agents/game-qa.agent.yaml b/src/modules/bmgd/agents/game-qa.agent.yaml index a1eddbc6..973d521c 100644 --- a/src/modules/bmgd/agents/game-qa.agent.yaml +++ b/src/modules/bmgd/agents/game-qa.agent.yaml @@ -22,6 +22,8 @@ agent: critical_actions: - "Consult {project-root}/_bmad/bmgd/gametest/qa-index.csv to select knowledge fragments under knowledge/ and load only the files needed for the current task" + - "For E2E testing requests, always load knowledge/e2e-testing.md first" + - "When scaffolding tests, distinguish between unit, integration, and E2E test needs" - "Load the referenced fragment(s) from {project-root}/_bmad/bmgd/gametest/knowledge/ before giving recommendations" - "Cross-check recommendations with the current official Unity Test Framework, Unreal Automation, or Godot GUT documentation" - "Find if this exists, if it does, always treat it as the bible I plan and execute against: `**/project-context.md`" @@ -43,6 +45,10 @@ agent: workflow: "{project-root}/_bmad/bmgd/workflows/gametest/automate/workflow.yaml" description: "[TA] Generate automated game tests" + - trigger: ES or fuzzy match on e2e-scaffold + workflow: "{project-root}/_bmad/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml" + description: "[ES] Scaffold E2E testing infrastructure" + - trigger: PP or fuzzy match on playtest-plan workflow: "{project-root}/_bmad/bmgd/workflows/gametest/playtest-plan/workflow.yaml" description: "[PP] Create structured playtesting plan" diff --git a/src/modules/bmgd/gametest/knowledge/e2e-testing.md b/src/modules/bmgd/gametest/knowledge/e2e-testing.md new file mode 100644 index 00000000..4ed33d2f --- /dev/null +++ b/src/modules/bmgd/gametest/knowledge/e2e-testing.md @@ -0,0 +1,957 @@ +# End-to-End Testing for Games + +## Overview + +E2E tests validate complete gameplay flows from the player's perspective — the full orchestra, not individual instruments. Unlike integration tests that verify system interactions, E2E tests verify *player journeys* work correctly from start to finish. + +This is the difference between "does the damage calculator work with the inventory system?" (integration) and "can a player actually complete a combat encounter from selection to resolution?" (E2E). + +## E2E vs Integration vs Unit + +| Aspect | Unit | Integration | E2E | +|--------|------|-------------|-----| +| Scope | Single class | System interaction | Complete flow | +| Speed | < 10ms | < 1s | 1-30s | +| Stability | Very stable | Stable | Requires care | +| Example | DamageCalc math | Combat + Inventory | Full combat encounter | +| Dependencies | None/mocked | Some real | All real | +| Catches | Logic bugs | Wiring bugs | Journey bugs | + +## The E2E Testing Pyramid Addition + +``` + /\ + / \ Manual Playtesting + /----\ (Fun, Feel, Experience) + / \ + /--------\ E2E Tests + / \ (Player Journeys) + /------------\ + / \ Integration Tests + /----------------\ (System Interactions) + / \ Unit Tests + /____________________\ (Pure Logic) +``` + +E2E tests sit between integration tests and manual playtesting. They automate what *can* be automated about player experience while acknowledging that "is this fun?" still requires human judgment. + +## E2E Infrastructure Requirements + +Before writing E2E tests, scaffold supporting infrastructure. Without this foundation, E2E tests become brittle, flaky nightmares that erode trust faster than they build confidence. + +### 1. Test Fixture Base Class + +Provides scene loading, cleanup, and common utilities. Every E2E test inherits from this. + +**Unity Example:** + +```csharp +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; + +public abstract class GameE2ETestFixture +{ + protected virtual string SceneName => "GameScene"; + protected GameStateManager GameState { get; private set; } + protected InputSimulator Input { get; private set; } + protected ScenarioBuilder Scenario { get; private set; } + + [UnitySetUp] + public IEnumerator BaseSetUp() + { + // Load the game scene + yield return SceneManager.LoadSceneAsync(SceneName); + yield return null; // Wait one frame for scene initialization + + // Get core references + GameState = Object.FindFirstObjectByType(); + Assert.IsNotNull(GameState, $"GameStateManager not found in {SceneName}"); + + // Initialize test utilities + Input = new InputSimulator(); + Scenario = new ScenarioBuilder(GameState); + + // Wait for game to be ready + yield return WaitForGameReady(); + } + + [UnityTearDown] + public IEnumerator BaseTearDown() + { + // Clean up any test-spawned objects + yield return CleanupTestObjects(); + + // Reset input state + Input?.Reset(); + } + + protected IEnumerator WaitForGameReady(float timeout = 10f) + { + yield return AsyncAssert.WaitUntil( + () => GameState != null && GameState.IsReady, + "Game ready state", + timeout); + } + + protected virtual IEnumerator CleanupTestObjects() + { + // Override in derived classes for game-specific cleanup + yield return null; + } +} +``` + +**Unreal Example:** + +```cpp +// GameE2ETestBase.h +UCLASS() +class AGameE2ETestBase : public AFunctionalTest +{ + GENERATED_BODY() + +protected: + UPROPERTY() + UGameStateManager* GameState; + + UPROPERTY() + UInputSimulator* InputSim; + + UPROPERTY() + UScenarioBuilder* Scenario; + + virtual void PrepareTest() override; + virtual void StartTest() override; + virtual void CleanUp() override; + + void WaitForGameReady(float Timeout = 10.f); +}; +``` + +**Godot Example:** + +```gdscript +extends GutTest +class_name GameE2ETestFixture + +var game_state: GameStateManager +var input_sim: InputSimulator +var scenario: ScenarioBuilder +var _scene_instance: Node + +func before_each(): + # Load game scene + var scene = load("res://scenes/game.tscn") + _scene_instance = scene.instantiate() + add_child(_scene_instance) + + # Get references + game_state = _scene_instance.get_node("GameStateManager") + 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() + 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") +``` + +### 2. Scenario Builder (Fluent API) + +Configure game state for test scenarios without manual setup. This is the secret sauce — it lets you express test preconditions in domain language. + +**Unity Example:** + +```csharp +public class ScenarioBuilder +{ + private readonly GameStateManager _gameState; + private readonly List> _setupActions = new(); + + public ScenarioBuilder(GameStateManager gameState) + { + _gameState = gameState; + } + + // Domain-specific setup methods + public ScenarioBuilder WithUnit(Faction faction, Hex position, int movementPoints = 6) + { + _setupActions.Add(() => SpawnUnit(faction, position, movementPoints)); + return this; + } + + public ScenarioBuilder WithTerrain(Hex position, TerrainType terrain) + { + _setupActions.Add(() => SetTerrain(position, terrain)); + return this; + } + + public ScenarioBuilder OnTurn(int turnNumber) + { + _setupActions.Add(() => SetTurn(turnNumber)); + return this; + } + + public ScenarioBuilder OnPhase(TurnPhase phase) + { + _setupActions.Add(() => SetPhase(phase)); + return this; + } + + public ScenarioBuilder WithActiveFaction(Faction faction) + { + _setupActions.Add(() => SetActiveFaction(faction)); + return this; + } + + public ScenarioBuilder FromSaveFile(string saveFileName) + { + _setupActions.Add(() => LoadSaveFile(saveFileName)); + return this; + } + + // Execute all setup actions + public IEnumerator Build() + { + foreach (var action in _setupActions) + { + yield return action(); + yield return null; // Allow state to propagate + } + _setupActions.Clear(); + } + + // Private implementation methods + private IEnumerator SpawnUnit(Faction faction, Hex position, int mp) + { + var unit = _gameState.SpawnUnit(faction, position); + unit.MovementPoints = mp; + yield return null; + } + + private IEnumerator SetTerrain(Hex position, TerrainType terrain) + { + _gameState.Map.SetTerrain(position, terrain); + yield return null; + } + + private IEnumerator SetTurn(int turn) + { + _gameState.SetTurnNumber(turn); + yield return null; + } + + private IEnumerator SetPhase(TurnPhase phase) + { + _gameState.SetPhase(phase); + yield return null; + } + + private IEnumerator SetActiveFaction(Faction faction) + { + _gameState.SetActiveFaction(faction); + yield return null; + } + + private IEnumerator LoadSaveFile(string fileName) + { + var path = $"TestData/{fileName}"; + yield return _gameState.LoadGame(path); + } +} +``` + +**Usage:** + +```csharp +yield return Scenario + .WithUnit(Faction.Player, new Hex(3, 4), movementPoints: 6) + .WithUnit(Faction.Enemy, new Hex(5, 4)) + .WithTerrain(new Hex(4, 4), TerrainType.Forest) + .OnTurn(1) + .WithActiveFaction(Faction.Player) + .Build(); +``` + +### 3. Input Simulator + +Abstract player input for deterministic testing. Don't simulate raw mouse positions — simulate player *intent*. + +**Unity Example (New Input System):** + +```csharp +using UnityEngine; +using UnityEngine.InputSystem; + +public class InputSimulator +{ + private Mouse _mouse; + private Keyboard _keyboard; + private Camera _camera; + + public InputSimulator() + { + _mouse = Mouse.current ?? InputSystem.AddDevice(); + _keyboard = Keyboard.current ?? InputSystem.AddDevice(); + _camera = Camera.main; + } + + public IEnumerator ClickWorldPosition(Vector3 worldPos) + { + var screenPos = _camera.WorldToScreenPoint(worldPos); + yield return ClickScreenPosition(screenPos); + } + + public IEnumerator ClickHex(Hex hex) + { + var worldPos = HexUtils.HexToWorld(hex); + yield return ClickWorldPosition(worldPos); + } + + public IEnumerator ClickScreenPosition(Vector2 screenPos) + { + // Move mouse + InputSystem.QueueStateEvent(_mouse, new MouseState { position = screenPos }); + yield return null; + + // Press + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = screenPos, + buttons = 1 + }); + yield return null; + + // Release + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = screenPos, + buttons = 0 + }); + yield return null; + } + + public IEnumerator ClickButton(string buttonName) + { + var button = GameObject.Find(buttonName)?.GetComponent(); + Assert.IsNotNull(button, $"Button '{buttonName}' not found"); + + button.onClick.Invoke(); + yield return null; + } + + public IEnumerator DragFromTo(Vector3 from, Vector3 to, float duration = 0.5f) + { + var fromScreen = _camera.WorldToScreenPoint(from); + var toScreen = _camera.WorldToScreenPoint(to); + + // Start drag + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = fromScreen, + buttons = 1 + }); + yield return null; + + // Interpolate drag + var elapsed = 0f; + while (elapsed < duration) + { + var t = elapsed / duration; + var pos = Vector2.Lerp(fromScreen, toScreen, t); + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = pos, + buttons = 1 + }); + yield return null; + elapsed += Time.deltaTime; + } + + // End drag + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = toScreen, + buttons = 0 + }); + yield return null; + } + + public IEnumerator PressKey(Key key) + { + _keyboard.SetKeyDown(key); + yield return null; + _keyboard.SetKeyUp(key); + yield return null; + } + + public void Reset() + { + // Reset any held state + if (_mouse != null) + { + InputSystem.QueueStateEvent(_mouse, new MouseState()); + } + } +} +``` + +### 4. Async Assertions + +Wait-for-condition assertions with meaningful failure messages. The timeout and message are critical — when tests fail, you need to know *what* it was waiting for. + +**Unity Example:** + +```csharp +using System; +using System.Collections; +using NUnit.Framework; +using UnityEngine; + +public static class AsyncAssert +{ + /// + /// Wait until condition is true, or fail with message after timeout. + /// + public static IEnumerator WaitUntil( + Func condition, + string description, + float timeout = 5f) + { + var elapsed = 0f; + while (!condition() && elapsed < timeout) + { + yield return null; + elapsed += Time.deltaTime; + } + + Assert.IsTrue(condition(), + $"Timeout after {timeout}s waiting for: {description}"); + } + + /// + /// Wait until condition is true, with periodic logging. + /// + public static IEnumerator WaitUntilVerbose( + Func condition, + string description, + float timeout = 5f, + float logInterval = 1f) + { + var elapsed = 0f; + var lastLog = 0f; + + while (!condition() && elapsed < timeout) + { + if (elapsed - lastLog >= logInterval) + { + Debug.Log($"[E2E] Still waiting for: {description} ({elapsed:F1}s)"); + lastLog = elapsed; + } + yield return null; + elapsed += Time.deltaTime; + } + + Assert.IsTrue(condition(), + $"Timeout after {timeout}s waiting for: {description}"); + } + + /// + /// Wait for a specific value, with descriptive failure. + /// + public static IEnumerator WaitForValue( + Func getter, + T expected, + string description, + float timeout = 5f) where T : IEquatable + { + yield return WaitUntil( + () => expected.Equals(getter()), + $"{description} to equal {expected} (current: {getter()})", + timeout); + } + + /// + /// Wait for an event to fire. + /// + public static IEnumerator WaitForEvent( + Action> subscribe, + Action> unsubscribe, + string eventName, + float timeout = 5f) where T : class + { + T received = null; + Action handler = e => received = e; + + subscribe(handler); + + yield return WaitUntil( + () => received != null, + $"Event '{eventName}' to fire", + timeout); + + unsubscribe(handler); + } + + /// + /// Assert that something does NOT happen within a time window. + /// + public static IEnumerator WaitAndAssertNot( + Func condition, + string description, + float duration = 1f) + { + var elapsed = 0f; + while (elapsed < duration) + { + Assert.IsFalse(condition(), + $"Condition unexpectedly became true: {description}"); + yield return null; + elapsed += Time.deltaTime; + } + } +} +``` + +## E2E Test Patterns + +### Given-When-Then with Async + +The core pattern for E2E tests. Clear structure, readable intent. + +```csharp +[UnityTest] +public IEnumerator PlayerCanMoveUnitThroughZOC() +{ + // GIVEN: Soviet unit adjacent to German ZOC + yield return Scenario + .WithUnit(Faction.Soviet, new Hex(3, 4), movementPoints: 6) + .WithUnit(Faction.German, new Hex(4, 4)) // Creates ZOC at adjacent hexes + .WithActiveFaction(Faction.Soviet) + .Build(); + + // WHEN: Player selects unit and moves through ZOC + yield return Input.ClickHex(new Hex(3, 4)); // Select unit + yield return AsyncAssert.WaitUntil( + () => GameState.Selection.HasSelectedUnit, + "Unit should be selected"); + + yield return Input.ClickHex(new Hex(5, 4)); // Click destination (through ZOC) + + // THEN: Unit arrives with reduced movement points (ZOC cost) + yield return AsyncAssert.WaitUntil( + () => GetUnitAt(new Hex(5, 4)) != null, + "Unit should arrive at destination"); + + var unit = GetUnitAt(new Hex(5, 4)); + Assert.Less(unit.MovementPoints, 3, + "ZOC passage should cost extra movement points"); +} +``` + +### Full Turn Cycle + +Testing the complete turn lifecycle. + +```csharp +[UnityTest] +public IEnumerator FullTurnCycle_PlayerToAIAndBack() +{ + // GIVEN: Mid-game state with both factions having units + yield return Scenario + .FromSaveFile("mid_game_scenario.json") + .Build(); + + var startingTurn = GameState.TurnNumber; + + // WHEN: Player ends their turn + yield return Input.ClickButton("EndPhaseButton"); + yield return AsyncAssert.WaitUntil( + () => GameState.CurrentPhase == TurnPhase.EndPhaseConfirmation, + "End phase confirmation"); + + yield return Input.ClickButton("ConfirmButton"); + + // THEN: AI executes its turn + yield return AsyncAssert.WaitUntil( + () => GameState.CurrentFaction == Faction.AI, + "AI turn should begin"); + + // AND: Eventually returns to player + yield return AsyncAssert.WaitUntil( + () => GameState.CurrentFaction == Faction.Player, + "Player turn should return", + timeout: 30f); // AI might take a while + + Assert.AreEqual(startingTurn + 1, GameState.TurnNumber, + "Turn number should increment"); +} +``` + +### Save/Load Round-Trip + +Critical for any game with persistence. + +```csharp +[UnityTest] +public IEnumerator SaveLoad_PreservesGameState() +{ + // GIVEN: Game in specific state + yield return Scenario + .WithUnit(Faction.Player, new Hex(5, 5), movementPoints: 3) + .OnTurn(7) + .Build(); + + var unitPosition = new Hex(5, 5); + var originalMP = GetUnitAt(unitPosition).MovementPoints; + var originalTurn = GameState.TurnNumber; + + // WHEN: Save and reload + var savePath = "test_save_roundtrip"; + yield return GameState.SaveGame(savePath); + + // Trash the current state + yield return SceneManager.LoadSceneAsync(SceneName); + yield return WaitForGameReady(); + + // Load the save + yield return GameState.LoadGame(savePath); + yield return WaitForGameReady(); + + // THEN: State is preserved + Assert.AreEqual(originalTurn, GameState.TurnNumber, + "Turn number should be preserved"); + + var loadedUnit = GetUnitAt(unitPosition); + Assert.IsNotNull(loadedUnit, "Unit should exist at saved position"); + Assert.AreEqual(originalMP, loadedUnit.MovementPoints, + "Movement points should be preserved"); + + // Cleanup + System.IO.File.Delete(GameState.GetSavePath(savePath)); +} +``` + +### UI Flow Testing + +Testing complete UI journeys. + +```csharp +[UnityTest] +public IEnumerator MainMenu_NewGame_ReachesGameplay() +{ + // GIVEN: At main menu + yield return SceneManager.LoadSceneAsync("MainMenu"); + yield return null; + + // WHEN: Start new game flow + yield return Input.ClickButton("NewGameButton"); + yield return AsyncAssert.WaitUntil( + () => FindPanel("DifficultySelect") != null, + "Difficulty selection should appear"); + + yield return Input.ClickButton("NormalDifficultyButton"); + yield return Input.ClickButton("StartButton"); + + // THEN: Game scene loads and is playable + yield return AsyncAssert.WaitUntil( + () => SceneManager.GetActiveScene().name == "GameScene", + "Game scene should load", + timeout: 10f); + + yield return WaitForGameReady(); + + Assert.AreEqual(TurnPhase.PlayerMovement, GameState.CurrentPhase, + "Should start in player movement phase"); +} +``` + +## What to E2E Test + +### High Priority (Test These) + +| Category | Why | Examples | +|----------|-----|----------| +| Core gameplay loop | 90% of player time | Select → Move → Attack → End Turn | +| Turn/phase transitions | State machine boundaries | Phase changes, turn handoff | +| Save → Load → Resume | Data integrity | Full round-trip with verification | +| Win/lose conditions | Critical path endpoints | Victory triggers, game over | +| Critical UI flows | First impressions | Menu → Game → Pause → Resume | + +### Medium Priority (Test Key Paths) + +| Category | Why | Examples | +|----------|-----|----------| +| Undo/redo | Easy to break | Action reversal | +| Multiplayer sync | Complex state | Turn handoff in MP | +| Tutorial flow | First-time experience | Guided sequence | + +### Low Priority (Usually Skip for E2E) + +| Category | Why | Better Tested By | +|----------|-----|------------------| +| Edge cases | Combinatorial explosion | Unit tests | +| Visual correctness | Subjective, changes often | Manual testing | +| Performance | Needs dedicated tooling | Performance tests | +| Every permutation | Infinite combinations | Unit + integration | +| AI decision quality | Subjective | Playtesting | + +## E2E Test Organization + +``` +Tests/ +├── EditMode/ +│ └── ... (existing unit tests) +├── PlayMode/ +│ ├── Integration/ +│ │ └── ... (existing integration tests) +│ └── E2E/ +│ ├── E2E.asmdef +│ ├── Infrastructure/ +│ │ ├── GameE2ETestFixture.cs +│ │ ├── ScenarioBuilder.cs +│ │ ├── InputSimulator.cs +│ │ └── AsyncAssert.cs +│ ├── Scenarios/ +│ │ ├── TurnCycleE2ETests.cs +│ │ ├── MovementE2ETests.cs +│ │ ├── CombatE2ETests.cs +│ │ ├── SaveLoadE2ETests.cs +│ │ └── UIFlowE2ETests.cs +│ └── TestData/ +│ ├── mid_game_scenario.json +│ ├── endgame_scenario.json +│ └── edge_case_setup.json +``` + +### Assembly Definition for E2E + +```json +{ + "name": "E2E", + "references": [ + "GameAssembly", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [], + "excludePlatforms": [], + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "autoReferenced": false +} +``` + +## CI Considerations + +E2E tests are slower and potentially flaky. Handle with care. + +### Separate CI Job + +```yaml +# GitHub Actions example +e2e-tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: game-ci/unity-test-runner@v4 + with: + testMode: PlayMode + projectPath: . + customParameters: -testCategory E2E +``` + +### Retry Strategy + +```yaml +# Retry flaky tests once before failing +- uses: nick-fields/retry@v2 + with: + timeout_minutes: 15 + max_attempts: 2 + command: | + unity-test-runner --category E2E +``` + +### Failure Artifacts + +Capture screenshots and logs on failure: + +```csharp +[UnityTearDown] +public IEnumerator CaptureOnFailure() +{ + if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed) + { + var screenshot = ScreenCapture.CaptureScreenshotAsTexture(); + var bytes = screenshot.EncodeToPNG(); + var path = $"TestResults/Screenshots/{TestContext.CurrentContext.Test.Name}.png"; + System.IO.File.WriteAllBytes(path, bytes); + + Debug.Log($"[E2E FAILURE] Screenshot saved: {path}"); + } + yield return null; +} +``` + +### Execution Frequency + +| Suite | When | Timeout | +|-------|------|---------| +| Unit tests | Every commit | 5 min | +| Integration | Every commit | 10 min | +| E2E (smoke) | Every commit | 15 min | +| E2E (full) | Nightly | 60 min | + +## Avoiding Flaky Tests + +E2E tests are notorious for flakiness. Fight it proactively. + +### DO + +- Use explicit waits with `AsyncAssert.WaitUntil` +- Wait for *game state*, not time +- Clean up thoroughly in TearDown +- Isolate tests completely +- Use deterministic scenarios +- Seed random number generators + +### DON'T + +- Use `yield return new WaitForSeconds(x)` as primary sync +- Depend on test execution order +- Share state between tests +- Rely on animation timing +- Assume frame-perfect timing +- Skip cleanup "because it's slow" + +### Debugging Flaky Tests + +```csharp +// Add verbose logging to track down race conditions +[UnityTest] +public IEnumerator FlakyTest_WithDebugging() +{ + Debug.Log($"[E2E] Test start: {Time.frameCount}"); + + yield return Scenario.Build(); + Debug.Log($"[E2E] Scenario built: {Time.frameCount}"); + + yield return Input.ClickHex(targetHex); + Debug.Log($"[E2E] Input sent: {Time.frameCount}, Selection: {GameState.Selection}"); + + yield return AsyncAssert.WaitUntilVerbose( + () => ExpectedCondition(), + "Expected condition", + timeout: 10f, + logInterval: 0.5f); +} +``` + +## Engine-Specific Notes + +### Unity + +- Use `[UnityTest]` attribute for coroutine-based tests +- Prefer `WaitUntil` over `WaitForSeconds` +- Use `Object.FindFirstObjectByType()` (not the deprecated `FindObjectOfType`) +- Consider `InputTestFixture` for Input System simulation +- Remember: `yield return null` waits one frame + +### Unreal + +- Use `FFunctionalTest` actors for level-based E2E +- `LatentIt` for async test steps in Automation Framework +- Gauntlet for extended E2E suites running in isolated processes +- `ADD_LATENT_AUTOMATION_COMMAND` for sequenced operations + +### Godot + +- Use `await` for async operations in GUT +- `await get_tree().create_timer(x).timeout` for timed waits +- Scene instancing provides natural test isolation +- Use `queue_free()` for cleanup, not `free()` (avoids errors) + +## Anti-Patterns + +### The "Test Everything" Trap + +Don't try to E2E test every edge case. That's what unit tests are for. + +```csharp +// BAD: Testing edge case via E2E +[UnityTest] +public IEnumerator Movement_WithExactlyZeroMP_CannotMove() // Unit test this +{ + // 30 seconds of setup for a condition unit tests cover +} + +// GOOD: E2E tests the journey, unit tests the edge cases +[UnityTest] +public IEnumerator Movement_TypicalPlayerJourney_WorksCorrectly() +{ + // Tests the common path players actually experience +} +``` + +### The "Magic Sleep" Pattern + +```csharp +// BAD: Hoping 2 seconds is enough +yield return new WaitForSeconds(2f); +Assert.IsTrue(condition); + +// GOOD: Wait for the actual condition +yield return AsyncAssert.WaitUntil(() => condition, "description"); +``` + +### The "Shared State" Trap + +```csharp +// BAD: Tests pollute each other +private static int testCounter = 0; // Shared between tests! + +// GOOD: Each test is isolated +[SetUp] +public void Setup() +{ + // Fresh state every test +} +``` + +## Measuring E2E Test Value + +### Coverage Metrics That Matter + +- **Journey coverage**: How many critical player paths are tested? +- **Failure detection rate**: How many real bugs do E2E tests catch? +- **False positive rate**: How often do E2E tests fail spuriously? + +### Warning Signs + +- E2E suite takes > 30 minutes +- Flaky test rate > 5% +- E2E tests duplicate unit test coverage +- Team skips E2E tests because they're "always broken" + +### Health Indicators + +- E2E tests catch integration bugs unit tests miss +- New features include E2E tests for happy path +- Flaky tests are fixed or removed within a sprint +- E2E suite runs on every PR (at least smoke subset) diff --git a/src/modules/bmgd/gametest/knowledge/unity-testing.md b/src/modules/bmgd/gametest/knowledge/unity-testing.md index f1b872d9..f057933c 100644 --- a/src/modules/bmgd/gametest/knowledge/unity-testing.md +++ b/src/modules/bmgd/gametest/knowledge/unity-testing.md @@ -381,3 +381,17 @@ test: | NullReferenceException | Missing Setup | Ensure [SetUp] initializes all fields | | Tests hang | Infinite coroutine | Add timeout or max iterations | | Flaky physics tests | Timing dependent | Use WaitForFixedUpdate, increase tolerance | + +## End-to-End Testing + +For comprehensive E2E testing patterns, infrastructure scaffolding, and +scenario builders, see **knowledge/e2e-testing.md**. + +### Quick E2E Checklist for Unity + +- [ ] Create `GameE2ETestFixture` base class +- [ ] Implement `ScenarioBuilder` for your game's domain +- [ ] Create `InputSimulator` wrapping Input System +- [ ] Add `AsyncAssert` utilities +- [ ] Organize E2E tests under `Tests/PlayMode/E2E/` +- [ ] Configure separate CI job for E2E suite diff --git a/src/modules/bmgd/gametest/qa-index.csv b/src/modules/bmgd/gametest/qa-index.csv index af026afd..05b3ba79 100644 --- a/src/modules/bmgd/gametest/qa-index.csv +++ b/src/modules/bmgd/gametest/qa-index.csv @@ -14,4 +14,5 @@ input-testing,Input Testing,"Controller, keyboard, and touch input validation"," localization-testing,Localization Testing,"Text, audio, and cultural validation for international releases","localization,i18n,text",knowledge/localization-testing.md certification-testing,Platform Certification,"Console TRC/XR requirements and certification testing","certification,console,trc,xr",knowledge/certification-testing.md smoke-testing,Smoke Testing,"Critical path validation for build verification","smoke-tests,bvt,ci",knowledge/smoke-testing.md -test-priorities,Test Priorities Matrix,"P0-P3 criteria, coverage targets, execution ordering for games","prioritization,risk,coverage",knowledge/test-priorities.md \ No newline at end of file +test-priorities,Test Priorities Matrix,"P0-P3 criteria, coverage targets, execution ordering for games","prioritization,risk,coverage",knowledge/test-priorities.md +e2e-testing,End-to-End Testing,"Complete player journey testing with infrastructure patterns and async utilities","e2e,integration,player-journeys,scenarios,infrastructure",knowledge/e2e-testing.md diff --git a/src/modules/bmgd/workflows/gametest/automate/instructions.md b/src/modules/bmgd/workflows/gametest/automate/instructions.md index 2af2e4fe..8fb1615c 100644 --- a/src/modules/bmgd/workflows/gametest/automate/instructions.md +++ b/src/modules/bmgd/workflows/gametest/automate/instructions.md @@ -209,6 +209,87 @@ func test_{feature}_integration(): # Cleanup scene.queue_free() ``` +### E2E Journey Tests + +**Knowledge Base Reference**: `knowledge/e2e-testing.md` +```csharp +public class {Feature}E2ETests : GameE2ETestFixture +{ + [UnityTest] + public IEnumerator {JourneyName}_Succeeds() + { + // GIVEN + yield return Scenario + .{SetupMethod1}() + .{SetupMethod2}() + .Build(); + + // WHEN + yield return Input.{Action1}(); + yield return AsyncAssert.WaitUntil( + () => {Condition1}, "{Description1}"); + yield return Input.{Action2}(); + + // THEN + yield return AsyncAssert.WaitUntil( + () => {FinalCondition}, "{FinalDescription}"); + Assert.{Assertion}({expected}, {actual}); + } +} +``` + + +## Step 3.5: Generate E2E Infrastructure + +Before generating E2E tests, scaffold the required infrastructure. + +### Infrastructure Checklist + +1. **Test Fixture Base Class** + - Scene loading/unloading + - Game ready state waiting + - Common service access + - Cleanup guarantees + +2. **Scenario Builder** + - Fluent API for game state configuration + - Domain-specific methods (e.g., `WithUnit`, `OnTurn`) + - Yields for state propagation + +3. **Input Simulator** + - Click/drag abstractions + - Button press simulation + - Keyboard input queuing + +4. **Async Assertions** + - `WaitUntil` with timeout and message + - `WaitForEvent` for event-driven flows + - `WaitForState` for state machine transitions + +### Generation Template +```csharp +// GameE2ETestFixture.cs +public abstract class GameE2ETestFixture +{ + protected {GameStateClass} GameState; + protected {InputSimulatorClass} Input; + protected {ScenarioBuilderClass} Scenario; + + [UnitySetUp] + public IEnumerator BaseSetUp() + { + yield return LoadScene("{main_scene}"); + GameState = Object.FindFirstObjectByType<{GameStateClass}>(); + Input = new {InputSimulatorClass}(); + Scenario = new {ScenarioBuilderClass}(GameState); + yield return WaitForReady(); + } + + // ... (fill from e2e-testing.md patterns) +} +``` + +**After scaffolding infrastructure, proceed to generate actual E2E tests.** --- diff --git a/src/modules/bmgd/workflows/gametest/e2e-scaffold/checklist.md b/src/modules/bmgd/workflows/gametest/e2e-scaffold/checklist.md new file mode 100644 index 00000000..7f4a4b1f --- /dev/null +++ b/src/modules/bmgd/workflows/gametest/e2e-scaffold/checklist.md @@ -0,0 +1,94 @@ +# E2E Infrastructure Scaffold Checklist + +## Preflight Validation + +- [ ] Test framework already initialized (`Tests/` directory exists with proper structure) +- [ ] Game state manager class identified +- [ ] Main gameplay scene identified and loads without errors +- [ ] No existing E2E infrastructure conflicts + +## Architecture Analysis + +- [ ] Game engine correctly detected +- [ ] Engine version identified +- [ ] Input system type determined (New Input System, Legacy, Custom) +- [ ] Game state manager class located +- [ ] Ready/initialized state property identified +- [ ] Key domain entities catalogued for ScenarioBuilder + +## Generated Files + +### Directory Structure +- [ ] `Tests/PlayMode/E2E/` directory created +- [ ] `Tests/PlayMode/E2E/Infrastructure/` directory created +- [ ] `Tests/PlayMode/E2E/Scenarios/` directory created +- [ ] `Tests/PlayMode/E2E/TestData/` directory created + +### Infrastructure Files +- [ ] `E2E.asmdef` created with correct assembly references +- [ ] `GameE2ETestFixture.cs` created with correct class references +- [ ] `ScenarioBuilder.cs` created with at least placeholder methods +- [ ] `InputSimulator.cs` created matching detected input system +- [ ] `AsyncAssert.cs` created with core assertion methods + +### Example and Documentation +- [ ] `ExampleE2ETest.cs` created with working infrastructure test +- [ ] `README.md` created with usage documentation + +## Code Quality + +### GameE2ETestFixture +- [ ] Correct namespace applied +- [ ] Correct `GameStateClass` reference +- [ ] Correct `SceneName` default +- [ ] `WaitForGameReady` uses correct ready property +- [ ] `UnitySetUp` and `UnityTearDown` properly structured +- [ ] Virtual methods for derived class customization + +### ScenarioBuilder +- [ ] Fluent API pattern correctly implemented +- [ ] `Build()` executes all queued actions +- [ ] At least one domain-specific method added (or clear TODOs) +- [ ] `FromSaveFile` method scaffolded + +### InputSimulator +- [ ] Matches detected input system (New vs Legacy) +- [ ] Mouse click simulation works +- [ ] Button click by name works +- [ ] Keyboard input scaffolded +- [ ] `Reset()` method cleans up state + +### AsyncAssert +- [ ] `WaitUntil` includes timeout and descriptive failure +- [ ] `WaitForValue` provides current vs expected in failure +- [ ] `AssertNeverTrue` for negative assertions +- [ ] Frame/physics wait utilities included + +## Assembly Definition + +- [ ] References main game assembly +- [ ] References UnityEngine.TestRunner +- [ ] References UnityEditor.TestRunner +- [ ] References Unity.InputSystem (if applicable) +- [ ] `UNITY_INCLUDE_TESTS` define constraint set +- [ ] nunit.framework.dll in precompiled references + +## Verification + +- [ ] Project compiles without errors after scaffold +- [ ] `ExampleE2ETests.Infrastructure_GameLoadsAndReachesReadyState` passes +- [ ] Test appears in Test Runner under PlayMode → E2E category + +## Documentation Quality + +- [ ] README explains all infrastructure components +- [ ] Quick start example is copy-pasteable +- [ ] Extension instructions are clear +- [ ] Troubleshooting table addresses common issues + +## Handoff + +- [ ] Summary output provided with all configuration values +- [ ] Next steps clearly listed +- [ ] Customization requirements highlighted +- [ ] Knowledge fragments referenced diff --git a/src/modules/bmgd/workflows/gametest/e2e-scaffold/instructions.md b/src/modules/bmgd/workflows/gametest/e2e-scaffold/instructions.md new file mode 100644 index 00000000..efab44a7 --- /dev/null +++ b/src/modules/bmgd/workflows/gametest/e2e-scaffold/instructions.md @@ -0,0 +1,1095 @@ + + +# E2E Test Infrastructure Scaffold + +**Workflow ID**: `_bmad/bmgd/gametest/e2e-scaffold` +**Version**: 1.0 (BMad v6) + +--- + +## Overview + +Scaffold complete E2E testing infrastructure for an existing game project. This workflow creates the foundation required for reliable, maintainable end-to-end tests: test fixtures, scenario builders, input simulators, and async assertion utilities — all tailored to the project's specific architecture. + +E2E tests validate complete player journeys. Without proper infrastructure, they become brittle nightmares. This workflow prevents that. + +--- + +## Preflight Requirements + +**Critical:** Verify these requirements before proceeding. If any fail, HALT and guide the user. + +- ✅ Test framework already initialized (run `test-framework` workflow first) +- ✅ Game has identifiable state manager class +- ✅ Main gameplay scene exists and is functional +- ✅ No existing E2E infrastructure (check for `Tests/PlayMode/E2E/`) + +**Knowledge Base:** Load `knowledge/e2e-testing.md` before proceeding. + +--- + +## Step 1: Analyze Game Architecture + +### 1.1 Detect Game Engine + +Identify engine type by checking for: + +- **Unity**: `Assets/`, `ProjectSettings/`, `*.unity` scenes +- **Unreal**: `*.uproject`, `Source/`, `Config/DefaultEngine.ini` +- **Godot**: `project.godot`, `*.tscn`, `*.gd` files + +Load the appropriate engine-specific knowledge fragment: +- Unity: `knowledge/unity-testing.md` +- Unreal: `knowledge/unreal-testing.md` +- Godot: `knowledge/godot-testing.md` + +### 1.2 Identify Core Systems + +Locate and document: + +1. **Game State Manager** + - Primary class that holds game state + - Look for: `GameManager`, `GameStateManager`, `GameController`, `GameMode` + - Note: initialization method, ready state property, save/load methods + +2. **Input Handling** + - Unity: New Input System (`InputSystem` package) vs Legacy (`Input.GetKey`) + - Unreal: Enhanced Input vs Legacy + - Godot: Built-in Input singleton + - Custom input abstraction layer + +3. **Event/Messaging System** + - Event bus pattern + - C# events/delegates + - UnityEvents + - Signals (Godot) + +4. **Scene Structure** + - Main gameplay scene name + - Scene loading approach (additive, single) + - Bootstrap/initialization flow + +### 1.3 Identify Domain Concepts + +For the ScenarioBuilder, identify: + +- **Primary Entities**: Units, players, items, enemies, etc. +- **State Machine States**: Turn phases, game modes, player states +- **Spatial System**: Grid/hex positions, world coordinates, regions +- **Resources**: Currency, health, mana, ammunition, etc. + +### 1.4 Check Existing Test Structure + +``` +Expected structure after test-framework workflow: +Tests/ +├── EditMode/ +│ └── ... (unit tests) +└── PlayMode/ + └── ... (integration tests) +``` + +If `Tests/PlayMode/E2E/` already exists, HALT and ask user how to proceed. + +--- + +## Step 2: Generate Infrastructure + +### 2.1 Create Directory Structure + +``` +Tests/PlayMode/E2E/ +├── E2E.asmdef +├── Infrastructure/ +│ ├── GameE2ETestFixture.cs +│ ├── ScenarioBuilder.cs +│ ├── InputSimulator.cs +│ └── AsyncAssert.cs +├── Scenarios/ +│ └── (empty - user will add tests here) +├── TestData/ +│ └── (empty - user will add fixtures here) +└── README.md +``` + +### 2.2 Generate Assembly Definition + +**Unity: `E2E.asmdef`** + +```json +{ + "name": "E2E", + "rootNamespace": "{ProjectNamespace}.Tests.E2E", + "references": [ + "{GameAssemblyName}", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "Unity.InputSystem", + "Unity.InputSystem.TestFramework" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} +``` + +**Notes:** +- Replace `{ProjectNamespace}` with detected project namespace +- Replace `{GameAssemblyName}` with main game assembly +- Include `Unity.InputSystem` references only if Input System package detected + +### 2.3 Generate GameE2ETestFixture + +This is the base class all E2E tests inherit from. + +**Unity Template:** + +```csharp +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Base fixture for all E2E tests. Handles scene loading, game initialization, + /// and provides access to core test utilities. + /// + public abstract class GameE2ETestFixture + { + /// + /// Override to specify a different scene for specific test classes. + /// + protected virtual string SceneName => "{MainSceneName}"; + + /// + /// Primary game state manager reference. + /// + protected {GameStateClass} GameState { get; private set; } + + /// + /// Input simulation utility. + /// + protected InputSimulator Input { get; private set; } + + /// + /// Scenario configuration builder. + /// + protected ScenarioBuilder Scenario { get; private set; } + + [UnitySetUp] + public IEnumerator BaseSetUp() + { + // Load the game scene + yield return SceneManager.LoadSceneAsync(SceneName); + yield return null; // Wait one frame for Awake/Start + + // Get core references + GameState = Object.FindFirstObjectByType<{GameStateClass}>(); + Assert.IsNotNull(GameState, + $"{nameof({GameStateClass})} not found in scene '{SceneName}'"); + + // Initialize test utilities + Input = new InputSimulator(); + Scenario = new ScenarioBuilder(GameState); + + // Wait for game to reach ready state + yield return WaitForGameReady(); + + // Call derived class setup + yield return SetUp(); + } + + [UnityTearDown] + public IEnumerator BaseTearDown() + { + // Call derived class teardown first + yield return TearDown(); + + // Reset input state + Input?.Reset(); + + // Clear references + GameState = null; + Input = null; + Scenario = null; + } + + /// + /// Override for test-class-specific setup. Called after scene loads and game is ready. + /// + protected virtual IEnumerator SetUp() + { + yield return null; + } + + /// + /// Override for test-class-specific teardown. Called before base cleanup. + /// + protected virtual IEnumerator TearDown() + { + yield return null; + } + + /// + /// Waits until the game reaches a playable state. + /// + protected virtual IEnumerator WaitForGameReady(float timeout = 10f) + { + yield return AsyncAssert.WaitUntil( + () => GameState != null && GameState.{IsReadyProperty}, + "Game to reach ready state", + timeout); + } + + /// + /// Captures screenshot on test failure for debugging. + /// + protected IEnumerator CaptureFailureScreenshot() + { + if (TestContext.CurrentContext.Result.Outcome.Status == + NUnit.Framework.Interfaces.TestStatus.Failed) + { + var texture = ScreenCapture.CaptureScreenshotAsTexture(); + var bytes = texture.EncodeToPNG(); + var testName = TestContext.CurrentContext.Test.Name; + var path = $"TestResults/E2E_Failure_{testName}_{System.DateTime.Now:yyyyMMdd_HHmmss}.png"; + + System.IO.Directory.CreateDirectory("TestResults"); + System.IO.File.WriteAllBytes(path, bytes); + Debug.Log($"[E2E] Failure screenshot saved: {path}"); + + Object.Destroy(texture); + } + yield return null; + } + } +} +``` + +**Customization Points:** +- `{Namespace}`: Project namespace (e.g., `AugustStorm`) +- `{MainSceneName}`: Detected main gameplay scene +- `{GameStateClass}`: Identified game state manager class +- `{IsReadyProperty}`: Property indicating game is initialized (e.g., `IsReady`, `IsInitialized`) + +### 2.4 Generate ScenarioBuilder + +Fluent API for configuring test scenarios. This must be customized to the game's domain. + +**Unity Template:** + +```csharp +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Fluent builder for configuring E2E test scenarios. + /// Add domain-specific methods as needed for your game. + /// + public class ScenarioBuilder + { + private readonly {GameStateClass} _gameState; + private readonly List> _setupActions = new(); + + public ScenarioBuilder({GameStateClass} gameState) + { + _gameState = gameState; + } + + #region State Configuration + + /// + /// Load a pre-configured scenario from a save file. + /// + public ScenarioBuilder FromSaveFile(string fileName) + { + _setupActions.Add(() => LoadSaveFile(fileName)); + return this; + } + + // TODO: Add domain-specific configuration methods + // Examples for a turn-based strategy game: + // + // public ScenarioBuilder WithUnit(Faction faction, Hex position, int mp = 6) + // { + // _setupActions.Add(() => SpawnUnit(faction, position, mp)); + // return this; + // } + // + // public ScenarioBuilder OnTurn(int turnNumber) + // { + // _setupActions.Add(() => SetTurn(turnNumber)); + // return this; + // } + // + // public ScenarioBuilder WithActiveFaction(Faction faction) + // { + // _setupActions.Add(() => SetActiveFaction(faction)); + // return this; + // } + + #endregion + + #region Execution + + /// + /// Execute all configured setup actions. + /// + public IEnumerator Build() + { + foreach (var action in _setupActions) + { + yield return action(); + yield return null; // Allow state to propagate + } + _setupActions.Clear(); + } + + /// + /// Clear pending actions without executing. + /// + public void Reset() + { + _setupActions.Clear(); + } + + #endregion + + #region Private Implementation + + private IEnumerator LoadSaveFile(string fileName) + { + var path = $"TestData/{fileName}"; + // TODO: Implement save loading based on your save system + // yield return _gameState.LoadGame(path); + Debug.Log($"[ScenarioBuilder] Loading scenario from: {path}"); + yield return null; + } + + // TODO: Implement domain-specific setup methods + // private IEnumerator SpawnUnit(Faction faction, Hex position, int mp) + // { + // var unit = _gameState.SpawnUnit(faction, position); + // unit.MovementPoints = mp; + // yield return null; + // } + + #endregion + } +} +``` + +**Note to Agent:** After generating the template, analyze the game's domain model and add 3-5 concrete configuration methods based on identified entities (Step 1.3). + +### 2.5 Generate InputSimulator + +Abstract player input for deterministic testing. + +**Unity Template (New Input System):** + +```csharp +using System.Collections; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.LowLevel; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Simulates player input for E2E tests. + /// + public class InputSimulator + { + private Mouse _mouse; + private Keyboard _keyboard; + private Camera _camera; + + public InputSimulator() + { + _mouse = Mouse.current ?? InputSystem.AddDevice(); + _keyboard = Keyboard.current ?? InputSystem.AddDevice(); + _camera = Camera.main; + } + + #region Mouse Input + + /// + /// Click at a world position. + /// + public IEnumerator ClickWorldPosition(Vector3 worldPos) + { + var screenPos = _camera.WorldToScreenPoint(worldPos); + yield return ClickScreenPosition(new Vector2(screenPos.x, screenPos.y)); + } + + /// + /// Click at a screen position. + /// + public IEnumerator ClickScreenPosition(Vector2 screenPos) + { + // Move mouse to position + InputState.Change(_mouse.position, screenPos); + yield return null; + + // Press + using (StateEvent.From(_mouse, out var eventPtr)) + { + _mouse.CopyState(eventPtr); + _mouse.leftButton.WriteValueIntoEvent(1f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + + // Release + using (StateEvent.From(_mouse, out var eventPtr)) + { + _mouse.CopyState(eventPtr); + _mouse.leftButton.WriteValueIntoEvent(0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + } + + /// + /// Click a UI button by name. + /// + public IEnumerator ClickButton(string buttonName) + { + var button = GameObject.Find(buttonName)? + .GetComponent(); + + if (button == null) + { + // Try searching in inactive objects + var buttons = Resources.FindObjectsOfTypeAll(); + foreach (var b in buttons) + { + if (b.name == buttonName) + { + button = b; + break; + } + } + } + + UnityEngine.Assertions.Assert.IsNotNull(button, + $"Button '{buttonName}' not found"); + + button.onClick.Invoke(); + yield return null; + } + + /// + /// Drag from one world position to another. + /// + public IEnumerator DragFromTo(Vector3 from, Vector3 to, float duration = 0.3f) + { + var fromScreen = (Vector2)_camera.WorldToScreenPoint(from); + var toScreen = (Vector2)_camera.WorldToScreenPoint(to); + + // Move to start + InputState.Change(_mouse.position, fromScreen); + yield return null; + + // Press + using (StateEvent.From(_mouse, out var eventPtr)) + { + _mouse.CopyState(eventPtr); + _mouse.leftButton.WriteValueIntoEvent(1f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + + // Drag + var elapsed = 0f; + while (elapsed < duration) + { + var t = elapsed / duration; + var pos = Vector2.Lerp(fromScreen, toScreen, t); + InputState.Change(_mouse.position, pos); + yield return null; + elapsed += Time.deltaTime; + } + + // Release at destination + InputState.Change(_mouse.position, toScreen); + using (StateEvent.From(_mouse, out var eventPtr)) + { + _mouse.CopyState(eventPtr); + _mouse.leftButton.WriteValueIntoEvent(0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + } + + #endregion + + #region Keyboard Input + + /// + /// Press and release a key. + /// + public IEnumerator PressKey(Key key) + { + var control = _keyboard[key]; + using (StateEvent.From(_keyboard, out var eventPtr)) + { + control.WriteValueIntoEvent(1f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + + using (StateEvent.From(_keyboard, out var eventPtr)) + { + control.WriteValueIntoEvent(0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + } + + /// + /// Hold a key for a duration. + /// + public IEnumerator HoldKey(Key key, float duration) + { + var control = _keyboard[key]; + using (StateEvent.From(_keyboard, out var eventPtr)) + { + control.WriteValueIntoEvent(1f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + + yield return new WaitForSeconds(duration); + + using (StateEvent.From(_keyboard, out var eventPtr)) + { + control.WriteValueIntoEvent(0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + } + + #endregion + + #region Utility + + /// + /// Reset all input state. + /// + public void Reset() + { + if (_mouse != null) + { + InputState.Change(_mouse, new MouseState()); + } + if (_keyboard != null) + { + InputState.Change(_keyboard, new KeyboardState()); + } + } + + /// + /// Update camera reference (call after scene load if needed). + /// + public void RefreshCamera() + { + _camera = Camera.main; + } + + #endregion + } +} +``` + +**Unity Template (Legacy Input):** + +If legacy input system detected, generate a simpler version using `Input.mousePosition` simulation or UI event triggering. + +### 2.6 Generate AsyncAssert + +Wait-for-condition assertions with meaningful failure messages. + +**Unity Template:** + +```csharp +using System; +using System.Collections; +using NUnit.Framework; +using UnityEngine; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Async assertion utilities for E2E tests. + /// + public static class AsyncAssert + { + /// + /// Wait until condition is true, or fail with message after timeout. + /// + /// Condition to wait for + /// Human-readable description of what we're waiting for + /// Maximum seconds to wait + public static IEnumerator WaitUntil( + Func condition, + string description, + float timeout = 5f) + { + var elapsed = 0f; + while (!condition() && elapsed < timeout) + { + yield return null; + elapsed += Time.deltaTime; + } + + Assert.IsTrue(condition(), + $"Timeout after {timeout:F1}s waiting for: {description}"); + } + + /// + /// Wait until condition is true, with periodic debug logging. + /// + public static IEnumerator WaitUntilVerbose( + Func condition, + string description, + float timeout = 5f, + float logInterval = 1f) + { + var elapsed = 0f; + var lastLog = 0f; + + while (!condition() && elapsed < timeout) + { + if (elapsed - lastLog >= logInterval) + { + Debug.Log($"[E2E] Waiting for: {description} ({elapsed:F1}s elapsed)"); + lastLog = elapsed; + } + yield return null; + elapsed += Time.deltaTime; + } + + if (condition()) + { + Debug.Log($"[E2E] Condition met: {description} (after {elapsed:F1}s)"); + } + + Assert.IsTrue(condition(), + $"Timeout after {timeout:F1}s waiting for: {description}"); + } + + /// + /// Wait for a value to equal expected. + /// + public static IEnumerator WaitForValue( + Func getter, + T expected, + string description, + float timeout = 5f) where T : IEquatable + { + yield return WaitUntil( + () => expected.Equals(getter()), + $"{description} to equal '{expected}' (current: '{getter()}')", + timeout); + } + + /// + /// Wait for a value to not equal a specific value. + /// + public static IEnumerator WaitForValueNot( + Func getter, + T notExpected, + string description, + float timeout = 5f) where T : IEquatable + { + yield return WaitUntil( + () => !notExpected.Equals(getter()), + $"{description} to change from '{notExpected}'", + timeout); + } + + /// + /// Wait for a reference to become non-null. + /// + public static IEnumerator WaitForNotNull( + Func getter, + string description, + float timeout = 5f) where T : class + { + yield return WaitUntil( + () => getter() != null, + $"{description} to exist (not null)", + timeout); + } + + /// + /// Wait for a Unity Object to exist (handles Unity's fake null). + /// + public static IEnumerator WaitForUnityObject( + Func getter, + string description, + float timeout = 5f) where T : UnityEngine.Object + { + yield return WaitUntil( + () => getter() != null, // Unity overloads == for destroyed objects + $"{description} to exist", + timeout); + } + + /// + /// Assert that a condition does NOT become true within a time window. + /// Useful for testing that something doesn't happen. + /// + public static IEnumerator AssertNeverTrue( + Func condition, + string description, + float duration = 1f) + { + var elapsed = 0f; + while (elapsed < duration) + { + Assert.IsFalse(condition(), + $"Condition unexpectedly became true: {description}"); + yield return null; + elapsed += Time.deltaTime; + } + } + + /// + /// Wait for a specific number of frames. + /// Use sparingly - prefer WaitUntil with conditions. + /// + public static IEnumerator WaitFrames(int frameCount) + { + for (int i = 0; i < frameCount; i++) + { + yield return null; + } + } + + /// + /// Wait for physics to settle (multiple FixedUpdates). + /// + public static IEnumerator WaitForPhysics(int fixedUpdateCount = 3) + { + for (int i = 0; i < fixedUpdateCount; i++) + { + yield return new WaitForFixedUpdate(); + } + } + } +} +``` + +--- + +## Step 3: Generate Example Test + +Create a working E2E test that exercises the infrastructure and proves it works. + +**Unity Template:** + +```csharp +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Example E2E tests demonstrating infrastructure usage. + /// Delete or modify these once you've verified the setup works. + /// + [Category("E2E")] + public class ExampleE2ETests : GameE2ETestFixture + { + [UnityTest] + public IEnumerator Infrastructure_GameLoadsAndReachesReadyState() + { + // This test verifies the E2E infrastructure is working correctly. + // If this test passes, your infrastructure is properly configured. + + // The base fixture already loaded the scene and waited for ready, + // so if we get here, everything worked. + + Assert.IsNotNull(GameState, "GameState should be available"); + Assert.IsNotNull(Input, "InputSimulator should be available"); + Assert.IsNotNull(Scenario, "ScenarioBuilder should be available"); + + // Verify game is actually ready + yield return AsyncAssert.WaitUntil( + () => GameState.{IsReadyProperty}, + "Game should be in ready state"); + + Debug.Log("[E2E] Infrastructure test passed - E2E framework is working!"); + } + + [UnityTest] + public IEnumerator Infrastructure_InputSimulatorCanClickButtons() + { + // Test that input simulation works + // Modify this to click an actual button in your game + + // Example: Click a button that should exist in your main scene + // yield return Input.ClickButton("SomeButtonName"); + // yield return AsyncAssert.WaitUntil( + // () => /* button click result */, + // "Button click should have effect"); + + Debug.Log("[E2E] Input simulation test - customize with your UI elements"); + yield return null; + } + + [UnityTest] + public IEnumerator Infrastructure_ScenarioBuilderCanConfigureState() + { + // Test that scenario builder works + // Modify this to use your domain-specific setup methods + + // Example: + // yield return Scenario + // .WithUnit(Faction.Player, new Hex(3, 3)) + // .OnTurn(1) + // .Build(); + // + // Assert.AreEqual(1, GameState.TurnNumber); + + Debug.Log("[E2E] Scenario builder test - customize with your domain methods"); + yield return Scenario.Build(); // Execute empty builder (no-op) + } + } +} +``` + +--- + +## Step 4: Generate Documentation + +Create a README explaining how to use the E2E infrastructure. + +**Template: `Tests/PlayMode/E2E/README.md`** + +```markdown +# E2E Testing Infrastructure + +End-to-end tests that validate complete player journeys through the game. + +## Quick Start + +1. Create a new test class inheriting from `GameE2ETestFixture` +2. Use `Scenario` to configure game state +3. Use `Input` to simulate player actions +4. Use `AsyncAssert` to wait for and verify outcomes + +## Example Test + +```csharp +[UnityTest] +public IEnumerator Player_CanCompleteBasicAction() +{ + // GIVEN: Configured scenario + yield return Scenario + .WithSomeSetup() + .Build(); + + // WHEN: Player takes action + yield return Input.ClickButton("ActionButton"); + + // THEN: Expected outcome occurs + yield return AsyncAssert.WaitUntil( + () => GameState.ActionCompleted, + "Action should complete"); +} +``` + +## Infrastructure Components + +### GameE2ETestFixture + +Base class for all E2E tests. Provides: +- Automatic scene loading and cleanup +- Access to `GameState`, `Input`, and `Scenario` +- Override `SetUp()` and `TearDown()` for test-specific setup + +### ScenarioBuilder + +Fluent API for configuring test scenarios. Extend with domain-specific methods: + +```csharp +// In ScenarioBuilder.cs, add methods like: +public ScenarioBuilder WithPlayer(Vector3 position) +{ + _setupActions.Add(() => SpawnPlayer(position)); + return this; +} +``` + +### InputSimulator + +Simulates player input: +- `ClickWorldPosition(Vector3)` - Click in 3D space +- `ClickScreenPosition(Vector2)` - Click at screen coordinates +- `ClickButton(string)` - Click UI button by name +- `DragFromTo(Vector3, Vector3)` - Drag gesture +- `PressKey(Key)` - Keyboard input + +### AsyncAssert + +Async assertions with timeouts: +- `WaitUntil(condition, description, timeout)` - Wait for condition +- `WaitForValue(getter, expected, description)` - Wait for specific value +- `AssertNeverTrue(condition, description, duration)` - Assert something doesn't happen + +## Directory Structure + +``` +E2E/ +├── Infrastructure/ # Base classes and utilities (don't modify often) +├── Scenarios/ # Your actual E2E tests go here +└── TestData/ # Save files and fixtures for scenarios +``` + +## Running Tests + +**In Unity Editor:** +- Window → General → Test Runner +- Select "PlayMode" tab +- Filter by "E2E" category + +**Command Line:** +```bash +unity -runTests -testPlatform PlayMode -testCategory E2E -batchmode +``` + +## Best Practices + +1. **Use Given-When-Then structure** for readable tests +2. **Wait for conditions, not time** - avoid `WaitForSeconds` as primary sync +3. **One journey per test** - keep tests focused +4. **Descriptive assertions** - include context in failure messages +5. **Clean up state** - don't let tests pollute each other + +## Extending the Framework + +### Adding Scenario Methods + +Edit `ScenarioBuilder.cs` to add domain-specific setup: + +```csharp +public ScenarioBuilder OnLevel(int level) +{ + _setupActions.Add(() => LoadLevel(level)); + return this; +} + +private IEnumerator LoadLevel(int level) +{ + _gameState.LoadLevel(level); + yield return null; +} +``` + +### Adding Input Methods + +Edit `InputSimulator.cs` for game-specific input: + +```csharp +public IEnumerator ClickHex(Hex hex) +{ + var worldPos = HexUtils.HexToWorld(hex); + yield return ClickWorldPosition(worldPos); +} +``` + +## Troubleshooting + +| Issue | Cause | Fix | +|-------|-------|-----| +| Tests timeout waiting for ready | Game init takes too long | Increase timeout in `WaitForGameReady` | +| Input simulation doesn't work | Wrong input system | Check `InputSimulator` matches your setup | +| Flaky tests | Race conditions | Use `AsyncAssert.WaitUntil` instead of `WaitForSeconds` | +| Can't find GameState | Wrong scene or class name | Check `SceneName` and class reference | +``` + +--- + +## Step 5: Output Summary + +After generating all files, provide this summary: + +```markdown +## E2E Infrastructure Scaffold Complete + +**Engine**: {Unity | Unreal | Godot} +**Version**: {detected_version} + +### Files Created + +``` +Tests/PlayMode/E2E/ +├── E2E.asmdef +├── Infrastructure/ +│ ├── GameE2ETestFixture.cs +│ ├── ScenarioBuilder.cs +│ ├── InputSimulator.cs +│ └── AsyncAssert.cs +├── Scenarios/ +│ └── (empty) +├── TestData/ +│ └── (empty) +├── ExampleE2ETest.cs +└── README.md +``` + +### Configuration + +| Setting | Value | +|---------|-------| +| Game State Class | `{GameStateClass}` | +| Main Scene | `{MainSceneName}` | +| Input System | `{InputSystemType}` | +| Ready Property | `{IsReadyProperty}` | + +### Customization Required + +1. **ScenarioBuilder**: Add domain-specific setup methods for your game entities +2. **InputSimulator**: Add game-specific input methods (e.g., hex clicking, gesture shortcuts) +3. **ExampleE2ETest**: Modify example tests to use your actual UI elements + +### Next Steps + +1. ✅ Run `ExampleE2ETests.Infrastructure_GameLoadsAndReachesReadyState` to verify setup +2. 📝 Extend `ScenarioBuilder` with your domain methods +3. 📝 Extend `InputSimulator` with game-specific input helpers +4. 🧪 Use `test-design` workflow to identify E2E scenarios +5. 🤖 Use `automate` workflow to generate E2E tests from scenarios + +### Knowledge Applied + +- `knowledge/e2e-testing.md` - Core E2E patterns and infrastructure +- `knowledge/{engine}-testing.md` - Engine-specific implementation details +``` + +--- + +## Validation + +Refer to `checklist.md` for comprehensive validation criteria. diff --git a/src/modules/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml b/src/modules/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml new file mode 100644 index 00000000..013be903 --- /dev/null +++ b/src/modules/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml @@ -0,0 +1,101 @@ +# E2E Test Infrastructure Scaffold Workflow + +workflow: + id: e2e-scaffold + name: E2E Test Infrastructure Scaffold + version: 1.0 + module: bmgd + agent: game-qa + + description: | + Scaffold complete E2E testing infrastructure for an existing game project. + Creates test fixtures, scenario builders, input simulators, and async + assertion utilities tailored to the project's architecture. + + triggers: + - "ES" + - "e2e-scaffold" + - "scaffold e2e" + - "e2e infrastructure" + - "setup e2e" + + preflight: + - "Test framework initialized (run `test-framework` workflow first)" + - "Game has identifiable state manager" + - "Main gameplay scene exists" + + knowledge_fragments: + - "knowledge/e2e-testing.md" + - "knowledge/unity-testing.md" + - "knowledge/unreal-testing.md" + - "knowledge/godot-testing.md" + + inputs: + game_state_class: + description: "Primary game state manager class name" + required: true + example: "GameStateManager" + + main_scene: + description: "Scene name where core gameplay occurs" + required: true + example: "GameScene" + + input_system: + description: "Input system in use" + required: false + default: "auto-detect" + options: + - "unity-input-system" + - "unity-legacy" + - "unreal-enhanced" + - "godot-input" + - "custom" + + outputs: + infrastructure_files: + description: "Generated E2E infrastructure classes" + files: + - "Tests/PlayMode/E2E/Infrastructure/GameE2ETestFixture.cs" + - "Tests/PlayMode/E2E/Infrastructure/ScenarioBuilder.cs" + - "Tests/PlayMode/E2E/Infrastructure/InputSimulator.cs" + - "Tests/PlayMode/E2E/Infrastructure/AsyncAssert.cs" + + assembly_definition: + description: "E2E test assembly configuration" + files: + - "Tests/PlayMode/E2E/E2E.asmdef" + + example_test: + description: "Working example E2E test" + files: + - "Tests/PlayMode/E2E/ExampleE2ETest.cs" + + documentation: + description: "E2E testing README" + files: + - "Tests/PlayMode/E2E/README.md" + + steps: + - id: analyze + name: "Analyze Game Architecture" + instruction_file: "instructions.md#step-1-analyze-game-architecture" + + - id: scaffold + name: "Generate Infrastructure" + instruction_file: "instructions.md#step-2-generate-infrastructure" + + - id: example + name: "Generate Example Test" + instruction_file: "instructions.md#step-3-generate-example-test" + + - id: document + name: "Generate Documentation" + instruction_file: "instructions.md#step-4-generate-documentation" + + - id: complete + name: "Output Summary" + instruction_file: "instructions.md#step-5-output-summary" + + validation: + checklist: "checklist.md" diff --git a/src/modules/bmgd/workflows/gametest/test-design/instructions.md b/src/modules/bmgd/workflows/gametest/test-design/instructions.md index b799dafe..96bf2869 100644 --- a/src/modules/bmgd/workflows/gametest/test-design/instructions.md +++ b/src/modules/bmgd/workflows/gametest/test-design/instructions.md @@ -91,6 +91,18 @@ Create comprehensive test scenarios for game projects, covering gameplay mechani | Performance | FPS, loading times | P1 | | Accessibility | Assist features | P1 | +### E2E Journey Testing + +**Knowledge Base Reference**: `knowledge/e2e-testing.md` + +| Category | Focus | Priority | +|----------|-------|----------| +| Core Loop | Complete gameplay cycle | P0 | +| Turn Lifecycle | Full turn from start to end | P0 | +| Save/Load Round-trip | Save → quit → load → resume | P0 | +| Scene Transitions | Menu → Game → Back | P1 | +| Win/Lose Paths | Victory and defeat conditions | P1 | + --- ## Step 3: Create Test Scenarios @@ -153,6 +165,39 @@ SCENARIO: Gameplay Under High Latency CATEGORY: multiplayer ``` +### E2E Scenario Format + +For player journey tests, use this extended format: +``` +E2E SCENARIO: [Player Journey Name] + GIVEN [Initial game state - use ScenarioBuilder terms] + WHEN [Sequence of player actions] + THEN [Observable outcomes] + TIMEOUT: [Expected max duration in seconds] + PRIORITY: P0/P1 + CATEGORY: e2e + INFRASTRUCTURE: [Required fixtures/builders] +``` + +### Example E2E Scenario +``` +E2E SCENARIO: Complete Combat Encounter + GIVEN game loaded with player unit adjacent to enemy + AND player unit has full health and actions + WHEN player selects unit + AND player clicks attack on enemy + AND player confirms attack + AND attack animation completes + AND enemy responds (if alive) + THEN enemy health is reduced OR enemy is defeated + AND turn state advances appropriately + AND UI reflects new state + TIMEOUT: 15 + PRIORITY: P0 + CATEGORY: e2e + INFRASTRUCTURE: ScenarioBuilder, InputSimulator, AsyncAssert +``` + --- ## Step 4: Prioritize Test Coverage @@ -161,12 +206,12 @@ SCENARIO: Gameplay Under High Latency **Knowledge Base Reference**: `knowledge/test-priorities.md` -| Priority | Criteria | Coverage Target | -| -------- | ---------------------------- | --------------- | -| P0 | Ship blockers, certification | 100% automated | -| P1 | Major features, common paths | 80% automated | -| P2 | Secondary features | 60% automated | -| P3 | Edge cases, polish | Manual only | +| Priority | Criteria | Unit | Integration | E2E | Manual | +|----------|----------|------|-------------|-----|--------| +| P0 | Ship blockers | 100% | 80% | Core flows | Smoke | +| P1 | Major features | 90% | 70% | Happy paths | Full | +| P2 | Secondary | 80% | 50% | - | Targeted | +| P3 | Edge cases | 60% | - | - | As needed | ### Risk-Based Ordering