
简介这份资源是面向Unity开发者的NUnit单元测试框架完整源码包适合希望提升代码质量、构建自动化测试体系的中高级开发者。NUnit作为.NET生态中流行的开源测试框架与Unity兼容良好支持参数化测试、分类测试与测试套件可集成到CI/CD流程中帮助开发者在复杂逻辑与核心算法场景下快速验证功能、减少回归bug。压缩包共1504个文件约5.24MB以906个cs源码文件为核心辅以csproj工程文件、build构建脚本、resx资源文件、dll程序集及nunit测试配置等完整保留了框架的工程结构与构建链路便于研究其内部实现或按需裁剪。目前已有2994人学习下载。通过阅读源码与工程配置读者可深入理解测试运行器、断言机制与扩展插件的设计思路为在Unity项目中落地单元测试提供扎实参考。1. 从一次打包翻车说起Unity 单元测试到底该选谁上个月帮一个做数字孪生项目的团队排查问题场景很典型Unity 工程里有一套自研的坐标换算和状态机逻辑编辑器里跑得好好的打包到 Pico4 上就出现物体速度获取异常、状态跳变。三个人查了两天最后发现是一个边界条件在浮点精度下没兜住。这种问题如果有一层单元测试本地跑一遍就能定位根本不用烧到真机上。这也是为什么我一直在团队里强推 Unity 单元测试——它不是锦上添花而是把「玄学 bug」变成「可复现断言」的最低成本手段。Unity 单元测试的选型其实不复杂主流就两条路Unity 官方的 Test Framework底层是 NUnit以及第三方轻量方案。官方这套目前是事实上的最优解它同时支持 EditMode 和 PlayMode 两种测试模式能覆盖纯逻辑和依赖引擎生命周期的场景。适合谁适合所有工程规模超过「一个人一个场景」的团队尤其是做数字孪生、工业仿真、需要长期维护逻辑层的项目。下面我按「是什么 → 怎么搭 → 怎么写 → 坑在哪 → 怎么进阶」拆开讲都是能直接抄的配置。2. Unity Test Framework 的两种模式EditMode 与 PlayMode 怎么选2.1 两种模式的本质区别Unity Test Framework 建立在 NUnit 之上但做了引擎层的封装。它把测试分成两类这个划分直接决定了你写测试时能不能碰GameObject、MonoBehaviour和协程。EditMode 测试运行在编辑器环境下不进入播放模式。它不触发Awake、Start、Update也不走物理和渲染循环。所以它适合测纯 C# 逻辑数学计算、数据结构、状态机、配置解析、序列化。速度快几百个用例几秒钟跑完是日常回归的主力。PlayMode 测试会真正进入播放模式MonoBehaviour的生命周期正常触发协程、物理、时间系统都能用。代价是慢而且对场景有依赖。它适合测组件交互、协程流程、物理碰撞、对象池这类必须依赖运行时行为的逻辑。选型原则很简单能用 EditMode 测的绝不放到 PlayMode。我见过太多团队把所有测试都写成 PlayMode结果 CI 跑一次十几分钟最后没人愿意跑测试形同虚设。常见做法是把逻辑层从MonoBehaviour里剥出来做成纯 C# 类这样 80% 的测试都能落在 EditMode。2.2 工程目录与程序集配置Unity 的测试代码必须放在特定目录下并且需要独立的 Assembly Definitionasmdef文件否则测试程序集引用不到你的业务代码或者反过来污染正式包体。标准做法是在工程里建两个目录Assets/ Tests/ EditMode/ EditModeTests.asmdef CoordinateConverterTests.cs PlayMode/ PlayModeTests.asmdef ObjectPoolTests.cs Scripts/ Runtime/ Runtime.asmdef CoordinateConverter.csEditMode 的 asmdef 内容大致如下关键是includePlatforms里加上Editor并引用UnityEngine.TestRunner和UnityEditor.TestRunner{ name: EditModeTests, references: [ Runtime, UnityEngine.TestRunner, UnityEditor.TestRunner ], includePlatforms: [Editor], overrideReferences: true, precompiledReferences: [nunit.framework.dll], defineConstraints: [UNITY_INCLUDE_TESTS] }PlayMode 的 asmdef 则不要限制平台includePlatforms留空让它能进播放模式{ name: PlayModeTests, references: [ Runtime, UnityEngine.TestRunner ], optionalUnityReferences: [TestAssemblies], defineConstraints: [UNITY_INCLUDE_TESTS] }这里有个参数必须说清楚defineConstraints里的UNITY_INCLUDE_TESTS是官方约定的宏只有带上它测试程序集才会在正确的时机被编译进去打包正式版本时又会被自动排除。少了这一行要么测试代码进包体要么测试根本编译不过。overrideReferences配合precompiledReferences是为了显式引入 nunit避免和引擎自带的版本冲突。配置完在 Unity 菜单Window General Test Runner打开测试面板能看到 EditMode 和 PlayMode 两个标签页说明程序集被正确识别了。如果面板里空空如也九成是 asmdef 的引用或平台设置写错了。3. 写出第一个能跑的测试断言、SetUp 与参数化3.1 基础测试结构与断言假设业务代码里有一个坐标转换类把世界坐标转成屏幕坐标的归一化值// Assets/Scripts/Runtime/CoordinateConverter.cs namespace Runtime { public static class CoordinateConverter { // 把像素坐标归一化到 0~1超出范围做钳制 public static float Normalize(float pixel, float total) { if (total 0f) return 0f; float v pixel / total; return v 0f ? 0f : (v 1f ? 1f : v); } } }对应的 EditMode 测试// Assets/Tests/EditMode/CoordinateConverterTests.cs using NUnit.Framework; using Runtime; namespace Tests.EditMode { public class CoordinateConverterTests { [Test] public void Normalize_中间值_返回正确比例() { // 500 / 1000 应为 0.5 Assert.AreEqual(0.5f, CoordinateConverter.Normalize(500f, 1000f), 1e-6f); } [Test] public void Normalize_超出上界_钳制为1() { Assert.AreEqual(1f, CoordinateConverter.Normalize(1500f, 1000f), 1e-6f); } [Test] public void Normalize_总长为零_返回零不抛异常() { Assert.AreEqual(0f, CoordinateConverter.Normalize(100f, 0f), 1e-6f); } } }逻辑说明[Test]标记一个可执行用例方法名我习惯用「被测方法_场景_预期」的中文三段式跑失败时一眼能看出问题。Assert.AreEqual的第三个参数是浮点容差浮点比较绝对不能直接用这是血泪经验1e-6f对大多数游戏逻辑够用涉及物理量可以放宽到1e-4f。第三个用例专门测除零边界这类用例才是真正防翻车的地方。3.2 SetUp、TearDown 与参数化测试当多个用例共享初始化逻辑时用[SetUp]和[TearDown]。注意 EditMode 下SetUp每个用例都会执行一次不是整个类执行一次别把重初始化逻辑放进去拖慢速度。using NUnit.Framework; using UnityEngine; namespace Tests.PlayMode { public class ObjectPoolTests { private GameObject _prefab; [SetUp] public void 准备预制体() { _prefab new GameObject(Pooled); } [TearDown] public void 清理对象() { Object.DestroyImmediate(_prefab); } [Test] public void 对象池_取出后_激活状态为真() { var pool new ObjectPool(_prefab, 2); var obj pool.Get(); Assert.IsTrue(obj.activeSelf); } } }参数化测试用[TestCase]把同一逻辑的多组输入压成一个方法减少重复[TestCase(0f, 100f, 0f)] [TestCase(50f, 100f, 0.5f)] [TestCase(100f, 100f, 1f)] [TestCase(-10f, 100f, 0f)] public void Normalize_多组输入_结果符合预期(float pixel, float total, float expected) { Assert.AreEqual(expected, CoordinateConverter.Normalize(pixel, total), 1e-6f); }参数说明[TestCase]的实参顺序必须和方法签名一致类型要能隐式转换。浮点参数在 TestCase 里写的是字面量实际比较仍要靠容差别指望它帮你处理精度。参数化用例在 Test Runner 面板里会展开成多行失败时能精确定位是哪组数据挂了比写四个方法清爽得多。3.3 命令行与 CI 集成本地跑测试点面板就行但真正有价值的是进 CI。Unity 提供命令行入口Unity -batchmode -projectPath /path/to/project \ -runTests -testPlatform EditMode \ -testResults /path/to/results.xml \ -logFile /path/to/unity.log参数含义-batchmode无界面运行-runTests触发测试-testPlatform可选EditMode或PlayMode-testResults输出 NUnit 格式的 XMLCI 平台可以直接解析。注意-quit不要和-runTests一起用测试跑完 Unity 会自己退出手动加-quit有时会导致结果文件没写完就退出这是踩过的坑。返回码 0 表示全过2 表示有用例失败CI 里判断这个码就行。4. 避坑与排查五个真实翻车记录4.1 测试面板里看不到任何用例现象asmdef 配好了Test Runner 面板两个标签页都是空的。原因通常是 asmdef 的includePlatforms设置和测试类型不匹配比如 EditMode 测试没加Editor平台限制或者 PlayMode 测试错误地限制了平台。解决EditMode 必须includePlatforms: [Editor]PlayMode 留空同时确认defineConstraints里有UNITY_INCLUDE_TESTS。改完让 Unity 重新编译一次。4.2 打包后包体里混进了测试代码现象正式包体积异常增大反编译能看到 NUnit 相关符号。原因是 asmdef 缺少defineConstraints或者测试程序集被业务程序集反向引用了。解决所有测试 asmdef 必须带UNITY_INCLUDE_TESTS约束业务 asmdef 绝对不能引用测试程序集。用Assembly Definition References面板检查一遍引用方向单向依赖业务不依赖测试。4.3 PlayMode 测试偶发失败重跑就好现象CI 上 PlayMode 用例时好时坏本地却稳定通过。原因是 PlayMode 依赖帧循环和物理步进用了yield return null等待但没等够帧数或者物理查询在FixedUpdate之外调用。解决协程测试用yield return new WaitForFixedUpdate()或明确等待若干帧物理相关断言放在固定步进之后。时间相关的逻辑用Time.timeScale控制时要注意测试里改完记得在TearDown恢复否则污染后续用例。4.4 浮点断言用等号导致随机失败现象Assert.AreEqual(0.3f, result)偶尔失败。原因是浮点累加误差0.1f 0.2f不等于0.3f。解决所有浮点断言带容差Assert.AreEqual(expected, actual, 1e-5f)。涉及向量比较用Vector3.Distance小于阈值别逐分量比。这个坑新手几乎必踩写进团队规范里。4.5 测试之间互相污染状态现象单个跑都过一起跑就挂。原因是静态变量、单例、ScriptableObject状态没在TearDown里重置。解决每个测试类负责清理自己碰过的全局状态单例在SetUp里重建、TearDown里置空。EditMode 下静态字段尤其危险因为它不随播放模式退出而重置。我一般会写一个基类统一处理这类清理。5. 进阶技巧用测试驱动逻辑层重构与性能兜底写到这儿基础链路已经通了。最后分享一个我实际项目里用得最多的进阶玩法把单元测试当成重构的安全网同时给性能敏感逻辑加一道兜底断言。做数字孪生那类项目时逻辑层经常要重构比如把原来散在MonoBehaviour里的坐标换算抽成纯类。抽之前先补测试抽完跑一遍全绿就说明行为没变。这个顺序不能反先重构后补测试等于裸奔。我一般的做法是对每个准备动的类先在 EditMode 里写一组覆盖正常路径和边界的用例锁定当前行为再动手改结构。改完如果测试挂了要么是重构引入了 bug要么是原行为本身就有问题两种情况都值得停下来看。性能兜底这块很多人不知道 NUnit 支持[Timeout]和[Performance]属性。对帧率敏感的算法可以加超时断言防止某次改动把 O(n) 写成 O(n²) 还浑然不觉[Test] [Timeout(100)] // 单位毫秒超过即失败 public void 路径计算_千节点规模_百毫秒内完成() { var nodes BuildNodes(1000); var sw System.Diagnostics.Stopwatch.StartNew(); PathSolver.Solve(nodes); sw.Stop(); Assert.Less(sw.ElapsedMilliseconds, 100); }参数说明[Timeout]是硬性中断超时会直接判定失败并终止该用例适合防死循环。Stopwatch手动计时则更灵活能拿到具体耗时做断言。注意 CI 机器性能波动大阈值别卡太死留 2 到 3 倍余量否则会变成新的偶发失败源。再补一个验证方法测试覆盖率。Unity 官方有 Code Coverage 包装上后在 Test Runner 面板能看每个程序集的行覆盖率。我的习惯是逻辑层核心类覆盖率不低于 70%UI 和胶水代码不强求。覆盖率不是目标但低于 50% 说明测试写得有问题要么漏了分支要么测的都是无关紧要的东西。还有一个容易被忽略的点测试命名和分组。用例多了以后用[Category(Math)]给测试打标签命令行里用-testCategory Math只跑某一类本地调试时能省不少时间。CI 上则全量跑两者不冲突。从那以后我每次新建 Unity 工程第一件事就是把 Tests 目录和两个 asmdef 建好再写业务代码。这个习惯帮我省下的排查时间远比搭框架花的那点功夫多。希望帮到你。本文还有配套的精品资源点击获取