mcpField
G
godot-gdscript-headless-testing

godot-gdscript-headless-testing

github.com/gamedev-skills测试与质量5.4k3.9k 次安装1 天前更新
代码仓库

关于 godot-gdscript-headless-testing

这份技能指南介绍如何通过 Godot 4.7 的无头命令行运行 GDScript 测试,重点说明可靠的退出码、CI 设置、导入步骤及常见挂起问题。

主要功能

  1. 01无头运行 GDScript 测试
  2. 02SceneTree 测试运行器模式
  3. 03失败计数与进程退出码
  4. 04新检出项目的导入设置
  5. 05CI 超时与问题排查

适用场景

  1. 01在终端运行逻辑测试
  2. 02让 CI 在测试失败时中止构建
  3. 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 使用场景,用于生成二进制文件,而不是运行测试)。

工作流

  1. 确认二进制文件可无头运行。 Godot 4.x 内置了 --headless(不需要导出模板);运行 godot --headless --version,确认它输出版本字符串,而不是打开 GUI 窗口。
  2. 在全新检出后,先导入再运行测试。 .godot/ 通常不会提交,因此干净的检出没有导入缓存:class_name 类型无法解析(Identifier "X" not declared in the current scope),导入的资源也无法加载(No loader found for resource: res://...)。先在 CI 和本地各运行一次 godot --headless --path <project_dir> --import。
  3. 将运行器编写为 SceneTree 脚本,而不是 Node 场景。 SceneTree 脚本的 _initialize() 会在任何帧开始前运行一次,足以进行纯逻辑测试,而且启动时不需要 .tscn。
  4. 自行统计通过/失败数量,并显式调用 quit(<code>)。不要使用裸 assert() 让测试失败。 Godot 不会仅因 push_error() 就将进程退出代码设为非零——运行器必须统计失败数并调用 quit(1)。更糟的是,在 _initialize() 中失败的 assert()(官方/调试版本)会输出 SCRIPT ERROR: Assertion failed,并在执行到 quit() 之前停止运行,导致进程无法退出,CI 会一直卡到自身超时。使用会记录失败并继续执行的 assert_eq() 风格辅助函数。
  5. 通过 godot --headless --path <project_dir> --script res://<runner>.gd 调用,并从 shell 或 CI 步骤读取进程退出代码,而不只是标准输出。--script 接受 res:// 相对路径和绝对文件系统路径(例如,运行器位于项目文件夹之外);两者都可以使用。
  6. 通过包装 shell(PowerShell、某些 CI 运行器)编写调用脚本时,将标准输出和标准错误重定向到文件。 push_error() 的输出会写入标准错误,如果只实时捕获标准输出,错误输出可能被丢弃或重新排序。
  7. 为 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 的另一种使用场景。

评论

支持 Markdown