一、为什么 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:按服务器收到的顺序和规则处理 ↓所有客户端:收到一致的最终任务状态至少应该覆盖:
- 客户端不能直接修改权威任务进度
- 非 Owner 看不到私人任务提示
- 重复 RPC 不会重复领奖
- 晚加入客户端能从 Replicated 状态恢复当前副本
- 服务器拒绝非法请求后,客户端 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 | 把回归检查变成每次提交都会执行的基础设施 |
自动化测试不是对程序员的不信任,而是把记忆交给机器。 当规则、数据、复制和存档都有稳定的回归保护,后续做架构调整时,才有信心知道哪些行为被保留了下来。