2238 字
11 分钟
UE 自动化测试——让 Gameplay 规则自己跑起来

一、为什么 Gameplay 不能只靠手动点一遍#

合作副本的一轮流程看起来很短:

创建副本 → 生成任务 → 玩家探索 → 战斗 → 结算 → 存档

但真正的边界很多:

  • 两个玩家同时使用技能,谁的请求先生效?
  • 倒计时最后一秒收到延迟请求,是否接受?
  • 玩家断线后重新加入,是否保留任务进度?
  • 旧版本存档读入新版本,新增字段是什么默认值?
  • 客户端重复发送请求,服务器是否重复扣款?

手动测试可以验证“这次看起来能玩”,却很难保证上周修好的规则今天没有被另一个改动破坏。自动化测试的价值不是替代试玩,而是把确定的规则变成每次都能执行的检查


二、先做测试金字塔#

少量
PIE / Multiplayer Test
Functional Test / Actor Test
Automation Spec / Gameplay Test
纯函数、数据校验、序列化测试
大量

越靠下的测试越快、越稳定,应该覆盖更多边界;越靠上的测试越接近真实体验,但启动 World、网络和资源的成本也越高。

类型适合验证速度
纯 C++ 单元测试规则函数、数值、状态转移很快
Automation Spec带生命周期的 Gameplay 对象
Functional Test关卡中的 Actor 协作中等
PIE / 多人测试网络、复制、输入、完整流程

不是所有测试都要启动一张地图。 如果“任务未完成时不能提前结算”可以在纯规则对象里验证,就不要为它启动完整客户端和服务器。


三、Automation Spec:用 Given / When / Then 描述规则#

UE 的 Spec 风格适合把规则写成可读的场景:

BEGIN_DEFINE_SPEC(
FMissionRuleSpec,
"Mission.Rules",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::EngineFilter)
TObjectPtr<UMissionRuleSet> Rules;
END_DEFINE_SPEC(FMissionRuleSpec)
void FMissionRuleSpec::Define() {
Describe("objective validation", [this]() {
BeforeEach([this]() {
Rules = NewObject<UMissionRuleSet>();
});
It("rejects completion before the objective is ready", [this]() {
const FMissionValidationResult Result = Rules->ValidateObjective(
2,
5,
EMissionState::Exploring);
TestEqual("reason", Result.Reason,
EMissionRejectReason::ObjectiveIncomplete);
});
});
}

一个好的测试名称本身就是文档:

Mission.Rules.ObjectiveValidation.rejects_early_completion

失败时,其他人应该能从名称和断言立刻知道哪条规则不成立,而不是只看到“Test failed”。


四、测试状态机,而不是只测函数返回值#

回合系统最重要的是状态转移:

Waiting
→ Preparing
→ Exploring
→ Settling
→ Finished

应该同时测试合法和非法转移:

It("moves from exploring to reward when the objective is complete", [this]() {
Mission->SetState(EMissionState::Exploring);
Mission->SetObjectiveProgress(5, 5);
Mission->EvaluateObjectiveState();
TestEqual(
"state",
Mission->GetState(),
EMissionState::Reward);
});
It("does not complete an objective after the mission ends", [this]() {
Mission->SetState(EMissionState::Finished);
TestFalse("objective completion rejected",
Mission->TryCompleteObjective(ObjectiveId));
});

边界测试尤其重要:

  • 任务进度刚好达到目标
  • 倒计时刚好为 0
  • 没有任何有效任务目标
  • 只有一个玩家
  • 玩家余额刚好够和刚好不够
  • 重复结算请求

五、Functional Test:在真实 World 里验证协作#

当你需要验证 Actor、Component、Subsystem 和关卡一起工作时,可以使用 AFunctionalTest

UCLASS()
class AMissionFunctionalTest : public AFunctionalTest {
GENERATED_BODY()
public:
virtual void StartTest() override {
Super::StartTest();
MissionSubsystem = GetWorld()->GetSubsystem<UMissionSubsystem>();
TestTrue(
TEXT("mission subsystem exists"),
IsValid(MissionSubsystem));
MissionSubsystem->StartMission(TestMissionId);
MissionSubsystem->OnMissionFinished.AddDynamic(
this,
&AMissionFunctionalTest::OnMissionFinished);
}
private:
UFUNCTION()
void OnMissionFinished(const FMissionResult& Result) {
AssertTrue(
TEXT("mission grants the expected reward"),
Result.RewardId == ExpectedRewardId);
FinishTest(EFunctionalTestResult::Succeeded,
TEXT("mission completed correctly"));
}
};

Functional Test 适合验证“从启动到结果”的流程,但要避免把每条小规则都写在这里,否则一旦基础逻辑变化,所有大测试都会一起变脆。


六、多人测试:客户端只发请求,服务器做决定#

多人测试的重点不是让两个窗口都亮起来,而是验证网络边界:

Client A:请求使用火球技能
Client B:同时请求拾取任务物品
Server:按服务器收到的顺序和规则处理
所有客户端:收到一致的最终任务状态

至少应该覆盖:

  1. 客户端不能直接修改权威任务进度
  2. 非 Owner 看不到私人任务提示
  3. 重复 RPC 不会重复领奖
  4. 晚加入客户端能从 Replicated 状态恢复当前副本
  5. 服务器拒绝非法请求后,客户端 UI 会回到正确状态

如果测试依赖真实网络延迟,不要把断言写成“恰好第 1 个客户端先收到消息”。应该断言最终的服务器状态和每个客户端的可见状态符合协议。


七、时间、随机数和测试确定性#

自动化测试最讨厌“有时通过、有时失败”。常见原因是:

  • 依赖真实 DeltaSeconds
  • 依赖系统当前时间
  • 使用未固定种子的随机数
  • 等待一个不确定的异步加载
  • 依赖 Actor Tick 的偶然执行顺序

可以把时间和随机数注入规则系统:

class IGameplayClock {
public:
virtual FDateTime Now() const = 0;
};
class FMockGameplayClock : public IGameplayClock {
public:
FDateTime Current = FDateTime(2026, 8, 30, 12, 0, 0);
virtual FDateTime Now() const override {
return Current;
}
};

测试时手动推进时间:

Clock.Current += FTimespan::FromSeconds(10);
Round.EvaluateTimeout(Clock.Now());

这样不需要真的等待 10 秒,也不会因为机器负载不同而出现时间边界漂移。


八、资产和数据校验也应该自动化#

数据驱动文章里提到过:DataAsset 和 DataTable 是内容团队的代码。可以批量检查:

扫描所有 ItemDefinition
→ ItemId 非空且唯一
→ MinDamage <= MaxDamage
→ Icon / Preview 引用存在
→ 稀有度 Tag 已注册
→ 所有 PrimaryAssetId 可以被 Asset Manager 发现

这类测试不需要运行游戏,可以在编辑器命令或 CI 中执行。内容错误越早失败,越不会等到 Cook 之后才在打包版本里发现图标或模型丢失。

同样可以检查:

  • GameplayTag 命名是否符合项目规则
  • DataTable 的 RowName 是否重复或使用了保留字
  • SaveGame 迁移是否覆盖每个旧版本
  • 软引用是否落在允许的 Chunk 中

九、测试消息和复制的边界#

消息系统和复制系统的测试方式不同:

本地 Gameplay Message
→ 测试发布后,所有订阅者收到一次
→ 测试监听者销毁后不会再次回调
Replicated State
→ 测试服务器修改后,客户端最终状态一致
→ 测试晚加入者能得到当前值
→ 测试 OwnerOnly 数据不会出现在其他客户端

不要只测试“某个事件有没有发”,还要测试事件丢失时状态仍然可恢复。例如结算音效没有播放不应该让玩家失去奖励;客户端漏掉一个表现消息,也应该能从 RoundState = Finished 进入正确的 UI。


十、CI 中怎么安排测试#

一个实用的分层流程:

每次提交
→ 纯规则 + 数据校验 + 快速 Automation Spec
合并请求
→ Editor Functional Test + 资产加载测试
夜间 / 发布前
→ PIE 多人测试 + Cook 后资源测试 + 长时间 soak test

失败日志需要包含:

  • 测试名称和场景
  • 引擎版本、平台、构建配置
  • 随机种子
  • 回合号、玩家数、关键输入
  • 服务器和客户端的最终状态

自动化测试如果失败后只能让人“本地再点一次看看”,它的诊断价值就很低。


十一、任务系统的一组最小回归集#

规则层
✓ 任务进度不足时拒绝完成
✓ 资源不足时拒绝释放技能
✓ 非 Exploring 状态时拒绝完成目标
✓ 同一任务不会重复发放奖励
状态层
✓ Exploring → Reward → Finished
✓ 没有完成目标时正确处理失败
✓ 超时只触发一次
网络层
✓ 客户端请求必须由服务器验证
✓ 晚加入客户端得到当前副本状态
✓ 私人任务提示只发送给 Owner
持久化层
✓ 保存后读回状态一致
✓ 旧版本存档完成迁移
✓ 无效存档不会污染当前运行状态

这组测试不需要覆盖所有可能组合,但能守住系统最重要的契约。每次新增一个 Gameplay 规则,先补一个失败测试,再写实现,调试成本会明显下降。


十二、总结#

概念一句话
测试金字塔用大量快速测试覆盖规则,少量重测试验证真实协作
Automation Spec用 Given / When / Then 表达可读的 Gameplay 场景
Functional Test在真实 World 中验证 Actor 和 Subsystem 的协作
多人测试验证服务器权威、复制可恢复和权限边界
确定性注入时间、随机数和异步依赖,避免偶发失败
数据校验在 Cook 或运行前发现内容错误
CI把回归检查变成每次提交都会执行的基础设施

自动化测试不是对程序员的不信任,而是把记忆交给机器。 当规则、数据、复制和存档都有稳定的回归保护,后续做架构调整时,才有信心知道哪些行为被保留了下来。

UE 自动化测试——让 Gameplay 规则自己跑起来
https://www.m4doka.xyz/posts/ue/ue-15-automation-testing/
作者
m4doka
发布于
2026-08-30
许可协议
CC BY-NC-SA 4.0