尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity单元测试实战:从零集成开源框架NSubstitute与NUnit

Unity单元测试实战:从零集成开源框架NSubstitute与NUnit 1. 项目概述为什么Unity开发者绕不开单元测试如果你刚开始接触Unity开发或者已经做了一两个小项目可能觉得“单元测试”这个词离你很远。不就是写写脚本拖拖组件按个播放键看看效果吗我以前也是这么想的直到我负责的一个看似简单的金币收集系统因为一个队友修改了伤害计算公式导致整个经济系统崩盘我们花了整整两天才从一堆互相纠缠的代码里找到问题根源。那一刻我才明白没有单元测试的代码就像在沙地上盖高楼任何一点风吹草动都可能让一切垮掉。单元测试简单说就是为你写的每一个“单元”通常是一个函数或方法单独编写测试代码验证它在各种输入下是否都能产生预期的输出。在Unity的语境里这个“单元”可能是一个计算伤害的CalculateDamage()方法一个处理玩家库存的Inventory类或者一个管理游戏状态的GameManager。开源单元测试框架就是帮我们自动化、标准化完成这件事的工具集。它不再是大型团队的专利对于独立开发者和小团队来说更是提升代码质量、减少后期调试地狱的“后悔药”。这篇内容就是为你准备的无论你是刚下载Unity的纯新手还是已经能熟练使用Update函数但从未接触过测试的“实战派”。我会带你从“为什么需要测试”开始一步步拆解如何在Unity项目中引入并实际使用开源的单元测试框架把那些听起来高大上的概念变成你项目里一个个可运行、可验证的绿色对勾。我们的目标不是成为测试理论专家而是掌握一种能立刻让我们的开发工作更稳健、更高效的实际技能。2. 核心思路理解Unity测试的“三层架构”在动手写第一行测试代码之前我们必须先理清思路在Unity里做单元测试到底在测什么怎么测很多人一上来就埋头写Assert.AreEqual结果发现连测试都跑不起来。根据我的经验把Unity中的测试按层次和场景分开理解是成功的第一步。2.1 Edit Mode vs Play Mode测试的两个主战场Unity的测试运行器Test Runner将测试分为两大模式这是最根本的区分Edit Mode编辑模式测试顾名思义这些测试在Unity编辑器环境下、不点击播放按钮就能运行。它们不依赖于Unity的运行时环境比如Time.deltaTime、物理引擎更新、MonoBehaviour的生命周期方法Start,Update都不会被执行。你测的是什么是纯粹的、独立的C#逻辑。例如一个负责解析配置文件的静态工具类。一个计算技能伤害或经验值升级的纯算法函数。你的数据模型类如PlayerData、Item的属性和方法。注意Edit Mode测试速度极快因为它们绕过了Unity引擎的初始化。你应该将绝大多数业务逻辑设计成不依赖MonoBehaviour的纯C#类以便在这里进行测试。这是保证测试效率和独立性的关键。Play Mode播放模式测试这些测试需要启动Unity的运行时环境就像你点击了播放按钮。它们用于测试依赖于Unity引擎功能的代码。例如一个需要Rigidbody组件并与其他物体发生碰撞的脚本。一个协程Coroutine的逻辑是否正确。UI按钮的点击事件是否触发了正确的游戏流程。场景加载和资源管理如Addressables的集成逻辑。实操心得不要滥用Play Mode测试。因为它启动慢且受场景状态影响大。一个原则是能用Edit Mode测的绝不用Play Mode。Play Mode测试应该专注于那些真正需要引擎交互的“集成点”。2.2 单元测试、集成测试与“测试双雄”在测试领域我们常听到单元测试、集成测试等术语。在Unity中我们可以这样对应理解单元测试Unit Tests主要对应Edit Mode测试。专注于隔离测试一个最小的代码单元一个函数、一个类。我们会使用“测试替身”如Mock对象来隔离外部依赖如数据库、网络服务。后文会介绍的开源框架NSubstitute就是干这个的利器。集成测试Integration Tests更接近Play Mode测试。测试多个模块组合在一起是否能协同工作。例如测试“拾取物品”这个动作是否同时正确更新了UI背包、播放了音效、并保存了游戏数据。对于Unity开发尤其是新手我建议先从“单元测试”和“Play Mode集成测试”这两个最实用的概念入手。而实现高质量单元测试离不开两位“帮手”Mock框架和断言库。Unity内置的测试运行器提供了基础的断言如Assert.AreEqual但功能比较基础。开源社区为我们提供了更强大的选择比如NSubstitute用于轻松创建Mock对象NUnitUnity Test Framework已基于它提供了更丰富的断言语法。我们的教程核心就是教你如何将这些开源框架优雅地融入到Unity的工作流中。3. 环境搭建与项目初始化理论懂了我们立刻动手。第一步不是写代码而是正确设置你的项目和环境。很多新手在这里卡住就是因为忽略了一些关键的包管理和程序集配置。3.1 启用Unity内置的测试框架Unity从2017版本左右开始将测试功能以“Package”的形式提供这比旧版本稳定和方便得多。打开你的Unity项目建议使用2019.4 LTS或更新版本稳定性有保障按照以下步骤操作打开Package Manager窗口 (Window Package Manager)。在左上角的下拉菜单中选择Unity Registry。在列表中找到Test Framework。确保你安装的是较新的稳定版本如2.0。点击“Install”按钮。安装完成后你会在Window菜单下看到General Test Runner。打开它你会看到一个干净的测试运行器窗口。这是你所有测试的指挥中心。3.2 创建专用的测试程序集这是至关重要的一步也是区分新手和老鸟的一个标志。永远不要将你的测试代码和游戏运行时代码放在同一个程序集Assembly里。原因有二一是为了发布时不会将测试代码打包进游戏二是为了清晰的架构分离。操作步骤如下在Project窗口右键点击你的Assets文件夹选择Create Testing Tests Assembly Folder。Unity会自动创建一个名为Tests的文件夹里面包含一个Tests.asmdef文件程序集定义文件。点击这个Tests.asmdef文件在Inspector窗口中你会看到“Assembly Definition References”列表。我们需要在这里添加对测试框架和可能需要的其他程序集的引用。点击“”号添加以下引用如果列表里没有可以点击“Browse”按钮搜索UnityEngine.TestRunnerNUnit(通常Unity Test Framework会自带)你的游戏主逻辑代码所在的程序集例如如果你有一个GameLogic.asmdef就必须加进来否则测试代码“看不到”要测试的类。踩坑记录我曾经忘记添加对游戏逻辑程序集的引用导致测试类里无法using我的游戏命名空间百思不得其解。记住测试程序集需要显式引用它要测试的对象所在的程序集。3.3 引入强大的开源盟友NSubstituteUnity内置的测试框架已经集成了NUnit对于断言基本够用。但我们还需要一个工具来轻松创建“假对象”Mock/Stub以便在测试中隔离依赖。NSubstitute是我用过最简洁优雅的Mock框架。我们将通过Unity的Package Manager以添加Git URL的方式来安装它这是管理第三方开源库的推荐方式。在Package Manager窗口点击左上角的“”按钮选择Add package from git URL...。在弹出的输入框中粘贴NSubstitute的GitHub仓库URLhttps://github.com/nsubstitute/NSubstitute.git#v4.4.0(这里以4.4.0稳定版为例你可以查看其GitHub releases页面获取最新版本号)。点击“Add”。Unity会开始从Git仓库下载并编译该库。安装成功后你可以在Package Manager的“My Registries”或“In Project”列表中看到NSubstitute。现在你的测试项目就拥有了创建Mock对象的超能力。4. 编写你的第一个Edit Mode单元测试环境齐备让我们从一个最简单的例子开始感受一下测试驱动的节奏。假设我们有一个游戏里面有一个计算伤害的静态服务类。4.1 创建被测系统与测试文件首先在游戏逻辑代码区域例如Assets/Scripts/Combat下创建一个被测试的类DamageCalculator// DamageCalculator.cs namespace MyGame.Combat { public static class DamageCalculator { public static int CalculateBaseDamage(int attackerAttack, int defenderDefense) { if (attackerAttack 0 || defenderDefense 0) return 0; int damage attackerAttack * 2 - defenderDefense; return Mathf.Max(damage, 1); // 保证至少造成1点伤害 } } }接着在之前创建的Tests文件夹下右键Create Testing C# Test Script将其命名为DamageCalculatorTests。Unity会自动生成一个测试类的模板。4.2 解剖一个标准测试方法打开DamageCalculatorTests.cs让我们把它改造成一个真正的测试using NUnit.Framework; // 使用NUnit框架 using MyGame.Combat; // 引用被测试的命名空间 using UnityEngine; namespace MyGame.Tests.EditMode // 建议用.Tests.EditMode子命名空间区分 { public class DamageCalculatorTests { // 使用[Test]特性标记这是一个测试方法 [Test] public void CalculateBaseDamage_WhenAttackIsStronger_ReturnsPositiveDamage() { // Arrange (准备): 设置测试数据 int attack 10; int defense 5; int expectedDamage 15; // 10*2 - 5 15 // Act (执行): 调用被测试的方法 int actualDamage DamageCalculator.CalculateBaseDamage(attack, defense); // Assert (断言): 验证结果是否符合预期 Assert.AreEqual(expectedDamage, actualDamage, 基础伤害计算不正确); } [Test] public void CalculateBaseDamage_WhenDefenseVeryHigh_ReturnsMinimumDamage() { // Arrange int attack 10; int defense 100; // 防御远高于攻击 int expectedMinDamage 1; // Act int actualDamage DamageCalculator.CalculateBaseDamage(attack, defense); // Assert Assert.AreEqual(expectedMinDamage, actualDamage, 高防御下未返回最小伤害1点); } [Test] public void CalculateBaseDamage_WhenAttackIsZeroOrNegative_ReturnsZero() { // Arrange Act Assert for zero attack Assert.AreEqual(0, DamageCalculator.CalculateBaseDamage(0, 5)); // Arrange Act Assert for negative attack Assert.AreEqual(0, DamageCalculator.CalculateBaseDamage(-5, 5)); } } }代码解读与核心技巧测试方法命名我采用了被测方法名_测试场景_预期结果的命名约定如CalculateBaseDamage_WhenAttackIsZeroOrNegative_ReturnsZero。这能让测试报告一目了然当测试失败时你立刻就知道是哪个场景出了问题。Arrange-Act-Assert模式这是编写单元测试的黄金法则。严格遵循这三个阶段能让你的测试代码结构清晰易于维护。有意义的断言信息Assert.AreEqual的第三个参数是一个可选的错误信息。花几秒钟写一个清晰的描述如“高防御下未返回最小伤害1点”能在测试失败时为你节省大量排查时间。4.3 运行与查看结果回到Test Runner窗口。确保顶部选择了EditMode标签页。你应该能看到一个树状结构展开后找到MyGame.Tests.EditMode.DamageCalculatorTests下面列出了我们刚写的三个测试方法。点击Run All按钮或者勾选整个测试类旁边的复选框再点击运行。稍等片刻你会看到所有测试方法旁边都变成了绿色的对勾并且输出面板可能会有简单的日志。恭喜你你成功完成了第一次单元测试实操心得养成“红-绿-重构”的节奏。先写一个测试此时它可能因为功能没实现而失败-红色然后实现最简单的代码让测试通过绿色最后再优化代码结构重构同时保证测试始终是绿色的。这个循环能极大地提升代码质量。5. 使用NSubstitute进行Mock与依赖隔离现实中的类很少像DamageCalculator这样是静态且无状态的。大多数类都依赖其他服务比如一个PlayerController可能依赖IInputService获取输入依赖IAudioService播放声音。单元测试要求我们隔离这些依赖只测试当前类的逻辑。这就是Mock框架的用武之地。5.1 场景引入一个依赖服务的奖励系统假设我们有一个RewardManager它负责发放奖励并需要记录日志和播放音效。// IRewardService.cs - 奖励发放服务接口 namespace MyGame.Services { public interface IRewardService { bool GrantReward(string rewardId, int amount); } } // ILogger.cs - 日志接口 namespace MyGame.Utility { public interface ILogger { void LogInfo(string message); void LogError(string message); } } // RewardManager.cs - 被测试的类 namespace MyGame.Managers { public class RewardManager { private readonly IRewardService _rewardService; private readonly ILogger _logger; // 通过构造函数注入依赖依赖注入DI public RewardManager(IRewardService rewardService, ILogger logger) { _rewardService rewardService; _logger logger; } public bool TryClaimReward(string rewardId, int amount) { _logger.LogInfo($尝试领取奖励{rewardId}, 数量{amount}); if (string.IsNullOrEmpty(rewardId) || amount 0) { _logger.LogError($无效的奖励参数ID{rewardId}, Amount{amount}); return false; } bool success _rewardService.GrantReward(rewardId, amount); if (success) _logger.LogInfo($成功领取奖励{rewardId}); else _logger.LogError($领取奖励失败{rewardId}); return success; } } }5.2 使用NSubstitute创建并配置Mock对象现在我们要测试RewardManager.TryClaimReward的逻辑但不希望真的去调用可能连接数据库的IRewardService也不想在测试时输出杂乱的日志。我们需要Mock模拟这两个依赖。在测试文件中首先确保引用了NSubstituteusing NSubstitute;。using NUnit.Framework; using NSubstitute; using MyGame.Managers; using MyGame.Services; using MyGame.Utility; namespace MyGame.Tests.EditMode { public class RewardManagerTests { private IRewardService _mockRewardService; private ILogger _mockLogger; private RewardManager _rewardManager; // [SetUp]特性标记的方法会在每个测试运行前执行 [SetUp] public void SetUp() { // 为每个测试创建全新的、干净的Mock对象 _mockRewardService Substitute.ForIRewardService(); _mockLogger Substitute.ForILogger(); // 使用Mock对象构造被测试的类 _rewardManager new RewardManager(_mockRewardService, _mockLogger); } [Test] public void TryClaimReward_WithValidParameters_ReturnsTrueAndLogsSuccess() { // Arrange string validRewardId COIN_100; int validAmount 100; // 配置Mock对象的行为当GrantReward被调用时返回true _mockRewardService.GrantReward(validRewardId, validAmount).Returns(true); // Act bool result _rewardManager.TryClaimReward(validRewardId, validAmount); // Assert Assert.IsTrue(result); // 验证返回值为真 // 验证Logger的LogInfo被以特定参数调用了一次 _mockLogger.Received(1).LogInfo($尝试领取奖励{validRewardId}, 数量{validAmount}); _mockLogger.Received(1).LogInfo($成功领取奖励{validRewardId}); // 验证LogError从未被调用 _mockLogger.DidNotReceive().LogError(Arg.Anystring()); } [Test] public void TryClaimReward_WithInvalidRewardId_ReturnsFalseAndLogsError() { // Arrange string invalidRewardId ; int validAmount 100; // Act bool result _rewardManager.TryClaimReward(invalidRewardId, validAmount); // Assert Assert.IsFalse(result); // 验证GrantReward服务根本不会被调用 _mockRewardService.DidNotReceive().GrantReward(Arg.Anystring(), Arg.Anyint()); // 验证错误日志被调用 _mockLogger.Received(1).LogError($无效的奖励参数ID{invalidRewardId}, Amount{validAmount}); } [Test] public void TryClaimReward_WhenServiceFails_ReturnsFalseAndLogsError() { // Arrange string validRewardId COIN_100; int validAmount 100; // 配置服务调用失败 _mockRewardService.GrantReward(validRewardId, validAmount).Returns(false); // Act bool result _rewardManager.TryClaimReward(validRewardId, validAmount); // Assert Assert.IsFalse(result); // 验证服务被调用了一次 _mockRewardService.Received(1).GrantReward(validRewardId, validAmount); // 验证错误日志被记录 _mockLogger.Received(1).LogError($领取奖励失败{validRewardId}); } } }NSubstitute核心语法解析Substitute.ForT()创建接口或类的Mock/Substitute对象。.Returns(value)配置一个方法或属性的返回值。Received(count)断言一个方法被调用了特定的次数。Received(1)表示调用一次DidNotReceive()表示从未调用。Arg.AnyT()一个参数匹配器表示“任何T类型的值”。在验证调用时非常有用。Arg.IsT(predicate)更高级的匹配器例如Arg.Isstring(s s.Contains(“error”))。注意事项Mock对象在[SetUp]中初始化确保每个测试都是独立的。这是单元测试的隔离性原则一个测试的失败不应该影响另一个测试。6. 进阶Play Mode测试与协程/异步测试当你的逻辑涉及到Unity的生命周期、协程、物理或UI时就需要Play Mode测试了。它的编写方式与Edit Mode类似但需要一些特殊的处理。6.1 创建Play Mode测试程序集为了更好的分离建议为Play Mode测试创建独立的程序集。在Assets下创建PlayModeTests文件夹。在该文件夹内右键Create Assembly Definition命名为PlayModeTests。选中这个新的.asmdef文件在Inspector中勾选Test Assemblies下的Play Mode。同时添加对游戏逻辑程序集和UnityEngine.TestRunner、NUnit的引用。6.2 测试一个简单的协程逻辑假设我们有一个CountdownTimer组件它使用协程进行倒计时。// CountdownTimer.cs using UnityEngine; using System.Collections; namespace MyGame.Gameplay { public class CountdownTimer : MonoBehaviour { public float Duration { get; private set; } public bool IsRunning { get; private set; } public float TimeRemaining { get; private set; } public void StartCountdown(float duration) { if (IsRunning) return; Duration duration; TimeRemaining duration; StartCoroutine(CountdownRoutine()); } private IEnumerator CountdownRoutine() { IsRunning true; while (TimeRemaining 0) { yield return null; // 等待一帧 TimeRemaining - Time.deltaTime; } TimeRemaining 0; IsRunning false; Debug.Log(倒计时结束); } } }测试这个组件我们需要在Play Mode下创建一个GameObject并挂载它。using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; // 需要这个命名空间来使用UnityTest特性 using System.Collections; using MyGame.Gameplay; namespace MyGame.Tests.PlayMode { public class CountdownTimerTests { private GameObject _testGameObject; private CountdownTimer _timer; // [SetUp]在Play Mode中也会在每个测试前运行 [SetUp] public void SetUp() { _testGameObject new GameObject(TestTimer); _timer _testGameObject.AddComponentCountdownTimer(); } // [TearDown]在每个测试后运行用于清理 [TearDown] public void TearDown() { Object.Destroy(_testGameObject); } // 关键[UnityTest]特性允许返回IEnumeratorUnity会将其作为协程执行 [UnityTest] public IEnumerator CountdownRoutine_CompletesAfterDuration() { // Arrange float testDuration 0.5f; // 用较短时间测试 float tolerance 0.05f; // 时间容忍度 // Act _timer.StartCountdown(testDuration); float startTime Time.time; // 使用yield return new WaitForSeconds等待但最好用条件循环 // 等待直到计时器结束 while (_timer.IsRunning) { yield return null; // 每帧检查一次 } float elapsedTime Time.time - startTime; // Assert Assert.IsFalse(_timer.IsRunning); Assert.AreEqual(0, _timer.TimeRemaining, tolerance); // 允许微小误差 Assert.AreEqual(testDuration, _timer.Duration); // 验证实际经过的时间接近设定的时长 Assert.AreEqual(testDuration, elapsedTime, tolerance); } [UnityTest] public IEnumerator StartCountdown_WhileAlreadyRunning_DoesNotRestart() { // Arrange float firstDuration 1.0f; float secondDuration 2.0f; _timer.StartCountdown(firstDuration); yield return null; // 确保协程已启动 // Act - 尝试在运行时再次启动 _timer.StartCountdown(secondDuration); // Assert - 持续时间应该还是第一次设定的值 Assert.AreEqual(firstDuration, _timer.Duration); // 我们可以快速等待一个极短时间确认TimeRemaining仍在减少未重置 float remainingAfterCall _timer.TimeRemaining; yield return new WaitForSeconds(0.01f); Assert.Less(_timer.TimeRemaining, remainingAfterCall); } } }Play Mode测试要点[UnityTest]与IEnumerator这是测试协程或需要帧更新的逻辑的标准方式。测试方法本身就是一个协程。WaitForSeconds的谨慎使用在测试中直接yield return new WaitForSeconds(1)会让测试硬等待1秒拖慢测试速度。更好的做法是使用while循环配合yield return null来等待某个条件达成。对象生命周期管理必须在[SetUp]中创建测试用的GameObject和组件在[TearDown]中销毁它们。否则测试结束后这些对象会残留在场景中影响后续测试。时间容忍度由于帧率波动基于Time.deltaTime的计时不可能100%精确。在断言浮点数相等时使用带有delta参数的重载如Assert.AreEqual(expected, actual, tolerance)。7. 测试策略、常见陷阱与持续集成掌握了基本写法后如何将测试融入日常开发如何避免常见的坑这是让测试发挥最大价值的关键。7.1 单元测试的FIRST原则与测试策略好的单元测试应该遵循FIRST原则Fast快速测试应该能在几毫秒内完成。避免文件I/O、网络请求、复杂的数据库操作。Independent独立测试之间不应该有依赖可以以任何顺序运行。这就是为什么我们要用[SetUp]和[TearDown]来保证每个测试的纯净环境。Repeatable可重复在任何环境你的机器、队友的机器、CI服务器上运行都应该得到相同的结果。避免依赖随机数或未初始化的全局状态。Self-Validating自我验证测试的结果应该是二元的——通过或失败不需要人工去检查日志或输出。Timely及时理想情况下测试代码应该与生产代码同时编写测试驱动开发TDD。测试策略建议测试公共接口而非私有实现只测试类对外暴露的方法和属性。私有方法通常通过公共方法来间接测试。如果发现一个私有方法复杂到需要单独测试考虑将其提取到一个新的公共类中。关注行为而非实现细节测试“这个方法做了什么”而不是“这个方法内部是怎么做的”。这样即使你重构了内部代码只要输入输出行为不变测试就不需要修改。使用测试金字塔编写大量小而快的单元测试金字塔底层适量集成测试中层少量端到端E2E或Play Mode测试顶层。这样反馈最快维护成本最低。7.2 常见陷阱与排查技巧即使经验丰富的开发者也会在测试中踩坑。下面是一个常见问题速查表问题现象可能原因排查与解决测试在Test Runner中不显示1. 测试类或方法没有[Test]/[UnityTest]特性。2. 测试类不是public的。3. 测试脚本不在标记为“Test Assemblies”的程序集中。1. 检查特性标注。2. 确保类是public class。3. 在.asmdef文件的Inspector中勾选“Test Assemblies”下的对应模式。NullReferenceException1. 在测试中访问了未初始化的MonoBehaviour组件如未调用Awake/Start。2. Mock对象没有配置返回值而测试代码试图访问其属性或方法。1. 对于Play Mode测试确保在[SetUp]中完成了GameObject的实例化和组件添加。2. 使用NSubstitute时对于需要返回值的方法务必使用.Returns()进行配置。对于void方法或仅需验证调用的方法则不需要。测试结果不稳定有时过有时不过1. 测试间存在状态共享如静态变量。2. 使用了随机数或依赖系统时间。3. Play Mode测试中时间判断过于严格。1. 在[SetUp]/[TearDown]中重置所有静态状态。2. 注入随机数生成器或时间提供者接口在测试中Mock它们。3. 为浮点数断言增加合理的容忍度delta。NSubstitute的Received()断言失败1. 方法根本没有被调用。2. 方法被调用了但参数不匹配。Received()默认进行精确匹配。1. 检查你的逻辑路径确保执行到了调用处。2. 使用参数匹配器Arg.AnyT()或Arg.IsT(...)来放宽匹配条件。例如_mockService.Received(1).SomeMethod(Arg.Anystring(), Arg.Isint(x x 0))。Play Mode测试超时或卡住1. 协程陷入无限循环条件永远不满足。2. 在测试中使用了while(true)或等待一个永远不会发生的事件。1. 在等待循环中加入超时机制。例如float timeout Time.time 5f; while (condition Time.time timeout) { yield return null; } Assert.IsTrue(condition, “超时条件未满足”);。2. 仔细检查循环结束条件。7.3 将测试集成到工作流与CI中测试不应该只是你本地运行的东西。将其集成到版本控制如Git和持续集成CI流程中才能为团队保驾护航。版本控制将Tests文件夹和所有.asmdef文件纳入版本控制。确保.meta文件也被提交。这样所有团队成员都能运行同一套测试。命令行运行测试Unity提供了通过命令行或CI脚本运行测试并生成报告的能力。这对于自动化构建至关重要。# 基本命令示例 Unity.exe -batchmode -runTests -projectPath [项目路径] -testResults [结果文件路径].xml -testPlatform [平台]-batchmode: 无头模式不显示图形界面。-runTests: 执行测试。-testPlatform editmode或-testPlatform playmode: 指定测试模式。-testResults: 指定JUnit格式的XML结果输出路径CI服务器如Jenkins, GitLab CI可以解析此报告。在CI中配置在你的CI服务器上配置一个Job在每次代码推送或合并请求时执行上述命令行来运行Edit Mode测试因为它最快。可以在每晚构建时运行更耗时的Play Mode测试。如果任何测试失败CI应标记构建为失败并通知开发者。从我个人的经验来看引入单元测试的初期可能会觉得拖慢了开发速度但一旦形成习惯它带来的信心和节省的调试时间是无法估量的。尤其是当项目规模扩大或者你需要重构一段陈年旧代码时有一套可靠的测试套件在旁边守护那种安全感会让你觉得之前的所有投入都是值得的。开始可以从一个小的、独立的工具类写起慢慢扩展到核心的游戏系统你会发现代码的设计不知不觉中变得更清晰、更模块化了因为为了便于测试你自然会写出依赖更少、职责更单一的代码。这或许是单元测试带来的比“发现Bug”更深层的价值。
返回列表