1. 为什么我会把数值测试单独拎出来讲
1.1 一次线上事故让我开始认真对待数值测试
事情发生在某个手游版本的平衡性调整里。策划把某个技能的基础伤害从120改成了135,数值表在配置后台轻轻一点,客户端同步后上架。结果第二天玩家社区就炸了,有人贴出伤害截图,说实际伤害比配置高了8%,怀疑暗改。排查到最后,问题不是策划配错,而是技能伤害公式里的一个乘算节点用了整数除法,baseDamage * coefficient / 100 在系数小于100时被截断成了0,导致后续加成全乱。更尴尬的是,当时项目里没有一条测试能拦住这种问题。
从那天起我开始意识到,Unity项目里的"数值测试",和一般意义上的"单元测试"不完全是一回事。单元测试更多是在验证"这段逻辑跑对了没有",而数值测试要回答的问题是"这组数值在各种输入、随机、边界、配置组合下,是否符合预期"。后者在很大程度上是数据驱动和统计驱动的,恰恰是游戏项目里最容易出事、又最容易被忽视的部分。Unity TestFramework(以下简称UTF)提供的基础设施,刚好能把这些验证固化下来。
1.2 数值测试和逻辑单元测试的差异
很多Unity开发者对单元测试的印象还停留在"写几个断言,保证函数不报错"。这类测试当然有用,但用它来测数值系统时,常见的问题是:测试写得太浅,根本没覆盖到策划真正关心的东西。
我举几个具体差异:
- 普通单元测试关注"真/假",数值测试关注"数值是否落在预期区间",往往还要考虑精度。
- 普通单元测试直接调用某个方法,数值测试经常需要把一个带有随机性的过程跑很多次,用统计结果来判断逻辑是否合理。
- 普通单元测试的输入是手工指定的边界值,数值测试的输入有很大一部分来自配置文件、数据表,需要把"配置-代码-实际运行结果"串成同一条验证链。
- 普通单元测试失败时定位到具体函数,数值测试失败时往往要区分是公式写错、配置配错、还是随机性导致的偶发差异。
UTF本身是基于NUnit的,支持[Test]、[UnityTest]、Assert系列方法、TestCase参数化等能力。做数值测试时,我一般会把它当做一个轻量级的"数值验证平台"来用,而不是只当作单元测试工具。
1.3 这套方案适用谁、解决什么
如果你是单机小项目里的独立开发者,数值公式写在Update里,改一个数字全靠肉眼观察伤害跳字,那么这套方案可能显得有点重。但只要你的项目里存在以下任何一种情况,UTF做数值测试就很有必要:
- 数值公式在多个副本、多个技能、多个角色之间共用;
- 配置表由策划或后端维护,客户端代码和配置经常异步更新;
- 随机掉落、暴击、命中这类概率逻辑需要保证线上表现和配置一致;
- 已经出现过"公式改了A功能,B功能跟着出错"的情况;
- 团队里有多个人在动数值相关代码。
这套方案能解决的核心问题,就是让数值的验证前置到提交之前,而不是让玩家帮你测出来。接下来我把搭建环境和写测试的完整流程讲一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 搭建Unity TestFramework的环境与第一个断言
2.1 环境准备:通过Package Manager安装Test Framework
Unity TestFramework在2019.2之后的版本里都作为官方包提供,推荐用Package Manager安装。打开菜单的Window > Package Manager,在左上角包类型里选择Unity Registry,搜索Test Framework,点击Install就好。Unity 2020之后一般会默认带com.unity.test-framework,如果是新项目,Window > General > Test Runner面板里也会提示你安装。
装完之后,项目里会出现两个程序集定义文件的位置差异,这是新手最容易困惑的地方。UTF把测试分成两种模式:
- EditMode Tests:运行在编辑器环境里,不进入Play模式,适合测试纯逻辑、纯数值、配置解析这类和引擎运行时无关的代码。
- PlayMode Tests:会真正进入Play模式,适合测试MonoBehaviour生命周期、协程、物理、场景加载等依赖运行时状态的逻辑。
数值测试绝大多数属于纯逻辑验证,放到EditMode里跑就够,速度极快,也适合做提交检查。如果你的数值计算依赖了Time.deltaTime、Random的引擎实例、或者Unity的Mathf在特定平台上的实现,再考虑PlayMode。我自己有一个原则:能进EditMode就不去写PlayMode,因为后者跑一轮要启动Play模式,哪怕场景很空,每次也要多花好几秒。
2.2 在编辑器和Console跑通第一个EditMode测试
安装完成后,打开Window > General > Test Runner,选择EditMode页签,点击Create EditMode Test Assembly Folder,Unity会自动创建一个Tests目录,里面包含一个TestAssembly的asmdef文件和一个示例测试脚本。
一个最朴素的数值测试可以这样写:
csharp复制using NUnit.Framework;
public class NumbericTestExample
{
[Test]
public void 加法_符合交换律()
{
int a = 5;
int b = 7;
int result = a + b;
Assert.AreEqual(12, result);
}
}
这个测试在Test Runner里会以方法名的形式显示,中文方法名在UTF里也可以正常显示。实际项目里我见过不少团队纠结测试方法命名规范,我的建议是直接使用"行为描述"风格,例如暴击伤害_在破甲状态下_不低于基础值。虽然有点啰嗦,但失败时看测试列表就能明白是哪个数值场景出了问题。
点击Run All,如果一切正常,测试会显示绿色通过。这是最基础的闭环。但数值测试真正难的不是跑通一个断言,而是想清楚"到底要断言什么"。
2.3 关于浮点数比较:第一个坑
数值系统里大量使用浮点数,而浮点数直接比较在计算机里是危险的。写第一个数值测试的人,大概率会在某次运行后发现Assert.AreEqual(0.1f, 0.1f)居然可能不通过,或者0.3f - 0.2f不等于0.1f。这不是Unity的问题,是IEEE 754浮点数表示方式决定的。
UTF里做数值比较,应该用NUnit的Within约束。比如要验证某个伤害计算后的浮点结果是否接近期望值,可以写:
csharp复制Assert.That(actual, Is.EqualTo(expected).Within(0.001f));
Within表示允许误差范围。这里有个需要注意的点:如果你比较的是百分比、万分比这类相对数值,用绝对误差0.001f可能不合理,更合适的做法是计算相对误差,或者把阈值放大到0.01f。我一般会在测试工程里封装一个数值断言的辅助类,提供AssertFloatApproximately这样的方法,内部统一处理绝对误差和相对误差,避免每个测试里到处散落魔法数字。
提示:别在数值测试里使用
Assert.AreEqual(float, float)的重载,除非你明确知道这两个浮点数在二进制层面完全相同。默认的浮点比较在大多数项目里都会带来偶发失败。
3. 数值测试真正需要的几种场景和用例设计
3.1 公式正确性测试:把策划表变成可执行断言
公式正确性测试是最容易理解的一类。它的思路是:从策划配置中读取输入,把公式计算的结果和手工推算的期望值做对比。
举个例子,常见的伤害公式:
code复制最终伤害 = (基础攻击 - 目标防御) * 技能倍率
如果技能倍率是百分比,用整数存储,比如倍率150表示150%,那么实现时可能写成:
csharp复制public int CalcDamage(int attack, int defense, int skillPercent)
{
int raw = attack - defense;
if (raw < 0) raw = 0;
return raw * skillPercent / 100;
}
这里有三个隐藏问题:
attack - defense可能出现负数,公式是否应该截断到0?raw * skillPercent可能超过int上限,是否需要改用long?- 整数除法是向下取整,策划期望是四舍五入还是向下取整?
公式测试要做的事情,就是把这些"未明确的部分"变成明确的断言。比如:
csharp复制[TestCase(100, 30, 150, 105)]
[TestCase(100, 200, 150, 0)]
[TestCase(0, 0, 150, 0)]
public void 伤害公式_各种输入_结果符合预期(int attack, int defense, int skillPercent, int expected)
{
int actual = DamageCalc.CalcDamage(attack, defense, skillPercent);
Assert.AreEqual(expected, actual);
}
TestCase可以一次定义多组输入输出,每一条都会生成一条独立测试记录。数值测试用这个特性非常方便,既能覆盖常规值,也能覆盖边界值。很多数值bug就是边界条件没测出来。
3.2 随机性测试:概率算法能不能信
游戏数值里最让人头疼的是随机。暴击率、闪避率、掉落概率、强化成功率,几乎每一个都可能因为实现方式不同而产生和配置不一致的实际表现。
随机性测试不是要证明随机数的数学性质,而是要在"固定种子"的前提下,验证你的概率逻辑是否真的把配置值用对了。举一个最常见的错误:配置的暴击率是0.3,代码却写成了Random.value < 0.03f,或者反过来Random.value <= 0.3f与< 0.3f的边界差异,都会导致实际暴击率和配置不一致。测试要做的,就是固定随机种子,把抽样的统计结果和配置期望值做对比,断言在一个容许的偏差范围内。
后面一节我会给出完整的暴击测试代码。核心思路是:把你代码里用的Random替换成可注入的System.Random而不是Unity的UnityEngine.Random,这样测试就可以传入固定种子,让结果可复现。
3.3 配置表完整性测试:缺项和越界的防火墙
数值测试里性价比最高的一类,其实是配置表验证。这类测试不直接测公式,而是测"数据本身是不是可用"。游戏里经常出现这样的情况:代码用itemConfigs[101]查配置,策划在Excel里更新了数值但忘了给101号物品填表,运行时查出来是null或默认值,装备属性全部变成0。
用UTF写配置表完整性测试,本质上就是遍历所有配置路径,断言每个关键字段不为空、数值范围合法、ID不重复、引用关系完整。比如:
csharp复制[Test]
public void 所有武器配置_攻击力大于0()
{
var configs = ConfigLoader.LoadWeaponConfigs();
foreach (var config in configs)
{
Assert.That(config.Attack, Is.GreaterThan(0), $"武器ID:{config.Id} 攻击力非法");
}
}
这类测试写起来简单,但价值极大。因为数值公式出错时,至少还能定位到代码;配置表一个大字段漏填,往往是运行时出现一堆"灵异"现象,定位成本要高得多。
3.4 边界与溢出测试:万一遇到极大值
数值系统里还容易忽略边界溢出。攻击力、血量、金币、等级经验这些数值在游戏后期可能膨胀到很大,如果中间计算用了int,一个int.MaxValue的乘法直接溢出成负数。属性从几百万突然变成负几百万,这种bug在线上出现时,玩家往往一脸懵。
所以数值测试里应该包含溢出测试,专门用极大值、极小值去试公式。常见做法是用long或double做内部运算,最终再转换回目标类型,但转换前要判断是否越界。测试代码形如:
csharp复制[Test]
public void 极端攻击力_不会溢出为负数()
{
int hugeAttack = int.MaxValue;
int highDefense = 100000;
int skillPercent = 300;
int result = DamageCalc.CalcDamage(hugeAttack, highDefense, skillPercent);
Assert.That(result, Is.GreaterThan(0));
}
这种测试一旦跑挂,说明数值设计区间已经超过了类型上限,需要在数值策划层面就重新规划表达范围,而不是在代码里打补丁。
4. 一个完整案例:暴击伤害公式的测试套件
4.1 先给目标代码设计一个可测试的结构
假设我们的暴击系统包含三部分:是否触发暴击、暴击伤害倍率、实际伤害计算。最开始的实现可能是这样的:
csharp复制public class CritSystem
{
public float critRate;
public float critMultiplier;
public int CalcCritDamage(int baseDamage)
{
bool isCrit = UnityEngine.Random.value < critRate;
return isCrit ? (int)(baseDamage * critMultiplier) : baseDamage;
}
}
这段代码的问题很明显:UnityEngine.Random.value是全局静态方法,测试时无法控制随机序列。为了做数值测试,需要把随机源抽象出来:
csharp复制public interface IRandomSource
{
float NextValue();
}
public class UnityRandomSource : IRandomSource
{
public float NextValue() => UnityEngine.Random.value;
}
public class FixedRandomSource : IRandomSource
{
private readonly System.Random _random;
public FixedRandomSource(int seed)
{
_random = new System.Random(seed);
}
public float NextValue() => (float)_random.NextDouble();
}
然后让CritSystem接收IRandomSource,默认注入UnityRandomSource,测试时注入FixedRandomSource。这一步改造极其重要,否则你没法做可复现的随机性测试。有同事问过我:"用UnityEngine.Random.InitState(seed)不也能固定种子?"能,但InitState是全局的,会影响同一帧内其他所有随机调用,多线程环境也不安全。注入方式更干净。
4.2 用NUnit断言锁定公式与边界
改造后的CritSystem长这样:
csharp复制public class CritSystem
{
private readonly IRandomSource _randomSource;
public float critRate;
public float critMultiplier;
public CritSystem(IRandomSource randomSource)
{
_randomSource = randomSource;
}
public bool IsCritical()
{
return _randomSource.NextValue() < critRate;
}
public int CalcCritDamage(int baseDamage)
{
return IsCritical() ? (int)(baseDamage * critMultiplier) : baseDamage;
}
}
对公式和边界的测试可以这样设计:
csharp复制[TestFixture]
public class CritSystemTests
{
private CritSystem CreateSystem(float rate, float multiplier, int seed = 12345)
{
var random = new FixedRandomSource(seed);
return new CritSystem(random)
{
critRate = rate,
critMultiplier = multiplier
};
}
[TestCase(0f, 2f, 100, 100, TestName = "暴击率0不暴击")]
[TestCase(1f, 2f, 100, 200, TestName = "暴击率1必暴击")]
public void 暴击判定_边界情况(float rate, float multiplier, int baseDamage, int expected)
{
var system = CreateSystem(rate, multiplier);
int result = system.CalcCritDamage(baseDamage);
Assert.AreEqual(expected, result);
}
[Test]
public void 暴击倍率_小于1时_伤害可能低于基础伤害()
{
var system = CreateSystem(1f, 0.5f, 99);
int result = system.CalcCritDamage(200);
Assert.AreEqual(100, result);
}
[Test]
public void 暴击伤害_超出int范围_按截断处理()
{
var system = CreateSystem(1f, 3f, 99);
int result = system.CalcCritDamage(int.MaxValue);
Assert.That(result, Is.LessThan(0), $"应发现溢出,实际:{result}");
}
}
注意最后一个测试,它暴露的是一个问题:当baseDamage * critMultiplier超过int.MaxValue时,结果是负数,这个实现显然是错的。但数值测试的意义之一就是把这个错误提前暴露出来,让你和策划商量到底应该用long还是限制数值上限。
4.3 用固定随机种子做暴击率统计
边界测试能验证逻辑分支,但没法验证"配置的暴击率在长时间抽样后是否和实际表现接近"。这需要统计测试。思路是:固定种子,生成足够多的随机数,通过CritSystem判断暴击次数,再除以总次数得到实测暴击率,断言它和配置值的偏差在一定范围内。
csharp复制[Test]
public void 暴击率_长时间抽样_与配置值偏差在容忍范围内()
{
float configuredRate = 0.3f;
int sampleCount = 100000;
var system = CreateSystem(configuredRate, 2f, seed: 20240701);
int critCount = 0;
for (int i = 0; i < sampleCount; i++)
{
if (system.IsCritical())
{
critCount++;
}
}
float actualRate = (float)critCount / sampleCount;
float tolerance = 0.01f;
Assert.That(actualRate, Is.EqualTo(configuredRate).Within(tolerance),
$"实测暴击率 {actualRate:F4} 与配置 {configuredRate:F2} 偏差超出容忍范围");
}
这里有两个关键点需要展开说。
第一,抽样次数为什么用10万而不是1000?因为二项分布的标准差是sqrt(p*(1-p)/n)。当p=0.3、n=1000时,标准差约0.0145,意味着实测率落在[0.30-0.03, 0.30+0.03]区间的概率大约是95%。如果你把容忍范围设为0.01,样本只有1000次时很容易偶发失败。10万次样本时标准差约0.00145,容忍范围0.01就比较稳。所以"跑多少次、容忍多少偏差"不是拍脑袋定的,要在统计意义上算清楚。
第二,固定种子的意义。统计测试如果完全不固定种子,每次跑可能通过也可能失败,这在CI里会变成"时好时坏"的雷。固定种子能保证同样一套代码每次跑出同样的随机序列,从而保证测试结果可复现。但这也有一个副作用:当你换一套随机算法后,种子虽然一样,序列也会变,需要重新确认测试仍然通过。
4.4 测试结果解读:到底能测出什么问题
这套测试套件跑起来后,你会发现它能暴露的问题大致分三类:
- 逻辑错误:比如判断条件从
<写成了<=,边界率0.3和Random.NextValue() == 0.3几乎不会发生,但配置率1时<1和<=1都能正常工作,配置率0时Random.NextValue() < 0永远不会触发;但配置率是0.9999时,<和<=的差异就可能被统计测试捕捉到。 - 精度丢失:
(int)(baseDamage * critMultiplier)直接截断,导致暴击伤害小数部分丢失。如果策划期望四舍五入,统计测试不一定能发现,但公式边界测试可以。 - 随机源被污染:如果某个地方在测试中间调用了
UnityEngine.Random.InitState,你注入的FixedRandomSource不受影响,但生产环境的UnityRandomSource会被影响,这种问题很难在测试里复现,只能靠代码审查。
需要额外提醒一下:统计测试不应该是第一道防线。它更适合作为"验证已修复问题"的手段,而不是定位问题的工具。如果统计测试失败,你依然需要靠日志或分段统计去定位是不是随机数被额外消耗了一两次——这个问题我下一节会专门讲。
5. 实际跑测试时最常踩的几个坑
5.1 PlayMode测试的启动时间和场景污染
如果你把数值测试写成PlayMode模式,Test Runner会在进入Play模式后执行测试。PlayMode测试每次运行都会加载项目当前场景,而场景里的任务、UI、网络模块可能也会在Awake和Start里初始化。如果你的数值测试本身不依赖场景,这个初始化是纯粹的浪费;如果场景里某些模块在测试环境下会报错,还会干扰测试结果。
我的建议是数值测试全部放EditMode,因为数值计算本质上是纯函数。真遇到依赖MonoBehaviour的数值组件,把计算逻辑抽到独立类或静态方法里,让组件只负责调用和展示。这样测试速度能从秒级降到毫秒级,也能彻底隔离场景污染。这个改造性价比很高,强烈建议做。
5.2 Random对象被全局复用的连锁反应
这是随机性测试里最隐蔽的坑。假设你的项目里有一个全局的UnityEngine.Random使用习惯,到处直接调Random.value。在测试里如果你用UnityEngine.Random.InitState(seed)来控制种子,它会影响同一帧内其他所有测试或者游戏代码中的随机调用,导致其他测试结果不稳定。反过来,如果其他代码也在测试期间使用随机数,又会消耗你预期的随机序列,导致你断言暴击率时,实际调用到的随机数并不是你设想的那一批。
解决的思路依然是依赖注入,把随机源尽量收拢到一个地方。如果历史代码太多,短期没法改完,至少要在测试里避免依赖UnityEngine.Random的真实序列,而是通过注入System.Random来控制。如果都不行,最后一个办法是把统计测试改成分批断言,对结果做多次抽样取平均,降低偶发随机带来的失败率。
5.3 测试之间共享数据的"脏状态"
数值测试如果依赖静态字段、全局配置缓存,很容易出现"上一个测试改坏了下一个测试"的连锁失败。比如某个测试读取了配置并缓存到静态字典,另一个测试修改了这份字典,再跑第三个测试时数据就变了。
避免的办法有三条:
- 每个测试尽可能独立构造自己的数据,不用共享单例;
- 如果被测代码内部有静态缓存,在
[SetUp]和[TearDown]里做清理; - 配置加载类统一提供
Reload或ClearCache方法,专门给测试用。
这里说一个真实经历。我一度在测试A里加载了DataTable缓存,测试B修改了其中一项数值,测试C去断言另一项数值时,发现已经被B污染了。排查了很久,最后是在TearDown里增加配置缓存清理才解决。这类问题本身和UTF无关,但UTF的批量执行模式会放大这类问题,尤其当你一百多个测试连续跑的时候。
5.4 批处理跑测试时Console日志噪音
数值测试经常会在循环里打印大量抽样数据,本地肉眼看着还行,一旦接入CI,成堆的日志会淹没真正的失败信息。我建议在测试里尽量不写Debug.Log,改用断言失败时的Assert.Fail消息来传递上下文。如果确实需要打印,用TestContext.Progress.WriteLine和TestContext.WriteLine,并且只在调试时启用。
另外,用命令行走批处理时,返回码要处理好。Unity TestFramework支持用-runTests -testPlatform EditMode等参数在命令行跑测试,失败时会有非零返回码,这样CI就能感知到测试挂了。下面是一个我常用的命令行示例:
bash复制Unity -batchmode -projectPath /path/to/project -runTests -testPlatform EditMode -testResults /path/to/results.xml -logFile /path/to/log.txt -quit
记得在CI的归档步骤里保存results.xml,这样测试失败时可以直接查看是哪一条断言挂了。
6. 把数值测试嵌入日常开发流程
6.1 触发时机:保存、提交还是CI?
数值测试的价值和触发频率强相关。如果只是每周手动跑一次,那它基本起不到防护作用;如果每次改一行代码都全量跑,消耗又太高。我目前使用的策略分三层:
- 本地开发阶段,高频:用Test Runner手动跑当前修改相关的测试类,或者绑定到编辑器保存事件,只跑受影响的测试。
- 提交代码阶段,中频:在pre-commit钩子里跑全部EditMode测试,跑挂就不允许提交。
- 持续集成阶段,低频但全量:在CI里同时跑EditMode和PlayMode测试,并且把结果作为合入主干的前置条件。
这样三层下来,数值问题基本不会流到玩家手里。
6.2 与配置导出的联动:测试数据来自同一份源头
数值测试能不能真正生效,很大程度上取决于测试用的配置和游戏运行的配置是否同源。如果测试里硬编码了一份数据,游戏运行时又读另一份数据,测试就是在自欺欺人。
我见过一个好的实践:项目会把策划维护的数值表(JSON、CSV、ScriptableObject)作为测试数据的唯一来源。测试启动时先加载这些配置,再对公式进行验证。这样当策划改数值表时,测试会自动验证新数据是否有问题,不需要手动同步。如果你项目里的配置是Excel导出的,建议把导出工具也做成可命令行调用的,CI里先重新导出配置,再跑数值测试,保证测的是最新数据。
6.3 性能测试的扩展:用Unity Performance Testing监控计算耗时
最后聊一个和数值测试关系密切的扩展:性能。很多数值逻辑在数据量小时没感觉,等到几百个单位同屏计算时就开始卡。Unity官方的Performance Testing包(com.unity.test-framework.performance)可以把耗时和分配量做成断言,纳入测试体系。套路是这样的:
csharp复制[Test]
[Performance]
public void 伤害计算_万次调用_耗时控制在范围内()
{
Measure.Method(() =>
{
for (int i = 0; i < 10000; i++)
{
DamageCalc.CalcDamage(100, 50, 150);
}
}).WarmupCount(10).MeasurementCount(30).Run();
}
性能测试可以作为数值测试的上层补充,但它有几个注意点:机器性能会影响结果,所以阈值要留足余量;有GC分配时会导致结果波动,需要在测试前做预热。别把性能阈值卡到刚好及格线,否则换一台性能稍差的开发机就可能误报。
我自己的习惯是,数值测试先保证正确性,再做一轮性能基准回归。正确性靠断言,性能靠Performance测试的记录和告警,两者分开而不是混在一起。这样当构建机性能波动时,不会因为一次性能抖动把整个测试套件标红。
数值测试从工具层面看,本质上是把"人工肉眼验收数值"转成"机器断言自动验收数值"。它解决的是游戏项目里一个很难受的问题:数值改动的后果通常不会立刻暴露,等到玩家反馈时,定位成本已经翻了十几倍。也许你现在的项目还没有这类测试,但我建议你从一个最容易出错的公式开始,先写一个简单的断言,再逐步把配置检查和随机验证加进来。这套体系一旦搭起来,后期数值调整的胆子都会大很多。
