# 单元测试 04 · 游戏状态机(GameStateMachine) > **测试类型**:EditMode 单元测试(NUnit) > **测试文件**:`Assets/Tests/EditMode/GameStateMachineTests.cs` > **被测程序集**:`BaseGames.Core`、`BaseGames.Core.Events` > **asmdef 依赖**:`BaseGames.Tests.EditMode.asmdef` 需引用 `BaseGames.Core`、`BaseGames.Core.Events` --- ## 目录 1. [测试覆盖范围](#1-测试覆盖范围) 2. [GameStateMachine 测试](#2-gamestatemachine-测试) 3. [GameStateId 测试](#3-gamestateid-测试) 4. [BuiltinGameStates 测试](#4-builtingamestates-测试) 5. [完整测试代码](#5-完整测试代码) --- ## 1. 测试覆盖范围 | 类 | 测试点 | |----|--------| | `GameStateMachine` | 状态注册;合法转换成功;非法转换拒绝并返回 false;未知状态 ID 拒绝;OnEnter/OnExit 回调顺序正确;初始状态为 default | | `GameStateId` | 值相等性(struct);ToString 输出 | | `BuiltinGameStates` | 8 个内置状态 ID 唯一;`GameStates.Gameplay` 等工厂属性非 default | --- ## 2. GameStateMachine 测试 ### 测试说明 `GameStateMachine` 是纯 C# 类(非 MonoBehaviour),完全可以在 EditMode 中实例化测试。 需要创建 `IGameState` 的 Mock 实现来注册状态: ```csharp private class MockState : IGameState { public GameStateId Id { get; } public IReadOnlyCollection ValidNextStates { get; } public bool OnEnterCalled { get; private set; } public bool OnExitCalled { get; private set; } public GameStateId EnteredFrom { get; private set; } public GameStateId ExitedTo { get; private set; } public MockState(GameStateId id, params GameStateId[] validNext) { Id = id; ValidNextStates = new HashSet(validNext); } public void OnEnter(GameStateId from) { OnEnterCalled = true; EnteredFrom = from; } public void OnExit(GameStateId to) { OnExitCalled = true; ExitedTo = to; } public void Tick(float dt) { } } ``` **关键验证点:** - 初始状态 `CurrentStateId == default` - `TransitionTo` 未知状态返回 `false` + error 包含 "未知状态" - 合法转换返回 `true` + error 为 `null` - 非法转换(不在 ValidNextStates)返回 `false` + error 包含 "非法转换" - `OnExit` 在旧状态上调用,传入新状态 ID - `OnEnter` 在新状态上调用,传入旧状态 ID --- ## 3. GameStateId 测试 ### 测试说明 `GameStateId` 是值类型(struct),验证其相等性语义(两个相同 ID 的实例应 `==` 相等)。 --- ## 4. BuiltinGameStates 测试 ### 测试说明 `BuiltinGameStates`(或 `GameStates` 静态类)提供 8 个内置状态的 `GameStateId` 常量,验证它们都是有效的非 default 值。 --- ## 5. 完整测试代码 将以下代码保存至 `Assets/Tests/EditMode/GameStateMachineTests.cs`: ```csharp using System.Collections.Generic; using NUnit.Framework; using BaseGames.Core; using BaseGames.Core.Events; namespace BaseGames.Tests.EditMode { /// /// GameStateMachine + GameStateId 单元测试(EditMode)。 /// 覆盖:状态注册、合法/非法转换、OnEnter/OnExit 回调顺序。 /// [TestFixture] public class GameStateMachineTests { // ── Mock 状态实现 ──────────────────────────────────────────────────── private class MockState : IGameState { public GameStateId Id { get; } public IReadOnlyCollection ValidNextStates { get; } public bool OnEnterCalled { get; private set; } public bool OnExitCalled { get; private set; } public GameStateId EnteredFrom { get; private set; } public GameStateId ExitedTo { get; private set; } public int TickCount { get; private set; } public MockState(GameStateId id, params GameStateId[] validNext) { Id = id; ValidNextStates = new HashSet(validNext); } public void OnEnter(GameStateId from) { OnEnterCalled = true; EnteredFrom = from; } public void OnExit(GameStateId to) { OnExitCalled = true; ExitedTo = to; } public void Tick(float dt) => TickCount++; } private static readonly GameStateId StateA = new GameStateId("StateA"); private static readonly GameStateId StateB = new GameStateId("StateB"); private static readonly GameStateId StateC = new GameStateId("StateC"); // ── 初始状态 ───────────────────────────────────────────────────────── [Test] public void GameStateMachine_InitialState_IsDefault() { var fsm = new GameStateMachine(); Assert.AreEqual(default(GameStateId), fsm.CurrentStateId, "未注册任何状态时,CurrentStateId 应为 default"); } // ── 状态注册与转换 ──────────────────────────────────────────────────── [Test] public void TransitionTo_UnknownState_ReturnsFalse() { var fsm = new GameStateMachine(); bool result = fsm.TransitionTo(StateA, out string error); Assert.IsFalse(result, "未注册的状态转换应返回 false"); StringAssert.Contains("未知状态", error); } [Test] public void TransitionTo_RegisteredState_FromNull_Succeeds() { var fsm = new GameStateMachine(); var stateA = new MockState(StateA); fsm.Register(stateA); bool result = fsm.TransitionTo(StateA, out string error); Assert.IsTrue(result, "注册状态后首次转换应成功"); Assert.IsNull(error, "成功转换时 error 应为 null"); Assert.AreEqual(StateA, fsm.CurrentStateId); } [Test] public void TransitionTo_ValidNextState_Succeeds() { var fsm = new GameStateMachine(); var stateA = new MockState(StateA, StateB); // A 可以转换到 B var stateB = new MockState(StateB); fsm.Register(stateA); fsm.Register(stateB); fsm.TransitionTo(StateA, out _); bool result = fsm.TransitionTo(StateB, out string error); Assert.IsTrue(result); Assert.IsNull(error); Assert.AreEqual(StateB, fsm.CurrentStateId); } [Test] public void TransitionTo_InvalidNextState_ReturnsFalse() { var fsm = new GameStateMachine(); var stateA = new MockState(StateA); // A 无有效下一状态 var stateB = new MockState(StateB); fsm.Register(stateA); fsm.Register(stateB); fsm.TransitionTo(StateA, out _); bool result = fsm.TransitionTo(StateB, out string error); Assert.IsFalse(result, "非法转换应返回 false"); StringAssert.Contains("非法转换", error); Assert.AreEqual(StateA, fsm.CurrentStateId, "非法转换后状态不应改变"); } // ── OnEnter / OnExit 回调 ──────────────────────────────────────────── [Test] public void TransitionTo_CallsOnExit_OnPreviousState() { var fsm = new GameStateMachine(); var stateA = new MockState(StateA, StateB); var stateB = new MockState(StateB); fsm.Register(stateA); fsm.Register(stateB); fsm.TransitionTo(StateA, out _); fsm.TransitionTo(StateB, out _); Assert.IsTrue(stateA.OnExitCalled, "转出 StateA 时应调用 OnExit"); Assert.AreEqual(StateB, stateA.ExitedTo, "OnExit 传入的下一状态应为 StateB"); } [Test] public void TransitionTo_CallsOnEnter_OnNextState() { var fsm = new GameStateMachine(); var stateA = new MockState(StateA, StateB); var stateB = new MockState(StateB); fsm.Register(stateA); fsm.Register(stateB); fsm.TransitionTo(StateA, out _); fsm.TransitionTo(StateB, out _); Assert.IsTrue(stateB.OnEnterCalled, "转入 StateB 时应调用 OnEnter"); Assert.AreEqual(StateA, stateB.EnteredFrom, "OnEnter 传入的前一状态应为 StateA"); } [Test] public void TransitionTo_FirstEntry_OnEnterFromIsDefault() { var fsm = new GameStateMachine(); var stateA = new MockState(StateA); fsm.Register(stateA); fsm.TransitionTo(StateA, out _); Assert.AreEqual(default(GameStateId), stateA.EnteredFrom, "首次进入状态时,OnEnter 的 from 参数应为 default"); } // ── Tick ────────────────────────────────────────────────────────────── [Test] public void Tick_DelegatesTo_CurrentState() { var fsm = new GameStateMachine(); var stateA = new MockState(StateA); fsm.Register(stateA); fsm.TransitionTo(StateA, out _); fsm.Tick(0.016f); fsm.Tick(0.016f); Assert.AreEqual(2, stateA.TickCount, "Tick 应转发给当前状态"); } [Test] public void Tick_NoCurrentState_DoesNotThrow() { var fsm = new GameStateMachine(); Assert.DoesNotThrow(() => fsm.Tick(0.016f)); } // ── 重复注册 ───────────────────────────────────────────────────────── [Test] public void Register_SameId_OverwritesPreviousState() { var fsm = new GameStateMachine(); var stateA1 = new MockState(StateA); var stateA2 = new MockState(StateA, StateB); fsm.Register(stateA1); fsm.Register(stateA2); // 覆盖 fsm.TransitionTo(StateA, out _); var stateB = new MockState(StateB); fsm.Register(stateB); bool result = fsm.TransitionTo(StateB, out _); Assert.IsTrue(result, "重新注册后的状态(含 ValidNextStates)应覆盖旧注册"); } // ── GameStateId 值相等性 ─────────────────────────────────────────────── [Test] public void GameStateId_SameKey_AreEqual() { var id1 = new GameStateId("TestState"); var id2 = new GameStateId("TestState"); Assert.AreEqual(id1, id2, "相同 key 的 GameStateId 应相等"); } [Test] public void GameStateId_DifferentKey_AreNotEqual() { var id1 = new GameStateId("StateA"); var id2 = new GameStateId("StateB"); Assert.AreNotEqual(id1, id2); } [Test] public void GameStateId_DefaultIsNotEqualToNamed() { var named = new GameStateId("Something"); var defaultId = default(GameStateId); Assert.AreNotEqual(named, defaultId); } // ── BuiltinGameStates 唯一性 ─────────────────────────────────────────── [Test] public void BuiltinStates_AllIdsAreUnique() { var ids = new[] { GameStates.MainMenu, GameStates.Gameplay, GameStates.Paused, GameStates.BossFight, GameStates.Cutscene, GameStates.Loading, GameStates.Dead, GameStates.GameOver, }; var set = new HashSet(ids); Assert.AreEqual(ids.Length, set.Count, "8 个内置游戏状态 ID 必须全部唯一"); } [Test] public void BuiltinStates_NoneIsDefault() { var ids = new[] { GameStates.MainMenu, GameStates.Gameplay, GameStates.Paused, GameStates.BossFight, GameStates.Cutscene, GameStates.Loading, GameStates.Dead, GameStates.GameOver, }; foreach (var id in ids) { Assert.AreNotEqual(default(GameStateId), id, $"内置状态 {id} 不应等于 default(GameStateId)"); } } } } ``` --- ## 预期结果 所有 **17 个测试** 全部通过(绿色)。 ### 常见问题排查 | 失败现象 | 排查方向 | |---------|---------| | `GameStateId` 构造函数找不到 | 确认 `GameStateId` 是 struct,构造函数参数为 string key | | `GameStates.MainMenu` 找不到 | 确认 `GameStates` 静态类(或 `BuiltinGameStates`)在 `BaseGames.Core` 命名空间下 | | `IGameState` 接口找不到 | asmdef 需同时引用 `BaseGames.Core` 和 `BaseGames.Core.Events` | | TransitionTo 返回 true 但 error 非 null | 确认 `GameStateMachine.TransitionTo` 成功时将 `error` 设为 `null` |