关于 godot-gdscript-headless-testing
这份技能指南介绍如何通过 Godot 4.7 的无头命令行运行 GDScript 测试,重点说明可靠的退出码、CI 设置、导入步骤及常见挂起问题。
主要功能
- 01无头运行 GDScript 测试
- 02SceneTree 测试运行器模式
- 03失败计数与进程退出码
- 04新检出项目的导入设置
- 05CI 超时与问题排查
适用场景
- 01在终端运行逻辑测试
- 02让 CI 在测试失败时中止构建
- 03排查无头运行挂起或结果异常
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ gamedev-skills/ awesome-gamedev-agent-skills --skill godot-gdscript-headless-testing安装后在 Claude Code 中输入/godot-gdscript-headless-testing确认已加载。
详细介绍
Godot GDScript 无头测试(4.x)
从命令行运行 GDScript 测试,无需编辑器 GUI,并获取 CI 可用的真实进程退出代码。目标版本为 Godot 4.7 无头 CLI。
适用场景
- 当 Godot 项目未安装测试插件,并且需要通过终端或 CI 流水线快速验证 GDScript 逻辑(纯函数、资源加载、自动加载状态)时使用。
- 当配置的 CI 任务必须在
.gd测试失败时使构建失败时使用。 - 当排查
godot --headless调用为何卡住、打开窗口,或在断言失败时仍以 0 退出时使用。
不适用场景: GDScript 语法或语言特性本身 → godot-gdscript;导出/构建流水线和平台模板 → godot-export(它有自己的 --headless 使用场景,用于生成二进制文件,而不是运行测试)。
工作流
- 确认二进制文件可无头运行。 Godot 4.x 内置了
--headless(不需要导出模板);运行godot --headless --version,确认它输出版本字符串,而不是打开 GUI 窗口。 - 在全新检出后,先导入再运行测试。
.godot/通常不会提交,因此干净的检出没有导入缓存:class_name类型无法解析(Identifier "X" not declared in the current scope),导入的资源也无法加载(No loader found for resource: res://...)。先在 CI 和本地各运行一次godot --headless --path <project_dir> --import。 - 将运行器编写为
SceneTree脚本,而不是Node场景。SceneTree脚本的_initialize()会在任何帧开始前运行一次,足以进行纯逻辑测试,而且启动时不需要.tscn。 - 自行统计通过/失败数量,并显式调用
quit(<code>)。不要使用裸assert()让测试失败。 Godot 不会仅因push_error()就将进程退出代码设为非零——运行器必须统计失败数并调用quit(1)。更糟的是,在_initialize()中失败的assert()(官方/调试版本)会输出SCRIPT ERROR: Assertion failed,并在执行到quit()之前停止运行,导致进程无法退出,CI 会一直卡到自身超时。使用会记录失败并继续执行的assert_eq()风格辅助函数。 - 通过
godot --headless --path <project_dir> --script res://<runner>.gd调用,并从 shell 或 CI 步骤读取进程退出代码,而不只是标准输出。--script接受res://相对路径和绝对文件系统路径(例如,运行器位于项目文件夹之外);两者都可以使用。 - 通过包装 shell(PowerShell、某些 CI 运行器)编写调用脚本时,将标准输出和标准错误重定向到文件。
push_error()的输出会写入标准错误,如果只实时捕获标准输出,错误输出可能被丢弃或重新排序。 - 为 CI 添加步骤超时。 即使避免了
assert()陷阱,永远无法完成的await(模式 #2)仍会导致运行器一直卡住;CI 步骤中的timeout-minutes是 CI 侧的后备措施,不能替代为每个await配置超时节点。
模式
1. 带真实退出代码的最简 SceneTree 测试运行器
# res://test_runner.gd — run with:
# godot --headless --path . --script res://test_runner.gd
extends SceneTree
var passed := 0
var failed := 0
func _initialize() -> void:
test_add()
print("Results: %d passed, %d failed" % [passed, failed])
quit(1 if failed > 0 else 0) # non-zero exit fails the CI step
func assert_eq(actual, expected, label: String) -> void:
if actual == expected:
passed += 1
else:
failed += 1
push_error("FAIL %s: expected %s, got %s" % [label, expected, actual])
func test_add() -> void:
assert_eq(2 + 2, 4, "test_add")
经 Godot 4.7.2 验证:godot --headless --path . --script res://test_runner.gd 会将 Results: N passed, M failed 输出到标准输出,将
push_error 行输出到标准错误;当 failed == 0 时返回进程退出代码 0,否则返回 1。
2. 测试需要帧、计时器或信号的内容
extends SceneTree
var passed := 0
var failed := 0
func _initialize() -> void:
await run_tests()
print("Results: %d passed, %d failed" % [passed, failed])
quit(1 if failed > 0 else 0) # track and report failures here too
func assert_eq(actual, expected, label: String) -> void:
if actual == expected:
passed += 1
else:
failed += 1
push_error("FAIL %s: expected %s, got %s" % [label, expected, actual])
func run_tests() -> void:
# `root` is not inside the tree yet during _initialize(): a Timer started now
# errors ("not inside the tree") and its `timeout` never fires. Wait one frame.
await process_frame
var timer_node := Timer.new()
timer_node.one_shot = true # default Timer restarts after timeout
root.add_child(timer_node)
timer_node.start(0.1)
await timer_node.timeout
# assertions here can rely on the node having been in the tree for a frame
assert_eq(timer_node.is_stopped(), true, "timer_fires_once")
timer_node.queue_free()
_initialize() 可以使用 await,因此此模式适用于任何需要节点实际进入场景树、计时器触发或信号发出的情况——在引擎至少处理一帧之前,这些都不会发生。使用与模式 #1 相同的 passed/failed 计数器和 assert_eq() 辅助函数;如果此模式总是调用 quit(0),就永远无法让构建失败。
3. 通过退出代码设门槛的 CI 步骤(GitHub Actions)
- name: Import project (populates .godot/ on a fresh checkout)
run: godot --headless --path . --import
- name: Run GDScript tests
timeout-minutes: 5
run: godot --headless --path . --script res://test_runner.gd
在干净的检出环境中,导入步骤不可或缺——没有它,class_name 类型和导入的资源将无法解析。测试步骤本身不需要额外标志:对于来自 run: 的非零退出代码,运行器本来就会让作业失败;关键在于运行器脚本中的 quit() 调用,而不在 CI 配置中。timeout-minutes 用于防止 await 卡住时作为后备措施(参见“常见问题”),不能替代为每个 await 配置超时节点。
常见问题
- 脚本“什么也不做”或打开了编辑器窗口 → 缺少
--headless,或脚本路径错误。--script接受相对于--path <project_dir>解析的res://相对路径,也接受绝对文件系统路径——两者都有效。 - 仅在全新检出时出现
Identifier "X" not declared in the current scope,或资源加载失败 →.godot/(导入缓存)通常不会提交,因此class_name类型和导入的资源尚未解析。请在测试步骤前运行一次godot --headless --path <project_dir> --import。 assert()失败后挂起,而不是让测试失败 → 在官方/调试构建中,_initialize()内失败的assert()会打印SCRIPT ERROR: Assertion failed,并在执行到quit()前停止该函数——进程永不退出,CI 会一直等待,直到触发自身的超时。测试运行器中请使用assert_eq()计数器(模式 #1),而不要直接使用assert()。- 断言失败后退出代码仍为 0 → 运行器从未调用
quit(1),或者(模式 #2)无论是否失败都始终调用quit(0)。请自行跟踪失败情况,并显式调用quit(),传入能反映失败情况的代码;不要指望assert()或push_error()单独改变退出代码。 _initialize()在节点、计时器或信号存在之前运行 → 需要等待一帧处理完成的逻辑,必须先await一个信号或计时器,再执行断言;参见模式 #2。此时root本身尚未进入场景树,因此在其中添加并启动Timer会报错,且其timeout永远不会触发(运行器会挂起)——请先await process_frame。- 通过包装 shell 查看时,输出为空或顺序错乱 → 某些 shell(尤其是 PowerShell)可能会重排或丢弃原生进程的实时 stdout/stderr。请将两个流都重定向到文件,并在进程退出后读取文件,而不要依赖实时控制台。
- 运行器始终不终止 →
SceneTree脚本会持续运行,直到有内容调用quit()。如果测试await的信号从未触发,作业就会永远挂起——请始终为await配置超时节点作为后备措施,并在 CI 步骤中设置timeout-minutes作为后备。
相关技能
godot-gdscript— 本模式的运行器脚本所使用的语言语法和节点生命周期。godot-export— 无界面 CLI 导出/构建,这是--headless的另一种使用场景。