# 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. /// Note: For floating-point comparisons, use WaitForValueApprox instead /// to handle precision issues. This method uses exact equality. /// 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 float value within tolerance (handles floating-point precision). /// public static IEnumerator WaitForValueApprox( Func getter, float expected, string description, float tolerance = 0.0001f, float timeout = 5f) { yield return WaitUntil( () => Mathf.Abs(expected - getter()) < tolerance, $"{description} to equal ~{expected} ±{tolerance} (current: {getter()})", timeout); } /// /// Wait for a double value within tolerance (handles floating-point precision). /// public static IEnumerator WaitForValueApprox( Func getter, double expected, string description, double tolerance = 0.0001, float timeout = 5f) { yield return WaitUntil( () => Math.Abs(expected - getter()) < tolerance, $"{description} to equal ~{expected} ±{tolerance} (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 var savedFilePath = GameState.GetSavePath(savePath); if (System.IO.File.Exists(savedFilePath)) { try { System.IO.File.Delete(savedFilePath); } catch (System.IO.IOException ex) { Debug.LogWarning($"[E2E] Failed to delete test save file '{savedFilePath}': {ex.Message}"); } catch (UnauthorizedAccessException ex) { Debug.LogWarning($"[E2E] Access denied deleting test save file '{savedFilePath}': {ex.Message}"); } } } ``` ### 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" ], "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": true, "precompiledReferences": [ "nunit.framework.dll", "UnityEngine.TestRunner.dll", "UnityEditor.TestRunner.dll" ], "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() { // Yield first to ensure we're on the main thread for screenshot capture yield return null; if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed) { var screenshot = ScreenCapture.CaptureScreenshotAsTexture(); var bytes = screenshot.EncodeToPNG(); var screenshotPath = $"TestResults/Screenshots/{TestContext.CurrentContext.Test.Name}.png"; System.IO.File.WriteAllBytes(screenshotPath, bytes); Debug.Log($"[E2E FAILURE] Screenshot saved: {screenshotPath}"); } } ``` ### 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)