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:
parent
eeebf152af
commit
16190f7a04
|
|
@ -22,6 +22,8 @@ agent:
|
||||||
|
|
||||||
critical_actions:
|
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"
|
- "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"
|
- "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"
|
- "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`"
|
- "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"
|
workflow: "{project-root}/_bmad/bmgd/workflows/gametest/automate/workflow.yaml"
|
||||||
description: "[TA] Generate automated game tests"
|
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
|
- trigger: PP or fuzzy match on playtest-plan
|
||||||
workflow: "{project-root}/_bmad/bmgd/workflows/gametest/playtest-plan/workflow.yaml"
|
workflow: "{project-root}/_bmad/bmgd/workflows/gametest/playtest-plan/workflow.yaml"
|
||||||
description: "[PP] Create structured playtesting plan"
|
description: "[PP] Create structured playtesting plan"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -381,3 +381,17 @@ test:
|
||||||
| NullReferenceException | Missing Setup | Ensure [SetUp] initializes all fields |
|
| NullReferenceException | Missing Setup | Ensure [SetUp] initializes all fields |
|
||||||
| Tests hang | Infinite coroutine | Add timeout or max iterations |
|
| Tests hang | Infinite coroutine | Add timeout or max iterations |
|
||||||
| Flaky physics tests | Timing dependent | Use WaitForFixedUpdate, increase tolerance |
|
| 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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
|
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
|
||||||
|
|
|
||||||
|
|
|
@ -209,6 +209,87 @@ func test_{feature}_integration():
|
||||||
# Cleanup
|
# Cleanup
|
||||||
scene.queue_free()
|
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.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
|
|
@ -91,6 +91,18 @@ Create comprehensive test scenarios for game projects, covering gameplay mechani
|
||||||
| Performance | FPS, loading times | P1 |
|
| Performance | FPS, loading times | P1 |
|
||||||
| Accessibility | Assist features | 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
|
## Step 3: Create Test Scenarios
|
||||||
|
|
@ -153,6 +165,39 @@ SCENARIO: Gameplay Under High Latency
|
||||||
CATEGORY: multiplayer
|
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
|
## Step 4: Prioritize Test Coverage
|
||||||
|
|
@ -161,12 +206,12 @@ SCENARIO: Gameplay Under High Latency
|
||||||
|
|
||||||
**Knowledge Base Reference**: `knowledge/test-priorities.md`
|
**Knowledge Base Reference**: `knowledge/test-priorities.md`
|
||||||
|
|
||||||
| Priority | Criteria | Coverage Target |
|
| Priority | Criteria | Unit | Integration | E2E | Manual |
|
||||||
| -------- | ---------------------------- | --------------- |
|
|----------|----------|------|-------------|-----|--------|
|
||||||
| P0 | Ship blockers, certification | 100% automated |
|
| P0 | Ship blockers | 100% | 80% | Core flows | Smoke |
|
||||||
| P1 | Major features, common paths | 80% automated |
|
| P1 | Major features | 90% | 70% | Happy paths | Full |
|
||||||
| P2 | Secondary features | 60% automated |
|
| P2 | Secondary | 80% | 50% | - | Targeted |
|
||||||
| P3 | Edge cases, polish | Manual only |
|
| P3 | Edge cases | 60% | - | - | As needed |
|
||||||
|
|
||||||
### Risk-Based Ordering
|
### Risk-Based Ordering
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue