Article

C# 实战课 15:测试脚手架与回归基线(把战斗核心变成可持续交付)

系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:给前 14 章的基础设施补齐自动化验证体系,让每次改动都有“可量化正确性保障”。

学习目标

完成本章后,你应该能做到:

  1. 为核心模块建立统一测试入口(单测 + 回放对比 + 性能烟测)。
  2. 把“业务规则”转成可执行断言,而不是靠人工观察。
  3. 建立稳定的回归样本集(固定 seed + 固定输入 + 固定预期)。
  4. 让 CI 在数分钟内判定本次改动是否破坏核心战斗行为。

为什么这一章是 C# 路线收官关键

前面章节已经有:

  • 命令系统
  • 状态机
  • 技能系统
  • Buff 系统
  • 伤害管线
  • 仇恨系统
  • 回放落盘
  • 生命周期治理

如果没有自动化回归,这些模块越多,回归成本越高,迭代速度会持续下降。
工程化的转折点不是“再加功能”,而是“把已有功能变成可验证资产”。

测试体系分层

建议分三层:

  1. L1 单元测试:纯逻辑,无引擎依赖,毫秒级。
  2. L2 场景回放测试:加载回放数据,验证关键指标一致。
  3. L3 性能烟测:固定时长跑战斗循环,检测 GC/对象数异常。

测试目录建议

tests/
  Unit/
    CommandBusTests.cs
    StateMachineTests.cs
    SkillValidatorTests.cs
    BuffStackTests.cs
    DamagePipelineTests.cs
    ThreatSelectorTests.cs
  Replay/
    ReplayDeterminismTests.cs
    ReplayCompatibilityTests.cs
    Data/
      baseline_2v2_001.rpl
      baseline_boss_001.rpl
  Perf/
    BattleLoopSmokeTests.cs

项目驱动:先落地最关键的 6 组单测

1. 命令系统:顺序与回收

[Test]
public void CommandBus_Should_ExecuteInSequence_And_Recycle()
{
    var bus = new CommandBus("test", 8, 16);
    var trace = new List<int>();

    bus.Enqueue(new TestCommand(1, trace), 0f);
    bus.Enqueue(new TestCommand(2, trace), 0.01f);
    bus.Enqueue(new TestCommand(3, trace), 0.02f);

    bus.Tick(new CommandContext(null, null), null);

    Assert.AreEqual(3, trace.Count);
    Assert.AreEqual(1, trace[0]);
    Assert.AreEqual(2, trace[1]);
    Assert.AreEqual(3, trace[2]);
}

2. 状态机:优先级切换

[Test]
public void GuardBrain_Should_Prioritize_Dead_Over_Hit()
{
    var actor = new Actor();
    actor.Hp = 0f;
    actor.IsHit = true;

    var fixture = GuardBrainFixture.Build(actor);
    fixture.Brain.Tick(0.016f);

    Assert.AreEqual("Dead", fixture.Fsm.CurrentName);
}

3. 技能系统:冷却与蓝耗校验

[Test]
public void SkillComponent_Should_Reject_When_Cooldown_Or_Mana_NotEnough()
{
    var setup = SkillFixture.Create();
    string reason;

    var ok1 = setup.PlayerSkills.TryCast(setup.Player, setup.Target, 1001, 1.0f, out reason);
    Assert.IsTrue(ok1);

    var ok2 = setup.PlayerSkills.TryCast(setup.Player, setup.Target, 1001, 1.1f, out reason);
    Assert.IsFalse(ok2);
    Assert.AreEqual("skill_cooldown", reason);
}

4. Buff 系统:叠层上限

[Test]
public void EffectContainer_Should_Clamp_Stack_To_Max()
{
    var actor = new Actor();
    var bus = new EventBus();
    var effects = new EffectContainer(actor, bus);

    var cfg = new EffectConfig
    {
        Id = 3001,
        Name = "Poison",
        Type = EffectType.Dot,
        DurationSeconds = 6f,
        TickIntervalSeconds = 1f,
        MaxStacks = 3,
        StackPolicy = StackPolicy.StackCount,
        Value = 5f
    };

    effects.Apply(cfg, 1);
    effects.Apply(cfg, 1);
    effects.Apply(cfg, 1);
    effects.Apply(cfg, 1);

    Assert.AreEqual(3, effects.GetStack(3001));
}

5. 伤害管线:固定 seed 一致性

[Test]
public void DamagePipeline_Should_Be_Deterministic_With_Same_Seed()
{
    var fixture = DamageFixture.Create();

    var req = fixture.CreateRequest(seed: 123456);
    var r1 = fixture.Pipeline.Execute(req, fixture.Context);
    var r2 = fixture.Pipeline.Execute(req, fixture.ContextClone());

    Assert.AreEqual(r1.IsHit, r2.IsHit);
    Assert.AreEqual(r1.IsCritical, r2.IsCritical);
    Assert.AreEqual(r1.FinalDamage, r2.FinalDamage);
}

6. 仇恨系统:切换防抖

[Test]
public void ThreatSelector_Should_Not_Switch_When_Delta_Below_Threshold()
{
    var fixture = ThreatFixture.Create(threshold: 25f);

    fixture.Table.AddThreat(1001, 200f, 1f);
    fixture.Table.AddThreat(1002, 210f, 1f);

    fixture.Component.Tick(1f, 0.016f);
    var first = fixture.Component.CurrentTarget.Id;

    fixture.Table.AddThreat(1002, 5f, 1.2f); // 仅微小领先
    fixture.Component.Tick(1.2f, 0.016f);

    Assert.AreEqual(first, fixture.Component.CurrentTarget.Id);
}

回放回归:把问题“冻结成样本”

1. 基线文件策略

每次重大版本发布前,录制并固定以下样本:

  1. baseline_1v1_duel_001.rpl
  2. baseline_2v2_teamfight_001.rpl
  3. baseline_boss_phase_001.rpl

每个样本记录预期指标:

  • 总时长(帧数)
  • 总伤害
  • 暴击次数
  • 目标切换次数
  • 击杀时间点

2. 回归断言示例

[Test]
public void Replay_2v2_Baseline_Should_Match_KeyMetrics()
{
    var data = ReplayLoaderUtil.Load("tests/Replay/Data/baseline_2v2_001.rpl");
    var result = ReplayVerifier.Run(data);

    Assert.AreEqual(5400, result.TotalFrames);
    Assert.AreEqual(18320, result.TotalDamage);
    Assert.AreEqual(47, result.CritCount);
    Assert.AreEqual(26, result.TargetSwitchCount);
    Assert.AreEqual(4210, result.FirstKillFrame);
}

性能烟测:防止“功能正确但性能崩”

[Test]
public void BattleLoop_10Minutes_Should_Keep_GC_And_AliveObjects_InBudget()
{
    var sim = BattleSimulator.Create(seed: 20250115);

    sim.RunFrames(60 * 60 * 10); // 10 分钟 @60fps

    Assert.LessOrEqual(sim.Metrics.Gen0Collections, 40);
    Assert.LessOrEqual(sim.Metrics.MaxAliveProjectiles, 256);
    Assert.LessOrEqual(sim.Metrics.PeakPendingCommands, 512);
}

CI 执行建议

流水线最小步骤:

  1. 运行 L1 单测(< 1 分钟)。
  2. 运行关键回放样本(2~3 分钟)。
  3. 运行 1 个性能烟测(1~2 分钟)。
  4. 生成测试报告并归档失败回放。

建议失败即阻断合并,避免“先合再修”。

常见坑

坑 1:测试依赖真实时间

用了 DateTime.NowTime.time 会导致偶发失败。应注入可控时钟。

坑 2:回放样本频繁被覆盖

基线应该是“签名资产”。只有评审通过才允许更新。

坑 3:断言只看最终 HP

中间链路错误可能被掩盖。应同时断言过程指标(暴击数、切换数、事件序列)。

本月作业

补齐你的第一版“战斗质量门禁”:

  1. 为命令/FSM/技能/Buff/伤害/仇恨各加至少 3 条单测。
  2. 固化 2 个回放基线文件,并写 2 条回放断言。
  3. 把测试命令接入本地一键脚本与 CI。

下一阶段开始进入 Unity 路线:把这套 C# Foundation 能力迁入 Unity 场景工程,构建真正可玩的 WebGL 小游戏基础架构。