给 Godot 提 PR 被 CI 打回,十有八九不是代码逻辑问题,是格式和测试。引擎的测试体系有个反直觉的设计:测试代码编译进引擎主二进制,用 --test 参数启动。这篇讲 tests/ 目录的结构、doctest 的接入方式,以及 clang-format 的工作流细节。基于 4.2/4.3 的开发分支流程。
测试即引擎:--test 的运行模型
Godot 用 doctest(单头文件 C++ 测试框架),但它不是独立的测试可执行文件。tests/SCsub 把测试源文件作为一个构建模块编进引擎,入口在 tests/test_main.cpp:
// tests/test_main.cpp(简化)
int test_main(int argc, char *argv[]) {
// 初始化最小引擎环境(register types、内存系统等)
doctest::Context context(argc, argv);
int res = context.run();
return res;
}
Main::setup() 解析到 --test 参数后,启动路径直接分叉进 test_main()——不进 SceneTree,不开窗口(通常配合 --headless)。完整的跑法:
# 编译带测试的编辑器二进制(tests=yes 是默认)
scons platform=macos arch=arm64 dev_build=yes -j8
# 跑全部测试
./bin/godot.macos.editor.dev.arm64 --headless --test
# 按测试套件过滤
./bin/godot.macos.editor.dev.arm64 --headless --test --test-suite="[String]"
# 列出所有用例
./bin/godot.macos.editor.dev.arm64 --headless --test --list-test-cases
为什么把测试塞进引擎二进制?因为 Godot 的大部分类无法脱离引擎环境构造——String 要内存系统先初始化,Node 要 ClassDB 注册完成,RefCounted 要 ObjectDB 在线。独立测试程序要么重新实现这套引导(维护两份),要么链接整个引擎(链接时间和二进制体积爆炸)。"测试作为引擎的一种运行模式"让测试代码直接用真环境,代价是跑测试前要等完整链接,以及测试崩溃时栈里混着引擎初始化代码。
测试的组织:目录镜像源码树
tests/ 下的目录结构镜像 core/、scene/、servers/:
tests/
├── core/
│ ├── string/
│ │ ├── test_string.h
│ │ └── test_node_path.h
│ ├── templates/
│ │ └── test_vector.h
│ └── ...
├── scene/
│ └── test_node.h
├── servers/
└── test_main.cpp
用例按 doctest 的 TEST_CASE 宏组织,习惯上用 tag 标注域:
// tests/core/string/test_string.h(风格示意)
TEST_CASE("[String] Construction and assignment") {
String s = "hello";
CHECK(s.length() == 5);
}
TEST_CASE("[Node] add_child notifications") {
// 需要 SceneTree 的用例会自建一个临时根节点
}
--test-suite="[String]" 按 tag 过滤是日常局部验证的最快方式。写新测试的规矩:
- 每个用例自包含,不依赖执行顺序;doctest 默认乱序执行(
--order-by),共享状态会在 CI 上随机炸; - 需要引擎环境的用例注意初始化级别:纯 core 数据结构测试在
test_main的最小环境里就能跑;涉及 SceneTree 的要自己 new 临时节点并挂到测试专用的根,跑完memdelete; - 不许碰真文件系统的固定路径,临时文件用
OS::get_user_data_dir()下的测试目录。
静态检查层:clang-format 与 clang-tidy
测试之外,CI(.github/workflows/static_checks.yml)还有两道关卡。
clang-format:仓库根有 .clang-format,风格是 Godot 定制(Allman 括号、Tab 缩进、指针靠右 Type *name)。本地用法:
# 只格式化改动文件(推荐,全仓格式化要跑几分钟)
git diff --name-only upstream/master | grep -E '\.(cpp|h)$' | xargs clang-format -i
# 或者用官方脚本
misc/scripts/clang_format.sh --check # 只检查不改
CI 上的格式检查对整个 diff 涉及的文件做校验,一处不符就红。最常见的踩坑:
- 编辑器自动格式化用了系统默认 clang-format 版本,而 CI 锁定特定版本(不同版本对同一规则渲染不同!),本地要装同版本;
- 第三方代码(
thirdparty/)和生成代码豁免,改动落在这些目录里不用管格式; - 宏展开体里的格式常常要人工整理,clang-format 对
#define多行宏的处理经常不如手写。
clang-tidy:misc/scripts/clang_tidy.sh,检查集比 format 小(主要是现代 C++ 用法和明显 bug 模式),但报告是全量的,存量代码有大量历史告警,PR 只要求不新增告警。
提交前的完整自检链
工程化推论
- 改动核心数据结构(String/Vector/Variant)必跑对应 suite 全套。这些类被全引擎依赖,一个行为变化会震出几十个旧用例的失败,它们往往揭示了你自己没想到的兼容性承诺。
dev_build=yes是廉价保险:它打开-Wall -Wextra等警告和断言(DEV_ENABLED宏),和 CI 的严格度对齐,比推上去再等 40 分钟 CI 反馈快得多。- 测试失败先看是不是环境依赖。涉及 DisplayServer 的用例在非 headless 下行为不同;
--test不加--headless在有显示器的机器上会真的开窗,CI 脚本里忘了 headless 会在无 X 环境直接崩。 - clang-format 只碰你改的行。对一个历史文件大改格式会污染 blame 和 review diff,官方立场是"顺手的格式修正允许,独立格式化 commit 拒绝"。
本文流程基于 Godot 4.2/4.3 的 tests/ 目录、.github/workflows/、misc/scripts/clang_format.sh 与官方贡献文档。命令在 macOS arm64 上实测,Linux/Windows 路径同理替换二进制名。