项目一变大,「改一处、崩三处」就成了日常。今天的主题是很多独立开发者最容易跳过、却最能救命的一环:单元测试。好消息是,Godot 生态里已经有成熟方案 GUT,而「写测试」这件事又恰好是 AI 最擅长、最不需要你有没有天赋的地方——你负责说清规则,DeepSeek 负责把断言写全。

今日速览

  • GUT 是 Godot 界的「JUnit」:用 GDScript 写 GDScript 测试,官方版本 9.x 对应 Godot 4.x,7.x 对应 Godot 3.x,装完在编辑器里点一下就能跑。
  • 约定极简:测试脚本 extends GutTest,文件名以 test_ 开头,函数名以 test_ 开头,就会被自动收集执行。
  • 生命周期四件套:before_all / before_each / after_each / after_all,用来准备和清理对象,避免测试之间互相污染。
  • 不只是断言:GUT 支持 Doubles(替身)、Stubbing(打桩)、Spies(间谍)和参数化测试,可以隔离掉网络、随机、时间等外部依赖。
  • 能进流水线:GUT 提供 CLI 和标准 JUnit XML 导出,可以直接接到 GitHub Actions,做到「提交即跑测」。

核心学习内容

1. 三步装上 GUT,跑通第一个测试

在 Godot 里打开「AssetLib」搜索 GUT - Godot Unit Testing 安装,或从 GitHub 下载 zip 把 addons/gut 放进项目并启用插件(启用后重启一次编辑器)。然后新建 res://test/unit/test_example.gd:

extends GutTest

func test_数值相等应通过():
    assert_eq(1 + 1, 2, "1+1 必须等于 2")

func test_字符串不等应失败():
    assert_eq('hello', 'goodbye', "这行会红,用来确认测试真的在跑")

打开底部 GUT 面板,把测试目录(如 res://test/unit)填进设置,点 Run All。看到一绿一红,说明环境搭好了。建议把测试按 test/unit(快,纯逻辑)和 test/integration(慢,涉及场景/节点)分目录,日常只跑 unit。

2. 一个真实例子:给「伤害计算」写测试

游戏里最容易出错、又最适合写测试的,就是数值逻辑。假设你有一个 Damage.gd:

class_name Damage
static func compute(atk: int, defense: int, crit: bool) -> int:
    var base := max(1, atk - defense)
    return int(base * (1.5 if crit else 1.0))

对应测试 test/unit/test_damage.gd:

extends GutTest

func before_each():
    # 每个测试前重置任何需要的状态
    pass

func test_普通攻击按攻减防计算():
    assert_eq(Damage.compute(10, 4, false), 6)

func test_至少造成一点伤害():
    assert_eq(Damage.compute(3, 99, false), 1, "防御再高也要保底 1 点")

func test_暴击有加成():
    assert_eq(Damage.compute(10, 0, true), 15)

func test_边界_攻防相等时保底():
    assert_eq(Damage.compute(5, 5, false), 1)

要点:把「纯函数」从节点脚本里抽出来(static func 或独立 Resource),测试就不需要实例化整个角色场景,跑得又快又稳。这正是让 AI 参与的前提——纯函数边界清晰,AI 不会瞎编。

3. 隔离依赖:Doubles 与 Spy

当逻辑依赖外部对象(比如背包、存档、随机数)时,用 double() 造替身、用 stub() 打桩、用 watch_signals() 验证信号有没有发出去:

extends GutTest

func test_受击时应发出_health_changed_信号():
    var player = preload("res://scripts/player.gd").new()
    watch_signals(player)
    player.take_damage(10)
    assert_signal_emitted_with_parameters(player, "health_changed", [90])
    player.queue_free()

把「随机」和「时间」也包成可注入的接口(例如传入一个 RandomNumberGenerator 种子),测试才能 100% 可复现——这也是发给 DeepSeek 的提示词里必须写清的约束。

4. 接进流水线:CLI + JUnit

GUT 支持命令行跑测并导出 JUnit XML,配合你之前搭的 GitHub Actions,就能做到每次 push 自动跑:

godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://test/unit -gexit -gjunit_xml_file=test-results.xml

-gexit 让 CI 根据结果返回正确退出码,test-results.xml 可直接喂给 CI 的结果面板。做到这一步,「AI 改代码 → 自动跑测 → 红了就回滚」的闭环就成立了。

🎯 DeepSeek 提示词(可直接复制)

提示词 1:为已有 GDScript 类生成 GUT 单元测试

适用场景:写完一个脚本,想让 DeepSeek 补齐测试,涵盖正常值、边界与异常。

# 角色
你是精通 Godot 4.x 与 GUT(Godot Unit Test 9.x) 的测试工程师。

# 任务
为下面的 GDScript 代码编写一份完整的 GUT 单元测试脚本。

# 输入
```gdscript
<在这里粘贴你的脚本源码>

输出要求

  1. 只输出一个 .gd 测试文件,首行 extends GutTest,文件名建议 test_<原名>.gd。
  2. 用中文测试函数名,例如 func test_攻击力低于防御时保底1点():,每个测试都要有含义明确的失败提示语。
  3. 必须覆盖:①典型正常值 ②边界值(0、相等、最大值)③异常/非法输入。
  4. 涉及随机、时间、文件、网络的部分,必须改为可注入参数,不得依赖真实外部状态。
  5. 需要构造对象时用 before_each() 创建、after_each() 里 queue_free() 清理。

约束

  • 只用 GUT 自带的 assert_* 方法,不要引入第三方库。
  • 不要修改被测代码的接口;如果你认为接口有问题,在最上方用注释单独说明。
  • 不确定的行为不要编造,用注释写「待确认」。

输出格式

先输出完整代码块,再用 5 行以内说明「我覆盖了哪些场景、还建议手工补测什么」。


### 提示词 2:把「面条代码」拆成可测试的纯函数

> **适用场景**:逻辑都堆在 `_process()` 或节点脚本里,没法测。让 AI 先重构,再补测试。

```text
# 角色
你是 Godot 架构师,擅长把难以测试的「上帝脚本」拆成纯函数 + 数据。

# 任务
重构我给的 GDScript,把与节点/渲染无关的「规则逻辑」抽成可独立测试的静态函数或独立的 RefCounted 类,然后为抽出的逻辑写 GUT 测试。

# 输入
```gdscript
<粘贴你的节点脚本,比如升级、掉落、结算相关>

输出要求

  1. 先给「重构后」的源码(保持对外行为不变),规则逻辑抽到独立文件或 static func。
  2. 再给对应的 GUT 测试文件,覆盖抽出的每一个公开函数。
  3. 明确列出:哪些逻辑被抽走了、哪些必须留在节点里、为什么。

约束

  • 不改变游戏对外表现(同样输入 → 同样输出)。
  • 所有依赖(随机数、当前时间、存档)改为函数参数注入。
  • 每个抽出的函数都要能被单独 new() 或直接调用,不依赖场景树。

输出格式

分三段:## 重构后源码、## 测试代码、## 重构说明(≤8 行)。


### 提示词 3:TDD 模式——先给测试,再给实现

> **适用场景**:新写一个系统(背包、技能冷却、经济系统),先用测试把需求「钉死」,再让 AI 实现。

```text
# 角色
你是遵循 TDD(测试驱动开发)的 Godot 开发者。

# 任务
我要实现一个功能:<用一两句话描述,例如「技能冷却系统:每个技能有独立冷却时间,冷却中不可释放,时间到自动就绪」>。

# 输入
- 引擎:Godot 4.x,语言 GDScript,测试框架 GUT 9.x。
- 我已有的约定/数据结构:<可选,粘贴相关类或字段命名>。

# 输出要求
第一步:先不要写实现!只输出一份 GUT 测试文件,用测试用例把我的需求完整表达出来(包含正常流程、边界、并发/重复触发等)。
第二步:等我回复「可以」,再输出让这些测试全部通过的实现代码。

# 约束
- 时间相关逻辑必须可注入时间源(不得直接用 `Time.get_ticks_msec()`),保证测试可复现。
- 需求里没讲清的地方,先列出问题问我,不要自己假设。
- 测试函数名用中文,写清预期行为。

# 输出格式
第一步只输出测试代码块 + 「待确认问题」列表;确认后再给实现。

学习心得

到今天才补上「测试」这一课,其实有点晚。用 DeepSeek 做游戏最大的风险从来不是它写不出代码,而是它改代码时会悄悄改坏别处——而单元测试就是最便宜的刹车。把数值、掉落、结算这类「规则」抽成纯函数,一方面让代码可测,另一方面也让提示词变得干净:AI 面对一个边界清晰的纯函数,几乎不会跑偏;面对一个塞满 _process() 的上帝脚本,只能猜。「先能测,才能让 AI 大胆改」——这是把「AI 写代码」从玩具变成工程的关键一步。建议从今天起,每写完一个纯逻辑文件,就顺手让 DeepSeek 补一份测试,积少成多。

明日关注

  • 在本机把 GUT 装上,跑通那「一绿一红」的示例,确认测试面板可用。
  • 挑自己项目里最简单的一个数值函数(伤害/经验/掉落),抽成纯函数并补 3 个测试。
  • 试一次提示词 3 的 TDD 流程:先让 DeepSeek 出测试,确认接口后再要实现。

本文由自动化学习流水线每日生成,坚持即学即记。