mcpField
U
unity-testing

unity-testing

github.com/nice-wolf-studio测试与质量4085 次安装6 周前更新
代码仓库

关于 unity-testing

unity-testing 是 Unity 内置测试框架的参考技能,面向 Unity 开发者。它介绍 Edit Mode 与 Play Mode 测试、测试模式,以及用于自动验证的命令行执行。

主要功能

  1. 01Edit Mode 与 Play Mode 测试
  2. 02NUnit 和 Unity 专用属性
  3. 03协程与异步测试模式
  4. 04Test Runner 与命令行执行
  5. 05输出 NUnit XML 测试结果

适用场景

  1. 01在 Edit Mode 测试纯 C# 逻辑
  2. 02在 Play Mode 验证游戏玩法和物理
  3. 03在 CI/CD 流水线运行测试
  4. 04导出 NUnit XML 测试结果

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/nice-wolf-studio/unity-claude-skills --skill unity-testing

安装后在 Claude Code 中输入/unity-testing确认已加载。

附带文件references/test-framework.md

详细介绍

Unity Test Framework

测试框架概述

Unity Test Framework(UTF)是 Unity 内置的测试解决方案,集成了为 Unity 调整的 NUnit 自定义版本(基于 NUnit 3.5)。它支持在 Edit Mode、Play Mode 和 目标平台(Standalone、Android、iOS)上测试 C# 代码。

UTF 通过 Unity 专用特性([UnityTest]、[UnitySetUp]、[UnityTearDown])扩展 NUnit,支持协程、yield 指令和基于帧的执行。

主要功能:

  • 用于编辑器扩展和纯逻辑的 Edit Mode 测试
  • 用于游戏玩法、物理和基于协程的代码的 Play Mode 测试
  • 用于平台专属验证的 Standalone 玩家测试构建
  • 用于 CI/CD 流水线的命令行执行
  • NUnit XML 结果输出

来源: Unity Test Framework 2.0 手册


设置和配置

安装测试框架

UTF 默认随 Unity 一同提供。通过 Window > General > Test Runner 打开 Test Runner。

创建测试程序集

测试代码必须放在自己的程序集定义文件(.asmdef)中,并引用 NUnit。

可通过 Window > General > Test Runner(点击 Create a new Test Assembly Folder)或 Assets > Create > Testing > Test Assembly Folder 创建。

这会创建一个 Tests 文件夹,其中包含一个预先配置了以下引用的 .asmdef 文件:

  • nunit.framework.dll
  • UnityEngine.TestRunner
  • UnityEditor.TestRunner(仅限 Edit Mode)
Edit Mode 程序集定义(.asmdef)
{
    "name": "Tests",
    "references": [
        "UnityEngine.TestRunner",
        "UnityEditor.TestRunner"
    ],
    "includePlatforms": ["Editor"],
    "defineConstraints": ["UNITY_INCLUDE_TESTS"],
    "autoReferenced": false
}
Play Mode 程序集定义(.asmdef)
{
    "name": "Tests.PlayMode",
    "references": [
        "UnityEngine.TestRunner",
        "UnityEditor.TestRunner",
        "MyGameAssembly"
    ],
    "includePlatforms": [],
    "optionalUnityReferences": ["TestAssemblies"],
    "defineConstraints": ["UNITY_INCLUDE_TESTS"],
    "autoReferenced": false
}

重要: 要测试游戏代码,请在 references 数组中添加对包含待测代码的程序集的引用。

来源: 创建测试程序集


Edit Mode 与 Play Mode 测试

方面Edit ModePlay Mode
执行上下文EditorApplication.update 回调循环MonoBehaviour 上的协程
编辑器访问权限完整的 Editor 和游戏代码仅游戏代码(除非在 Editor 中)
协程支持yield return null 会跳过一次更新完整的 yield 指令(WaitForSeconds 等)
平台目标仅限 EditorEditor Play Mode 和独立玩家
适用场景编辑器扩展、纯逻辑、序列化物理、游戏玩法、MonoBehaviour 生命周期
程序集平台"includePlatforms": ["Editor"]"includePlatforms": []

适合使用 Edit Mode 的场景:

  • 测试编辑器工具、自定义检查器、ScriptableObject 逻辑
  • 不依赖运行时系统的纯 C# 逻辑
  • 需要访问 UnityEditor API 的测试

适合使用 Play Mode 的场景:

  • 测试 MonoBehaviour 生命周期(Awake、Start、Update)
  • 需要 WaitForFixedUpdate 的物理交互
  • 协程和异步工作流
  • 运行时的场景加载和对象实例化

最佳实践: 除非需要 yield 指令,否则请使用 NUnit [Test],而不是 [UnityTest]。[Test] 运行速度更快,因为它不需要协程开销。

来源: Edit Mode 与 Play Mode 测试


编写测试

通过 Assets > Create > Testing > C# Test Script 或点击 Test Runner 中的 Create a new Test Script 按钮创建测试脚本。

基本测试结构
using NUnit.Framework;

[TestFixture]
public class HealthSystemTests
{
    private HealthSystem _health;

    [SetUp]
    public void SetUp() => _health = new HealthSystem(maxHealth: 100);

    [TearDown]
    public void TearDown() => _health = null;

    [Test]
    public void TakeDamage_ReducesHealth()
    {
        _health.TakeDamage(30);
        Assert.AreEqual(70, _health.CurrentHealth);
    }

    [Test]
    public void TakeDamage_CannotGoBelowZero()
    {
        _health.TakeDamage(150);
        Assert.AreEqual(0, _health.CurrentHealth);
    }

    [Test]
    public void Heal_IncreasesHealth()
    {
        _health.TakeDamage(50);
        _health.Heal(20);
        Assert.AreEqual(70, _health.CurrentHealth);
    }
}
NUnit 特性参考
特性用途
[Test]将方法标记为同步测试
[TestFixture]将类标记为包含测试
[SetUp]在每个测试方法运行前执行
[TearDown]在每个测试方法运行后执行
[OneTimeSetUp]在测试夹具中的所有测试运行前执行一次
[OneTimeTearDown]在测试夹具中的所有测试运行后执行一次
[TestCase(args)]使用内联数据的参数化测试
[Values(args)]为测试参数提供值
[Category("name")]对测试进行分类,以便筛选
[Ignore("reason")]跳过测试并说明原因
[Timeout(ms)]设置测试超时时间
Unity 专用特性
特性用途
[UnityTest]基于协程的测试,支持 yield 指令
[UnitySetUp]基于协程的测试设置(支持 yield)
[UnityTearDown]基于协程的测试清理(支持 yield)
[RequiresPlayMode]强制测试在 Play Mode(true)或 Edit Mode(false)下运行
[UnityPlatform]限制测试只能在指定平台运行
[TestMustExpectAllLogs]要求所有日志消息都必须是预期的
[PrebuildSetup]在构建播放器前执行设置
[PostBuildCleanup]在构建播放器后执行清理
[ConditionalIgnore]有条件地忽略测试
[TestRunCallback]订阅测试进度更新
常见断言
Assert.AreEqual(expected, actual);           // Equality
Assert.IsTrue(condition);                    // Boolean
Assert.IsNotNull(obj);                       // Null check
Assert.Throws<ArgumentException>(() => F()); // Exception
Assert.AreEqual(1.0f, actual, 0.01f);       // Float tolerance
StringAssert.Contains("sub", actualString);  // String
CollectionAssert.Contains(list, item);       // Collection

完整的断言 API 参考请参阅 references/test-framework.md。

来源: 创建测试


测试 MonoBehaviour

using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using System.Collections;

[TestFixture]
public class PlayerControllerTests
{
    private GameObject _playerObject;
    private PlayerController _player;

    [UnitySetUp]
    public IEnumerator SetUp()
    {
        _playerObject = new GameObject("Player");
        _player = _playerObject.AddComponent<PlayerController>();
        yield return null; // Wait one frame for Awake/Start
    }

    [UnityTearDown]
    public IEnumerator TearDown()
    {
        Object.Destroy(_playerObject);
        yield return null; // Wait for destruction
    }

    [UnityTest]
    public IEnumerator Player_MovesForward_WhenInputApplied()
    {
        var startPos = _player.transform.position;
        _player.Move(Vector3.forward);

        yield return new WaitForFixedUpdate();

        Assert.Greater(_player.transform.position.z, startPos.z);
    }
}

测试协程和异步操作

Edit Mode UnityTest(在 EditorApplication.update 中 yield)
[UnityTest]
public IEnumerator EditorUtility_WhenExecuted_ReturnsSuccess()
{
    var utility = RunEditorUtilityInTheBackground();
    while (utility.isRunning) { yield return null; }
    Assert.IsTrue(utility.isSuccess);
}
Play Mode UnityTest(基于协程)
[UnityTest]
public IEnumerator GameObject_WithRigidBody_WillBeAffectedByPhysics()
{
    var go = new GameObject();
    go.AddComponent<Rigidbody>();
    var originalPosition = go.transform.position.y;
    yield return new WaitForFixedUpdate();
    Assert.AreNotEqual(originalPosition, go.transform.position.y);
}
可用的 yield 指令(Play Mode)
Yield 指令效果
yield return null跳过一帧
yield return new WaitForSeconds(t)等待 t 秒(缩放时间)
yield return new WaitForSecondsRealtime(t)等待 t 秒的实时
yield return new WaitForFixedUpdate()等待下一次物理更新
yield return new WaitForEndOfFrame()等待帧结束
yield return new WaitUntil(() => cond)等待条件变为 true
yield return new WaitWhile(() => cond)在条件为 true 时等待
使用 LogAssert 进行测试
[Test]
public void LogWarning_IsExpected()
{
    LogAssert.Expect(LogType.Warning, "Expected warning message");
    Debug.LogWarning("Expected warning message");
}

使用 LogAssert.NoUnexpectedReceived() 断言没有发出意外的日志消息。除非这些消息是预期的,否则 Debug.LogError 和 Debug.LogException 会导致测试自动失败。


常见测试模式

Arrange-Act-Assert(AAA)
[Test]
public void Inventory_AddItem_IncreasesCount()
{
    // Arrange
    var inventory = new Inventory(maxSlots: 10);
    var item = new Item("Sword");

    // Act
    inventory.Add(item);

    // Assert
    Assert.AreEqual(1, inventory.Count);
}
参数化测试
[TestCase(100, 30, 70)]
[TestCase(100, 100, 0)]
[TestCase(100, 150, 0)]
[TestCase(50, 10, 40)]
public void TakeDamage_CalculatesCorrectly(int maxHp, int damage, int expectedHp)
{
    var health = new HealthSystem(maxHp);
    health.TakeDamage(damage);

    Assert.AreEqual(expectedHp, health.CurrentHealth);
}
测试异常
[Test]
public void Inventory_AddNull_ThrowsException()
{
    var inventory = new Inventory(maxSlots: 10);

    Assert.Throws<ArgumentNullException>(() =>
    {
        inventory.Add(null);
    });
}
基于场景的测试
[UnityTest]
public IEnumerator Scene_Loads_Successfully()
{
    SceneManager.LoadScene("GameScene", LoadSceneMode.Single);
    yield return new WaitUntil(() => SceneManager.GetActiveScene().name == "GameScene");
    Assert.IsTrue(SceneManager.GetActiveScene().isLoaded);
}
使用伪对象进行测试(依赖注入)
public class FakeAudioService : IAudioService
{
    public bool PlaySoundCalled { get; private set; }
    public void PlaySound(string name) => PlaySoundCalled = true;
}

[Test]
public void Attack_PlaysSound()
{
    var fakeAudio = new FakeAudioService();
    var weapon = new Weapon(fakeAudio);
    weapon.Attack();
    Assert.IsTrue(fakeAudio.PlaySoundCalled);
}

运行测试

从 Test Runner 窗口运行

打开 Window > General > Test Runner。切换 EditMode / PlayMode 复选框。通过 Run All、Run Selected、双击或右键上下文菜单运行测试。使用搜索框或结果图标按钮进行筛选。

从命令行运行(CI/CD)
# Run Edit Mode tests
Unity -runTests -batchmode -projectPath /path/to/project \
  -testPlatform EditMode -testResults /path/to/results.xml

# Run Play Mode tests
Unity -runTests -batchmode -projectPath /path/to/project \
  -testPlatform PlayMode -testResults /path/to/results.xml

# Run filtered tests by name and category
Unity -runTests -batchmode -projectPath /path/to/project \
  -testFilter "HealthSystemTests" -testCategory "Unit;Integration" \
  -testResults /path/to/results.xml
命令行参数参考
参数描述
-runTests运行测试所需的标志
-testPlatformEditMode、PlayMode 或任意 BuildTarget 值
-testFilter以分号分隔的测试名称或正则表达式模式;添加前缀 ! 可取反
-testCategory以分号分隔的类别名称;添加前缀 ! 可取反
-testResultsNUnit XML 结果的输出路径(默认:项目根目录)
-testNamesFixtureName.TestName 格式的完整测试名称
-assemblyNames按指定的测试程序集名称筛选
-batchmode无界面运行(CI/CD 必需)
-runSynchronously在一次编辑器更新中执行所有测试(仅限 EditMode)
-playerHeartbeatTimeout等待播放器心跳的秒数(默认:600)
IDE 集成

JetBrains Rider 支持直接从 IDE 运行 Unity 测试,并提供完整的调试支持。

来源: 运行测试、命令行参考


反模式

1. 在 [Test] 足以满足需求时使用 [UnityTest]

[UnityTest] 会带来协程开销。对于同步逻辑,请使用 [Test]。

// BAD - unnecessary coroutine overhead
[UnityTest]
public IEnumerator Add_ReturnsSum()
{
    var result = Calculator.Add(2, 3);
    yield return null;
    Assert.AreEqual(5, result);
}

// GOOD - simple synchronous test
[Test]
public void Add_ReturnsSum()
{
    var result = Calculator.Add(2, 3);
    Assert.AreEqual(5, result);
}
2. 未清理 GameObjects

泄漏的对象会污染其他测试。务必在 [UnityTearDown] 或 [TearDown] 中销毁 GameObjects。在 setup 中创建对象,在 teardown 中销毁对象。

3. 直接测试私有方法

通过公共接口进行测试。如果必须直接测试,请使用 [assembly: InternalsVisibleTo("Tests")]。

4. 硬编码基于时间的等待

请使用 WaitUntil 或 WaitForFixedUpdate,而不是随意指定 WaitForSeconds。

// BAD - brittle, slow
yield return new WaitForSeconds(5f);
Assert.IsTrue(enemy.IsDead);

// GOOD - deterministic
yield return new WaitUntil(() => enemy.IsDead);
Assert.IsTrue(enemy.IsDead);
5. 测试依赖执行顺序

每个测试都必须相互独立。使用 [SetUp] / [TearDown] 来建立干净的状态。

6. 忽略日志错误

意外的 Debug.LogError 调用默认会导致测试失败。对于预期的错误,请使用 LogAssert.Expect();只有在必要时才使用 LogAssert.ignoreFailingMessages = true。

7. 将测试放在生产程序集内

测试程序集使用 UNITY_INCLUDE_TESTS 定义约束,并会从生产构建中排除。切勿将测试代码与游戏代码程序集混在一起。


相关技能

  • unity-scripting -- C# 脚本基础知识、MonoBehaviour 生命周期、协程
  • unity-editor-tools -- 自定义编辑器、检查器和编辑器扩展

其他资源

评论

支持 Markdown