技术博客

doctest 单元测试与 clang-format 工作流:给引擎提 PR 前的自检清单

第 17 / 149 篇 · 2026-05-29 · godot · 引擎架构测试工作流

给 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 只要求不新增告警。

提交前的完整自检链

提 PR 前的本地自检链(与 CI 对齐) 1. scons dev_build=yes tests=yes dev 构建开全警告,warnings=extra 2. --headless --test 跑相关套件 --test-suite="[改动域]" 3. clang_format.sh --check 版本要与 CI 锁定版一致 4. git push,等 CI 全绿 CI 常见红线 static_checks:格式 / 文件头版权行 linux_builds:-Werror 把警告变错误 macos / windows 构建(交叉检查) 单元测试全套件(含未触达的旧用例) 最容易踩的: · clang-format 版本不一致 · 新增文件忘写版权头(misc/scripts 查) · 只跑了自己的 suite,旧用例被波及 dev_build 的 WARNINGS 在 CI 上即 ERROR, 本地 build 时开 dev_build=yes 提前暴露

工程化推论

  • 改动核心数据结构(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 路径同理替换二进制名。