BMAD-METHOD/src/modules/bmgd/gametest/knowledge/e2e-testing.md

28 KiB

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:

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:

// 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:

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:

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:

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):

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:

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.
    /// Note: For floating-point comparisons, use WaitForValueApprox instead
    /// to handle precision issues. This method uses exact equality.
    /// </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 a float value within tolerance (handles floating-point precision).
    /// </summary>
    public static IEnumerator WaitForValueApprox(
        Func<float> 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);
    }

    /// <summary>
    /// Wait for a double value within tolerance (handles floating-point precision).
    /// </summary>
    public static IEnumerator WaitForValueApprox(
        Func<double> 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);
    }

    /// <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.

[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.

[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.

[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.

[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

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

# 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

# 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:

[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

// 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.

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

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

// 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)