Unity ET框架与Test Runner集成:构建异步ECS架构的自动化测试方案
1. 项目概述:为什么我们需要新的测试范式?
在Unity项目开发中,尤其是涉及复杂业务逻辑和网络同步的游戏或应用,测试一直是个老大难问题。传统的单元测试往往只针对孤立的类和方法,而集成测试和端到端测试的搭建成本又高得吓人。很多团队最终陷入“手动点点点”的泥潭,或者写了一大堆难以维护、运行缓慢的测试代码。我自己在多个中大型Unity项目里摸爬滚打,深刻体会到,一套好的测试框架不仅仅是“写测试”,更是关乎开发效率、代码质量和团队协作的基石。
最近,我在一个基于ET框架的服务端-客户端一体化项目中,尝试将ET框架与Unity官方的Test Runner进行深度集成,摸索出了一套新的测试工作流。ET框架本身是一个基于ECS(实体-组件-系统)和Actor模型的单线程异步处理框架,在服务端开发中非常流行,其清晰的逻辑分层和热更新能力是巨大优势。但它的异步特性和独特的生命周期,让传统的、基于MonoBehaviour的Unity测试方法几乎无从下手。Test Runner虽然是Unity的亲儿子,功能强大,但默认主要面向的是编辑器和运行时环境下的常规代码测试。
将这两者结合,核心目标就是:为ET框架的异步逻辑、网络消息、组件系统等,提供一套能在Unity编辑器内一键运行、可重复、且能集成到CI/CD流水线中的自动化测试方案。这不仅仅是技术上的缝合,更是一种开发范式的转变——让我们能用写单元测试的效率和信心,去验证那些过去只能靠人工联调才能确认的复杂交互逻辑。
2. 核心思路拆解:ET框架与Test Runner的融合点
要实现“无缝集成”,我们不能简单地把ET的代码扔进Test Runner里跑,那样肯定会因为生命周期、初始化顺序等问题而崩溃。我们需要深入理解两者的运作机制,找到关键的结合点。
2.1 ET框架的测试挑战
ET框架的核心运行依赖于一个Game对象(或类似的入口类)来启动整个引擎,管理所有的Scene、Entity和System。它的生命周期是自驱动的,通常由一条主线程的异步循环(如EventSystem)来推动。这带来了几个测试难点:
- 环境初始化复杂:启动一个完整的ET环境需要初始化一大堆
World、Root、Scene,加载配置表,注册组件和系统。这个过程在测试中必须被精确地模拟和控制。 - 强异步依赖:ET中几乎所有的逻辑都是异步的,比如等待一个网络响应、一个定时器到期,或者一个事件被触发。传统的
[Test]方法默认是同步的,无法直接await一个ET的异步方法。 - 资源与依赖隔离:测试不应该依赖真实的网络连接、数据库或复杂的资源加载。我们需要能够Mock(模拟)这些外部依赖,例如模拟一个网络会话(
Session)或一个数据库查询结果。
2.2 Unity Test Runner的能力与扩展
Unity Test Runner基于NUnit,提供了两种主要的测试模式:
- Edit Mode Tests:在编辑器环境下运行,不进入Play模式。速度快,适合测试不依赖游戏运行时的工具类、数据结构和编辑器扩展逻辑。
- Play Mode Tests:会启动一个独立的Unity Player(可以是独立运行或特定平台),测试依赖于游戏运行时(如
GameObject、MonoBehaviour、物理系统)的代码。
对于ET框架,Play Mode Tests是我们的主战场,因为ET的完整生命周期需要Unity的运行时环境。但Test Runner默认并不理解ET的“世界”。因此,集成的核心思路是:利用Test Runner的[SetUp]和[TearDown]特性,在每一个测试用例开始前,为我们手动创建并启动一个纯净的、用于测试的ET运行环境;在测试结束后,完整地清理和销毁它。
此外,NUnit提供了一个强大的[UnityTest]属性。与普通的[Test]不同,[UnityTest]方法可以返回一个IEnumerator,这意味着我们可以在方法内部使用yield return来等待一帧或者等待某个异步操作完成。这正是我们处理ET异步逻辑的钥匙——我们可以通过yield return来等待ET框架内部的一个异步任务完成信号。
2.3 整体架构设计
基于以上分析,我设计的集成架构主要包含以下几个层次:
- 测试根节点 (Test Bootstrap):一个放在
Assets/Tests目录下的MonoBehaviour,它负责在Play Mode测试启动时,全局初始化一次ET框架的核心资源(如代码配置、协议类型等)。这个初始化通常只做一次。 - 测试场景 (Test Scene):创建一个专用于测试的
.unity场景。这个场景非常“干净”,可能只包含一个用于挂载测试启动脚本的空GameObject。Test Runner可以配置为在运行Play Mode测试前自动加载这个场景。 - 测试夹具 (Test Fixture):对应于NUnit的
[SetUp]和[TearDown]。我们创建一个基类,比如ETTestBase。每个测试类继承它。在[SetUp]方法中,我们创建新的ETWorld、RootScene,并注册测试所需的系统。在[TearDown]中,我们调用World.Dispose()来销毁整个世界,确保测试之间完全隔离,没有残留状态污染。 - 异步测试适配器:在
ETTestBase中提供一些辅助方法,用于将ET的ETTask或async方法适配到[UnityTest]的IEnumerator中。例如,一个WaitForETTask方法,内部通过while (!task.IsCompleted) yield return null;来等待任务完成。
这样,每个测试用例都运行在一个独立沙盒中,前一个测试的Entity不会影响到后一个,保证了测试的独立性和可重复性。
3. 环境搭建与项目配置实操
理论说完了,我们动手搭一个。假设你已经有一个基础的Unity项目(建议2021.3 LTS或更新版本)并导入了ET框架。
3.1 创建测试专用程序集
首先,为了避免测试代码被打进正式包,也为了更好的依赖管理,我们使用Assembly Definition (asmdef) 文件。
- 在
Assets目录下创建Tests文件夹。 - 在
Tests文件夹内右键 ->Create->Assembly Definition,命名为Game.ET.Tests。 - 选中这个
Game.ET.Tests.asmdef文件,在Inspector面板中:- 在
Assembly Definition References中添加引用:nunit.framework.dll,UnityEngine.TestRunner。 - 在
Version Defines部分,确保添加了UNITY_INCLUDE_TESTS(通常Test Runner会自动处理,但检查一下更保险)。 - 在
References中,引用你的游戏主逻辑程序集(例如Game.ET.Core)以及ET框架的核心程序集。
- 在
注意:这里有个关键点。你的游戏主逻辑程序集(被测试对象)不能直接引用
nunit或UnityEngine.TestRunner。否则,这些测试代码会被包含进最终的产品构建中。测试程序集是单向引用关系:测试程序集引用产品代码和测试框架。
3.2 配置Test Runner运行设置
- 打开
Window > General > Test Runner。 - 切换到
PlayMode标签页。 - 在Test Runner窗口的右上角,点击三个小点的菜单图标,选择
Edit > Playmode Test Runner Settings(不同Unity版本路径可能略有不同)。 - 在弹出的设置中,你可以指定一个
Test Scene。我们创建一个:在Assets/Tests下创建一个新场景,命名为TestEnvironment.unity。场景里可以什么都不放,或者放一个空的GameObject命名为TestRunnerBootstrap。 - 回到Test Runner设置,将这个
TestEnvironment.unity场景拖入指定区域。这样,每次运行Play Mode测试前,都会自动加载这个干净的场景。
3.3 编写测试环境启动器 (TestBootstrap)
在TestEnvironment.unity场景中,我们创建一个GameObject并挂载一个脚本TestBootstrap.cs。这个脚本的职责是在Play Mode测试开始时,执行一次性的全局初始化。
using UnityEngine; public class TestBootstrap : MonoBehaviour { // 使用RuntimeInitializeOnLoadMethod确保在运行时最早执行 [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void InitializeBeforeSceneLoad() { // 1. 初始化ET框架的全局代码配置 // 这里调用ET框架提供的初始化方法,例如注册所有组件类型、协议类型等。 // 假设ET框架有一个Game.Enter方法或类似入口 // Game.Enter(); // 2. 初始化资源管理(测试环境可能需要使用虚拟的或本地的资源加载器) // ResourcesComponent.Instance.LoadAll(); Debug.Log("[TestBootstrap] ET Framework global initialization for tests completed."); } void Start() { // 这个Start方法可能不会在Test Runner环境下被调用,取决于执行顺序。 // 主要依赖上面的静态方法。 DontDestroyOnLoad(this.gameObject); // 保持这个启动器在整个测试过程中存在 } }这个启动器的作用是模拟游戏启动时的那一套初始化流程,但只为测试服务。关键是[RuntimeInitializeOnLoadMethod]这个属性,它保证了代码在场景加载前执行,顺序可控。
4. 构建可复用的测试基类 (ETTestBase)
这是整个集成方案的核心。我们将创建一个所有ET相关测试类都继承的基类。
using System.Collections; using NUnit.Framework; using UnityEngine.TestTools; using ET; namespace ET.Tests { public class ETTestBase { protected World World { get; private set; } protected Scene RootScene { get; private set; } // 每个测试用例开始前运行 [SetUp] public virtual void SetUp() { // 1. 创建一个新的World,这是ET的根容器 World = new World(); // 2. 设置World的更新器为Test模式(可能需要自定义,避免依赖Unity的Update) // World.AddSingleton<ITimeInfo, TestTimeInfo>(); // 3. 创建根Scene RootScene = World.AddChild<Scene>(); RootScene.Name = "RootScene_Test"; // 4. 注册测试所需的核心单例组件到RootScene // 例如:RootScene.AddComponent<ObjectPool>(); // RootScene.AddComponent<IdGenerater>(); // RootScene.AddComponent<EventSystem>(); // 5. 注册你自定义的、测试需要的System // World.AddSystem<YourSystemType>(); Debug.Log($"[ETTestBase] Test World and Scene initialized. Test: {TestContext.CurrentContext.Test.Name}"); } // 每个测试用例结束后运行 [TearDown] public virtual void TearDown() { if (World != null && !World.IsDisposed) { // 销毁World,这会递归销毁所有Entity和Component World.Dispose(); World = null; RootScene = null; } Debug.Log($"[ETTestBase] Test World disposed. Test: {TestContext.CurrentContext.Test.Name}"); } // 一个辅助协程,用于在UnityTest中等待ETTask完成 protected IEnumerator WaitForETTask(ETTask task) { while (!task.IsCompleted) { yield return null; // 等待一帧 } // 如果任务有异常,在这里可以抛出,让测试失败 if (task.IsFaulted) { throw task.Exception; } } // 另一个辅助方法,等待若干帧(模拟时间流逝) protected IEnumerator WaitForFrames(int frameCount) { for (int i = 0; i < frameCount; i++) { yield return null; } } } }这个基类为每个测试建立了一个独立的ET沙箱。SetUp就是建房子,TearDown就是拆房子,保证下个测试有一块干净的地皮。
5. 编写第一个集成测试用例
现在,我们来针对一个具体的ET功能写测试。假设我们有一个简单的HealthComponent和一个DamageSystem。
// 被测代码 (示例) namespace ET { // 生命值组件 public class HealthComponent : Entity, IAwake, IDestroy { public int HP { get; set; } = 100; public bool IsDead => HP <= 0; } // 伤害系统 public static class DamageSystem { public static ETTask ApplyDamage(this HealthComponent health, int damage) { health.HP -= damage; if (health.IsDead) { // 触发死亡事件等... await health.GetParent<Unit>().PublishAsync(new DeadEvent()); } await ETTask.CompletedTask; } } }对应的测试类如下:
using System.Collections; using NUnit.Framework; using UnityEngine.TestTools; using ET; namespace ET.Tests { // 继承我们刚才写的测试基类 public class HealthSystemTests : ETTestBase { // 一个普通的同步测试 [Test] public void HealthComponent_Initialization_ShouldHaveFullHP() { // Arrange & Act var health = RootScene.AddChild<HealthComponent>(); // Assert Assert.AreEqual(100, health.HP); Assert.IsFalse(health.IsDead); // 测试结束,TearDown会自动清理health实体 } // 一个测试异步DamageSystem的UnityTest [UnityTest] public IEnumerator DamageSystem_ApplyDamage_ShouldReduceHP() { // Arrange var health = RootScene.AddChild<HealthComponent>(); int initialHP = health.HP; int damageAmount = 30; // Act // 调用异步方法,得到一个ETTask ETTask damageTask = health.ApplyDamage(damageAmount); // 使用基类的辅助方法等待任务完成 yield return WaitForETTask(damageTask); // Assert Assert.AreEqual(initialHP - damageAmount, health.HP); Assert.IsFalse(health.IsDead); } [UnityTest] public IEnumerator DamageSystem_ApplyLethalDamage_ShouldMarkAsDead() { // Arrange var health = RootScene.AddChild<HealthComponent>(); bool deadEventTriggered = false; // 订阅事件来验证系统行为(需要你的事件系统支持测试环境下的订阅) // RootScene.GetComponent<EventSystem>().AddListener<DeadEvent>((e) => deadEventTriggered = true); // Act ETTask damageTask = health.ApplyDamage(150); // 造成过量伤害 yield return WaitForETTask(damageTask); // 可能还需要等待一帧,让事件系统处理 yield return null; // Assert Assert.IsTrue(health.IsDead); // Assert.IsTrue(deadEventTriggered, "DeadEvent should be published."); } } }在Test Runner窗口中,你可以看到这些测试用例。点击Run All,Test Runner会加载测试场景,然后依次执行每个测试。绿色的对勾表示测试通过。这种反馈是即时且清晰的。
6. 模拟(Mock)外部依赖与进阶技巧
真实的ET系统不可能孤立运行,它需要与网络、数据库、资源服务等交互。在测试中,我们必须隔离这些不稳定或不可控的外部因素。
6.1 模拟网络会话 (Mock Session)
假设你有一个NetService需要Session来发送消息。在测试中,我们可以创建一个MockSession。
public class MockSession : Entity, ISession { // 实现ISession接口的必要方法 public long Id { get; set; } public MemoryStream LastSentStream { get; private set; } // 记录最后发送的消息 public bool IsDisposed { get; private set; } public void Send(IMessage message) { // 不真的发送,只是记录下消息内容,供测试断言 LastSentStream = MessageSerializeHelper.SerializeTo(message); Debug.Log($"[MockSession] Message recorded: {message.GetType().Name}"); } public void Dispose() { IsDisposed = true; } // ... 其他接口实现,如SendAsync,可以返回一个完成的ETTask }在测试的SetUp中,你可以将这个MockSession添加到某个测试用的Unit实体上,替换掉真实的网络层。这样,当你测试一个需要发送网络消息的逻辑时,就可以断言MockSession.LastSentStream里是否包含了预期的消息。
6.2 使用接口与依赖注入
更优雅的方式是让业务逻辑依赖于接口而非具体实现。例如,定义一个IResourceService接口,真实游戏中使用AssetBundle实现,测试中则使用MockResourceService从本地内存加载数据。
在ET框架中,可以通过在RootScene上添加不同的单例组件来实现这种“服务”的替换。在测试基类的SetUp中,注册Mock服务;在产品代码中,注册真实服务。
6.3 测试异步流程与超时控制
有些异步操作可能因为逻辑错误而永远无法完成。为了防止测试卡死,我们需要为[UnityTest]添加超时机制。NUnit的[UnityTest]属性本身可以结合[Timeout(milliseconds)]使用。
[UnityTest] [Timeout(5000)] // 5秒超时 public IEnumerator LongRunningAsyncOperation_ShouldCompleteWithinTimeout() { // 执行一个可能很长的异步操作 ETTask longTask = SomeLongRunningETMethod(); yield return WaitForETTask(longTask); Assert.IsTrue(longTask.IsCompletedSuccessfully); }如果操作超过5秒未完成,测试会自动失败,并提示超时。
7. 常见问题排查与实战心得
在实际集成过程中,我踩过不少坑,这里总结一下:
问题1:测试运行时,ET的EventSystem报空引用或未初始化错误。
- 排查:检查测试基类
SetUp中,是否在创建World和RootScene后,正确初始化了EventSystem单例。通常需要执行World.AddSingleton<EventSystem>()或RootScene.AddComponent<EventSystem>()(取决于ET版本)。 - 心得:ET框架内各单例的初始化顺序非常关键。最好在测试基类里完全复制一份游戏启动时精简版的初始化流程。
问题2:[UnityTest]协程执行完了,但ET的异步回调(如Timer、事件)还没触发,导致断言失败。
- 排查:ET的异步回调可能在下一帧或某个特定的系统更新中执行。在
yield return WaitForETTask(someTask);之后,再加一句yield return null;或yield return WaitForFrames(1);,给ET框架一帧的时间去处理内部队列。 - 心得:对于涉及ET内部
EventSystem.Update或TimerComponent.Update的逻辑,在测试末尾yield return null一下是个好习惯。
问题3:多个测试用例并行运行(虽然NUnit默认是顺序的,但CI环境可能配置并行)导致状态污染。
- 排查:确保你的测试基类
ETTestBase中的World和RootScene是实例字段(非静态),并且[SetUp]和[TearDown]是实例方法。这样NUnit会为每个测试类实例创建一个新的对象,天然隔离。绝对不要在测试中使用静态变量来共享状态。 - 心得:每个测试都应该是独立的、幂等的。依赖静态状态是测试腐化的开始。
问题4:测试在CI服务器上(如GitLab Runner, Jenkins)失败,但在本地编辑器里成功。
- 排查:
- 资源路径:测试中如果有加载本地配置文件(如Json、Bytes),确保使用
Application.dataPath等相对路径,或者将测试资源放在Resources文件夹并使用Resources.Load。CI环境的工作目录可能不同。 - 平台差异:如果你在CI上针对不同平台(如Standalone Linux, Android)进行测试,注意某些Unity API或ET的底层实现可能有差异。Mock掉所有平台相关的调用。
- 初始化顺序:CI上可能是“无头模式”运行测试,一些依赖于图形设备或输入系统的初始化可能会失败。确保测试环境的初始化不依赖这些。
- 资源路径:测试中如果有加载本地配置文件(如Json、Bytes),确保使用
- 心得:尽早将测试集成到CI流水线中,能暴露出很多环境依赖问题。使用Unity的
-batchmode和-runTests命令行参数来运行Test Runner。
问题5:测试运行速度太慢。
- 排查:每个测试都重新初始化整个ET世界,如果世界很复杂(注册了成百上千个System),确实会慢。考虑优化:
- 使用
[OneTimeSetUp]和[OneTimeTearDown]来初始化和清理只读的、全局的资源(如协议类型注册、配置表结构)。但绝不能用它们来初始化World和Scene。 - 审视你的测试,是否真的需要启动一个“完整”的ET世界?也许某些纯逻辑的单元测试,可以直接new出对象来测,无需通过ET框架。
- 使用
- 心得:测试金字塔仍然适用。大量低层级的、不依赖ET容器的单元测试应该是最快的。ET+Test Runner的集成测试用于验证组件、系统间的交互,数量应控制在中层。
将ET框架与Unity Test Runner集成,初期需要一些搭建成本,但一旦这套基础设施就位,它带来的收益是巨大的:快速的回归测试、清晰的接口验证、以及重构时那无与伦比的安全感。它让ET框架那种优雅而复杂的异步架构,变得像普通代码一样易于验证和调试。
