单元测试与后端回归(doctest)
SpinningMomo 的 C++ 后端回归测试由 doctest 框架承载,编译为独立的 SpinningMomoTests 可执行目标,通过 xmake test 运行,用于在纯 C++ 层面(无 UI、无 JSON-RPC)验证后端纯逻辑模块(glob 匹配、录制时间、路径工具等)的行为。
Purpose and Scope
本页面覆盖以下内容:
tests/目录下的 doctest 单元测试的组织方式、构建配置与运行机制;SpinningMomoTestsxmake 目标的完整定义(依赖、编译选项、源码编入方式、add_tests注册);test_main.cpp集中式测试入口的设计(DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN);- 测试编写范式:
TEST_CASE/CHECK/CHECK_FALSE的实际用法,以及测试文件以命名空间包裹、与生产代码命名空间对齐的惯例; - 各测试套件的覆盖范围(
matcher_test.cpp、time_test.cpp、path_test.cpp)。
以下相邻主题有意留给兄弟页面,本页仅作指引:
- TypeScript 端到端场景测试(
tests/scenarios/,通过 JSON-RPC 驱动编译后的SpinningMomo.exe,pnpm run test:scenarios运行):属于场景测试页面。仓库在 AGENTS.md 中明确区分了这两类测试。 - 交互式 RPC 调试工具(
web/src/features/playground/与根目录playground/):属于交互调试页面。 - 同一构建文件
tests/xmake.lua中的SpinningMomoScenarioWindow目标服务于场景测试,本页仅在构建配置一节简要提及。
需要说明的是:仓库文档将这批 doctest 测试定位为 "Legacy"(AGENTS.md),当前主要的行为级回归由 TypeScript 场景测试承担,doctest 套件负责纯函数级的小颗粒验证。
Overview
doctest 是一个单头文件的 C++ 测试框架。SpinningMomo 通过 vcpkg 引入(vcpkg::doctest,版本由 xmake-requires.lock 锁定),并在 src/vendor/doctest.hpp 中做了一层包装:
1#include "vendor/std.hpp"
2
3// 集中生成唯一的测试程序入口,让各测试文件只负责声明测试场景
4#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
5#include "vendor/doctest.hpp"Source: test_main.cpp
这个入口文件是整个测试程序唯一的 main() 来源。DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN 宏让 doctest 在此翻译单元中生成 main(),并自动注册、收集、运行所有其他翻译单元中通过 TEST_CASE 声明的测试场景。其设计意图(注释中写明)是「集中生成唯一的测试程序入口,让各测试文件只负责声明测试场景」——即入口与场景声明分离,避免每个测试文件各自实现 main 造成链接冲突。
当前测试二进制覆盖三个后端模块:
| 测试文件 | 被测生产源码 | 覆盖内容 |
|---|---|---|
tests/features/gallery/ignore/matcher_test.cpp | src/features/gallery/ignore/matcher.cpp | match_glob_pattern 的 glob 语义:完整路径匹配、globstar 跨段、? 通配符、[...]/[!...] 字符类、Windows 大小写不敏感 |
tests/features/recording/time_test.cpp | src/features/recording/time.cpp | 录制时间相关纯逻辑(具体断言以源码为准) |
tests/utils/path_test.cpp | src/utils/path/path.cpp | 路径工具纯逻辑(具体断言以源码为准) |
tests/test_main.cpp | —(doctest runner 本身) | 唯一 main() 入口 |
架构文档 docs/developer/architecture.md 对此的表述是:"后端回归测试使用 doctest,由独立的 SpinningMomoTests 目标承载"。
Architecture
架构要点说明:
- 不链接产品库,而是把生产
.cpp直接编入测试目标。SpinningMomoTests没有依赖任何已构建的产品静态/动态库,而是把matcher.cpp、time.cpp、path.cpp及其依赖的logger.cpp直接作为源文件加入目标。这使测试目标完全自包含——只需满足这几个文件的编译需求即可构建,无需先构建整个应用。代价是:新增测试若触及新的生产源文件,必须同步把它加进tests/xmake.lua的add_files列表,否则会出现链接错误。 logger.cpp+spdlog是公共依赖。路径工具与匹配器都会产生日志输出,因此日志实现(SPDLOG_COMPILED_LIB宏与vcpkg::spdlog包)也必须编入,且宏定义必须与产品构建保持一致,否则会出现符号不匹配。test_main.cpp是唯一的 runner。所有测试文件只写TEST_CASE,doctest 在链接期通过静态初始化自动收集它们,运行期由test_main.cpp生成的main()统一调度。add_tests("default")把二进制注册进xmake test,因此开发者不需要记忆测试可执行文件的具体路径,直接xmake test即可。
构建配置详解(tests/xmake.lua)
完整的 SpinningMomoTests 目标定义如下:
1add_requires("vcpkg::doctest", "vcpkg::spdlog")
2
3target("SpinningMomoTests")
4 set_kind("binary")
5 set_default(false)
6 set_plat("windows")
7 set_arch("x64")
8
9 add_defines("NOMINMAX", "UNICODE", "_UNICODE", "WIN32_LEAN_AND_MEAN",
10 "_WIN32_WINNT=0x0A00", "SPDLOG_COMPILED_LIB")
11 add_includedirs("../src")
12
13 add_files("../src/features/recording/time.cpp")
14 add_files("../src/features/gallery/ignore/matcher.cpp")
15 add_files("../src/utils/logger/logger.cpp")
16 add_files("../src/utils/path/path.cpp")
17 add_files("test_main.cpp")
18 add_files("features/gallery/ignore/matcher_test.cpp")
19 add_files("features/recording/time_test.cpp")
20 add_files("utils/path_test.cpp")
21
22 add_packages("vcpkg::doctest", "vcpkg::spdlog")
23 add_links("shell32", "ole32")
24 add_tests("default")Source: xmake.lua
逐项解读设计意图:
| 配置项 | 值 | 设计意图 |
|---|---|---|
add_requires("vcpkg::doctest", ...) | vcpkg 依赖声明 | 通过 vcpkg 包管理器解析 doctest 与 spdlog,版本锁定在 xmake-requires.lock(vcpkg::doctest#f56260b5),保证可复现构建 |
set_kind("binary") | 可执行文件 | 测试以独立进程方式运行,进程退出码即测试结果,便于 CI 直接消费 |
set_default(false) | 不在默认构建中 | xmake 默认构建不会编译测试目标,避免拖慢日常增量构建;需要显式 xmake build SpinningMomoTests 或 xmake test |
set_plat("windows") / set_arch("x64") | Windows x64 | 锁定测试目标的平台与架构,与产品构建对齐(add_defines 中 _WIN32_WINNT=0x0A00 对应 Windows 10 SDK 最低版本) |
add_defines(...) | NOMINMAX/UNICODE/_UNICODE/WIN32_LEAN_AND_MEAN/SPDLOG_COMPILED_LIB | 与产品代码的 Windows/字符集宏保持一致;NOMINMAX 防止 min/max 宏污染,SPDLOG_COMPILED_LIB 让 spdlog 走编译库模式以匹配 vcpkg 提供的预编译库 |
add_includedirs("../src") | 源码头文件目录 | 测试文件用 #include "features/gallery/ignore/matcher.hpp" 这种与产品内部一致的相对路径引用头文件 |
add_files(../src/...) | 4 个生产 .cpp | 直接编入被测实现,不依赖产品库(自包含目标) |
add_files(test_main.cpp ...) | 入口 + 3 个测试文件 | 入口唯一,测试文件只声明场景 |
add_links("shell32", "ole32") | Windows 系统库 | 满足 path.cpp(shell32,路径/SH 系列函数)与 COM 相关(ole32)符号解析 |
add_tests("default") | 注册测试 | 挂接到 xmake test,无需记忆可执行文件路径 |
注意 tests/xmake.lua 中还定义了第二个目标 SpinningMomoScenarioWindow(xmake.lua L26-L36),它服务于 TypeScript 场景测试(见兄弟页面),与 doctest 单元测试无关,仅共享同一构建文件。
测试编写范式
入口与场景分离
doctest 单头文件有两个角色:实现与接口。DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN 只能出现在一个翻译单元中(否则 main 重定义),因此仓库把实现收敛到 test_main.cpp,其他测试文件只包含 vendor/doctest.hpp(即 #include <doctest/doctest.h>,见 src/vendor/doctest.hpp)以取得 TEST_CASE/CHECK 等宏。
TEST_CASE 命名与命名空间对齐
每个测试文件用与被测生产代码完全一致的命名空间包裹测试:
namespace features::gallery::ignore::matcher {
TEST_CASE(...) { ... }
}这样测试内部的自由函数、辅助变量不会泄漏到全局命名空间,同时阅读时一眼可辨归属。doctest 的 TEST_CASE("名称") 字符串用于报告输出,本仓库采用「行为描述」风格的命名,例如 "glob matches the complete root-relative path"(描述被测行为而非被测函数名)。
断言风格
CHECK(expr):失败时记录但不中断当前TEST_CASE,适合一组相关断言批量验证;CHECK_FALSE(expr):验证否定分支;- 成对使用
CHECK/CHECK_FALSE形成「正例 + 反例」的表意测试(table-like 断言群),这是本仓库最典型的风格(见下文示例)。
核心流程
流程说明:
- 注册发生在静态初始化期。doctest 的
TEST_CASE宏展开后是一个全局对象的构造,它在main()之前向 doctest 内部注册表登记自己。这就是为什么test_main.cpp生成的 runner 能「自动发现」其他翻译单元中的测试——无需手工列表。 CHECK失败不终止测试。与REQUIRE不同,CHECK记录失败后继续执行,因此一个TEST_CASE内可以放多组正反例断言,一次运行看到全部失败点。xmake test的结果判定基于进程退出码,可直接接入 CI。
Usage Examples
示例一:glob 完整路径匹配语义(正反例成对断言)
1TEST_CASE("glob matches the complete root-relative path") {
2 CHECK(match_glob_pattern("*.jpg", "photo.jpg"));
3 CHECK_FALSE(match_glob_pattern("*.jpg", "photos/photo.jpg"));
4
5 CHECK(match_glob_pattern("photos/*", "photos/photo.jpg"));
6 CHECK_FALSE(match_glob_pattern("photos/*", "photos/2026/photo.jpg"));
7}Source: matcher_test.cpp
这是仓库最典型的测试风格:同一 TEST_CASE 中正反例相邻摆放,*.jpg 只匹配根相对路径根层(photo.jpg),不跨段(photos/photo.jpg 应失败);photos/* 同理只匹配一层。这直接锁定了忽略规则的语义边界,防止实现退化成「任意位置匹配」。
示例二:globstar(**)跨段匹配
1TEST_CASE("globstar crosses complete path segments") {
2 CHECK(match_glob_pattern("**/*.jpg", "photo.jpg"));
3 CHECK(match_glob_pattern("**/*.jpg", "photos/2026/photo.jpg"));
4
5 CHECK(match_glob_pattern("a/**/b.jpg", "a/b.jpg"));
6 CHECK(match_glob_pattern("a/**/b.jpg", "a/x/y/b.jpg"));
7 CHECK_FALSE(match_glob_pattern("a/**/b.jpg", "a/xxb.jpg"));
8
9 CHECK(match_glob_pattern("photos/**", "photos/2026/photo.jpg"));
10 CHECK_FALSE(match_glob_pattern("photos/**", "photos"));
11}Source: matcher_test.cpp
关键断言是 CHECK_FALSE(match_glob_pattern("a/**/b.jpg", "a/xxb.jpg"))——确保 ** 按完整路径段跨越,而不是被误实现为「任意字符序列」(那样 xxb.jpg 会被错误匹配)。photos/** 不匹配裸目录 photos 本身也锁定了 globstar 必须消耗至少一个段。
示例三:通配符与字符类
1TEST_CASE("glob supports wildcards and character classes") {
2 CHECK(match_glob_pattern("photo?.jpg", "photo1.jpg"));
3 CHECK_FALSE(match_glob_pattern("photo?.jpg", "photo10.jpg"));
4
5 CHECK(match_glob_pattern("[0-9]*.jpg", "1-photo.jpg"));
6 CHECK_FALSE(match_glob_pattern("[0-9]*.jpg", "a-photo.jpg"));
7 CHECK(match_glob_pattern("[!0-9]*.jpg", "a-photo.jpg"));
8 CHECK_FALSE(match_glob_pattern("[!0-9]*.jpg", "1-photo.jpg"));
9}Source: matcher_test.cpp
覆盖 ? 单字符通配、[0-9] 正向字符类、[!0-9] 取反字符类。? 的反例(photo10.jpg 失败)防止单字符通配退化成多字符。
示例四:Windows 大小写不敏感
TEST_CASE("glob matching is case insensitive on Windows") {
CHECK(match_glob_pattern("Photos/*.JPG", "photos/photo.jpg"));
}Source: matcher_test.cpp
平台行为(Windows 文件系统大小写不敏感)被固化为显式测试契约——这也是测试目标 set_plat("windows") 的意义所在:该断言在 Windows 上是成立的契约,而非偶然行为。
示例五:测试文件骨架(新建测试的标准模板)
1#include "features/gallery/ignore/matcher.hpp"
2
3#include "vendor/doctest.hpp"
4
5namespace features::gallery::ignore::matcher {
6
7TEST_CASE("glob matches the complete root-relative path") {
8 CHECK(match_glob_pattern("*.jpg", "photo.jpg"));
9 CHECK_FALSE(match_glob_pattern("*.jpg", "photos/photo.jpg"));
10}
11
12} // namespace features::gallery::ignore::matcherSource: matcher_test.cpp
新建 doctest 测试的标准步骤:① #include 被测头文件(相对 ../src 的路径);② #include "vendor/doctest.hpp"(不定义 DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN);③ 用与产品一致的命名空间包裹;④ 编写 TEST_CASE;⑤ 在 tests/xmake.lua 中同时登记新的测试 .cpp 与(如有)新增的生产 .cpp。
Configuration Options
| 选项 / 配置 | 类型 | 默认 / 值 | 说明 |
|---|---|---|---|
add_requires("vcpkg::doctest") | xmake 包依赖 | vcpkg::doctest#f56260b5(lock 锁定) | doctest 测试框架本体 |
add_requires("vcpkg::spdlog") | xmake 包依赖 | 由 xmake-requires.lock 锁定 | 生产代码日志库,测试目标必须同步引入 |
set_kind("binary") | 目标类型 | binary | 输出独立测试可执行文件 |
set_default(false) | bool | false | 不随默认 xmake 构建编译,需显式构建或 xmake test |
set_plat("windows") | 平台 | windows | 仅 Windows 平台;与大小写不敏感等平台契约断言配套 |
set_arch("x64") | 架构 | x64 | 与产品构建架构对齐 |
NOMINMAX | 宏 | 定义 | 防止 Windows 头的 min/max 宏污染 |
UNICODE / _UNICODE | 宏 | 定义 | 宽字符 API 选择,与产品一致 |
WIN32_LEAN_AND_MEAN | 宏 | 定义 | 精简 windows.h |
_WIN32_WINNT=0x0A00 | 宏 | Windows 10 | 最低 SDK 版本 |
SPDLOG_COMPILED_LIB | 宏 | 定义 | 匹配 vcpkg 预编译 spdlog 库模式 |
add_includedirs("../src") | 包含目录 | ../src | 测试以产品内部相对路径引用头文件 |
add_links("shell32", "ole32") | 系统库 | shell32, ole32 | 满足 path.cpp(shell32)与 COM(ole32)符号 |
add_tests("default") | 测试注册 | default | 注册进 xmake test,按退出码判定结果 |
DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN | doctest 宏 | 仅 test_main.cpp 定义 | 唯一 main() 的来源,其他文件禁止定义 |
API Reference
本页范围内对外可用的「API」是 doctest 宏与被测函数的契约。被测函数签名取自测试中的实际调用方式。
match_glob_pattern(pattern: std::string_view, path: std::string_view): bool
(签名以测试调用形态呈现;完整实现位于 src/features/gallery/ignore/matcher.cpp。)
Parameters:
pattern(string):glob 模式,支持*、?、**(globstar,按完整路径段跨越)、[...]/[!...]字符类。path(string):待匹配的根相对路径(root-relative path)。
Returns: 匹配返回 true,否则 false。匹配在 Windows 上大小写不敏感。
行为契约(由测试固化):
*不跨路径段:*.jpg匹配photo.jpg,不匹配photos/photo.jpg。**按完整段跨越:a/**/b.jpg匹配a/b.jpg与a/x/y/b.jpg,不匹配a/xxb.jpg。photos/**不匹配裸目录photos(globstar 至少消耗一个段)。?恰好一个字符:photo?.jpg不匹配photo10.jpg。[0-9]正向类、[!0-9]取反类按单字符匹配。Photos/*.JPG可匹配photos/photo.jpg(Windows 大小写不敏感)。
TEST_CASE(name: const char*)
doctest 宏。在静态初始化期注册一个测试场景;name 用于报告中显示。本仓库命名风格为行为描述句(如 "globstar crosses complete path segments")。
CHECK(expr)
doctest 宏。求值 expr,失败时记录文件/行号并继续执行当前测试。适合成组的正反例断言。
CHECK_FALSE(expr)
doctest 宏。求值 expr,期望其为假;失败记录并继续。
Failure Modes, Edge Cases & Concurrency
- 链接失败(新增被测源码未登记):测试目标把生产
.cpp直接编入,若新增测试依赖未列入add_files的生产源文件,会在链接期报未定义符号。这是「自包含目标」设计的直接代价,修法是把对应.cpp加进 tests/xmake.lua。 main重定义:若在test_main.cpp之外的测试文件定义DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN,会产生main重定义链接错误。约定是:入口宏只出现在 test_main.cpp。- 宏不一致导致符号不匹配:
SPDLOG_COMPILED_LIB、_WIN32_WINNT等宏若与产品构建不一致,spdlog 预编译库的符号可见性/ABI 可能不匹配,产生难排查的链接错误。构建脚本通过显式add_defines列表固化这些宏。 - 平台绑定断言:
"glob matching is case insensitive on Windows"这类用例依赖 Windows 文件系统语义;set_plat("windows")保证契约的平台一致性。若未来移植到其他平台,该断言需按平台条件化。 - 断言失败不中断:
CHECK失败后继续执行同一TEST_CASE内后续断言,一次运行可看到全部失败点;但也意味着断言之间存在数据依赖时要小心(本仓库测试均为独立断言,无此问题)。 - 无并发问题:测试全部为纯函数调用(不涉及共享状态、文件系统、时钟),顺序执行、无并发竞争面。
Performance & Operational Notes
- 运行成本极低:三个测试套件均为纯逻辑断言(glob 匹配、时间计算、路径处理),无 I/O、无进程间通信、无沙箱搭建,测试二进制整体运行在毫秒级。
- 不进入默认构建:
set_default(false)意味着日常xmake增量构建不会编译测试目标,开发主循环不受拖累;提交前或 CI 中通过xmake test触发。 - CI 友好:以独立进程 + 退出码作为结果判定,天然适配 CI 流水线与
xmake test报告聚合。 - 与场景测试的分工:doctest 覆盖纯函数颗粒度,TypeScript 场景测试(
tests/scenarios/,JSON-RPC 驱动真实可执行文件)覆盖端到端行为;后者按仓库文档是当前回归主力,且默认门控 Release 构建。详见 AGENTS.md 的测试分层说明(AGENTS.md L153-L154)。 - 测试前需注意:运行场景测试前需关闭正在运行的应用实例(AGENTS.md 提示);doctest 单元测试无此要求。
Extension Points
- 新增单元测试:复制
matcher_test.cpp的骨架(命名空间对齐 +TEST_CASE+ 成对CHECK/CHECK_FALSE),并在tests/xmake.lua中同时登记测试文件与新增生产源文件;不要在新文件中定义DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN。 - 接入新的被测模块:把对应生产
.cpp追加到add_files列表;若该模块引入新的第三方依赖,需同步add_packages/add_links。 - doctest 能力扩展:入口集中在
test_main.cpp,如需自定义 doctest 配置(如DOCTEST_CONFIG_NO_SHORT_MACRO_NAMES、命令行过滤、颜色输出等),只需修改这一个文件,不影响各测试文件。doctest 二进制本身支持--test-case=<pattern>等命令行过滤参数,可直接对测试可执行文件调试运行。 - 跨平台扩展:如需解除
set_plat("windows")绑定,需先处理平台契约断言(大小写不敏感用例)与_WIN32_WINNT等 Windows 专属宏。
Related Links
- tests/xmake.lua — SpinningMomoTests 目标完整定义
- tests/test_main.cpp — doctest 唯一入口
- tests/features/gallery/ignore/matcher_test.cpp — glob 匹配契约测试
- tests/features/recording/time_test.cpp — 录制时间测试
- tests/utils/path_test.cpp — 路径工具测试
- src/vendor/doctest.hpp — doctest 包装头
- AGENTS.md — 测试分层说明(单元测试 vs 场景测试)
- docs/developer/architecture.md — 后端回归测试架构定位