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
This commit is contained in:
Scott Jennings 2026-01-13 18:18:08 -06:00
parent eeebf152af
commit 16190f7a04
9 changed files with 2401 additions and 7 deletions

View File

@ -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"

View File

@ -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<GameStateManager>();
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<Func<IEnumerator>> _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<Mouse>();
_keyboard = Keyboard.current ?? InputSystem.AddDevice<Keyboard>();
_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<UnityEngine.UI.Button>();
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
{
/// <summary>
/// Wait until condition is true, or fail with message after timeout.
/// </summary>
public static IEnumerator WaitUntil(
Func<bool> 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}");
}
/// <summary>
/// Wait until condition is true, with periodic logging.
/// </summary>
public static IEnumerator WaitUntilVerbose(
Func<bool> 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}");
}
/// <summary>
/// Wait for a specific value, with descriptive failure.
/// </summary>
public static IEnumerator WaitForValue<T>(
Func<T> getter,
T expected,
string description,
float timeout = 5f) where T : IEquatable<T>
{
yield return WaitUntil(
() => expected.Equals(getter()),
$"{description} to equal {expected} (current: {getter()})",
timeout);
}
/// <summary>
/// Wait for an event to fire.
/// </summary>
public static IEnumerator WaitForEvent<T>(
Action<Action<T>> subscribe,
Action<Action<T>> unsubscribe,
string eventName,
float timeout = 5f) where T : class
{
T received = null;
Action<T> handler = e => received = e;
subscribe(handler);
yield return WaitUntil(
() => received != null,
$"Event '{eventName}' to fire",
timeout);
unsubscribe(handler);
}
/// <summary>
/// Assert that something does NOT happen within a time window.
/// </summary>
public static IEnumerator WaitAndAssertNot(
Func<bool> 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<T>()` (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)

View File

@ -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

View File

@ -15,3 +15,4 @@ localization-testing,Localization Testing,"Text, audio, and cultural validation
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
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

1 id name description tags fragment_file
15 certification-testing Platform Certification Console TRC/XR requirements and certification testing certification,console,trc,xr knowledge/certification-testing.md
16 smoke-testing Smoke Testing Critical path validation for build verification smoke-tests,bvt,ci knowledge/smoke-testing.md
17 test-priorities Test Priorities Matrix P0-P3 criteria, coverage targets, execution ordering for games prioritization,risk,coverage knowledge/test-priorities.md
18 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

View File

@ -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.**
---

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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