ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity ET框架与Test Runner集成:构建异步ECS架构的自动化测试方案

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.AddSingletonITimeInfo, TestTimeInfo(); // 3. 创建根Scene RootScene World.AddChildScene(); RootScene.Name RootScene_Test; // 4. 注册测试所需的核心单例组件到RootScene // 例如RootScene.AddComponentObjectPool(); // RootScene.AddComponentIdGenerater(); // RootScene.AddComponentEventSystem(); // 5. 注册你自定义的、测试需要的System // World.AddSystemYourSystemType(); 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.GetParentUnit().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.AddChildHealthComponent(); // Assert Assert.AreEqual(100, health.HP); Assert.IsFalse(health.IsDead); // 测试结束TearDown会自动清理health实体 } // 一个测试异步DamageSystem的UnityTest [UnityTest] public IEnumerator DamageSystem_ApplyDamage_ShouldReduceHP() { // Arrange var health RootScene.AddChildHealthComponent(); 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.AddChildHealthComponent(); bool deadEventTriggered false; // 订阅事件来验证系统行为需要你的事件系统支持测试环境下的订阅 // RootScene.GetComponentEventSystem().AddListenerDeadEvent((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 AllTest 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.AddSingletonEventSystem()或RootScene.AddComponentEventSystem()取决于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上可能是“无头模式”运行测试一些依赖于图形设备或输入系统的初始化可能会失败。确保测试环境的初始化不依赖这些。心得尽早将测试集成到CI流水线中能暴露出很多环境依赖问题。使用Unity的-batchmode和-runTests命令行参数来运行Test Runner。问题5测试运行速度太慢。排查每个测试都重新初始化整个ET世界如果世界很复杂注册了成百上千个System确实会慢。考虑优化使用[OneTimeSetUp]和[OneTimeTearDown]来初始化和清理只读的、全局的资源如协议类型注册、配置表结构。但绝不能用它们来初始化World和Scene。审视你的测试是否真的需要启动一个“完整”的ET世界也许某些纯逻辑的单元测试可以直接new出对象来测无需通过ET框架。心得测试金字塔仍然适用。大量低层级的、不依赖ET容器的单元测试应该是最快的。ETTest Runner的集成测试用于验证组件、系统间的交互数量应控制在中层。将ET框架与Unity Test Runner集成初期需要一些搭建成本但一旦这套基础设施就位它带来的收益是巨大的快速的回归测试、清晰的接口验证、以及重构时那无与伦比的安全感。它让ET框架那种优雅而复杂的异步架构变得像普通代码一样易于验证和调试。
返回列表