32-测试体系UnitTests
32 · 测试体系 Unit Tests(GoogleTest + CTest)
本节目标(Goals):用 GoogleTest 建立单元测试体系,覆盖核心层纯逻辑(GapBuffer/搜索/撤销/解析/配置),并以 CTest 挂进
make test。
前置知识(Prerequisites):教程 08/14/15/22/29 的模块接口;03(tests/ 目录)、02(CMake)。
1. 为什么我们的架构"天生好测"
教程一路以来的纯逻辑分层现在兑现了:搜索、高亮、撤销、命令解析、配置解析,全都是"字符串进、字符串出"的无状态纯函数——测试它们不需要虚拟终端、不需要按键模拟、不需要屏幕。这份可测性,是教程 08 起每一章坚持"核心不放 UI 依赖"换来的。
测试金字塔(自下而上):单元测试(本教程)→ 组件集成(插件流程,教程 28 的 main-demo 已示范)→ 端到端(打开/编辑/保存,CI 冒烟)。
2. 引入 GoogleTest(CMake + FetchContent)
# tests/CMakeLists.txt
include(FetchContent)
FetchContent_Declare(googletest
URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip)
FetchContent_MakeAvailable(googletest)
enable_testing()
add_executable(vicarus_tests
test_buffer.cpp
test_gap_buffer.cpp
test_search.cpp
test_undo.cpp
test_parser.cpp
test_config.cpp)
target_link_libraries(vicarus_tests PRIVATE
vicarus_core vicarus_infra gtest_main)
include(GoogleTest)
gtest_discover_tests(vicarus_tests) # ctest 自动枚举每个 TEST
add_test(NAME all COMMAND vicarus_tests) # 兜底一个整体测试
根 CMakeLists.txt 末尾挂上子目录:
if(BUILD_TESTING) # cmake -DBUILD_TESTING=OFF 可关
add_subdirectory(tests)
endif()
3. 测试集一:Buffer 与 GapBuffer(test_gap_buffer.cpp)
#include <vicarus/core/buffer.hpp>
#include <vicarus/core/gap_buffer.hpp>
#include <gtest/gtest.h>
TEST(GapBuffer, InsertMiddleKeepsInterior) {
vicarus::core::GapBuffer gb;
gb.insert(0, "hello");
gb.insert(5, " world");
EXPECT_EQ(gb.to_string(), "hello world");
}
TEST(Buffer, ReplaceAllReportsCount) {
vicarus::core::Buffer buf;
buf.replace_all("a b a c");
EXPECT_EQ(buf.line_count(), 1);
}
TEST(Buffer, LineCountsAcrossReplace) {
vicarus::core::Buffer buf;
buf.replace_all("one\ntwo\nthree");
EXPECT_EQ(buf.line_count(), 3);
}
TEST(Buffer, EmptyBufferIsEditable) {
// 默认构造的空缓冲天然 1 行(line_offsets_{0}),不打开文件也应能直接编辑
vicarus::core::Buffer buf;
EXPECT_EQ(buf.line_count(), 1);
buf.insert({0, 0}, "hi");
EXPECT_EQ(buf.to_string(), "hi");
EXPECT_EQ(buf.line_count(), 1);
}
TEST(GapBuffer, SubstrOnlyAcrossGap) {
vicarus::core::GapBuffer gb;
gb.insert(0, "hello world");
gb.move_gap(3); // 制造"间隙在中间"的布局
EXPECT_EQ(gb.substr(6, 5), "world");
gb.move_gap(11); // 间隙挪到末尾后再取整段
EXPECT_EQ(gb.substr(0, 11), "hello world");
}
自引用随机差分(用来抓"间隙越界/扩容布局"这类隐蔽 bug):真正的 GapBuffer/Buffer
测试不写死一串期望字节——那容易因手滑写错而反向误导。更稳的写法是参考实现 + 差分:
用std::string维护一份"期望文本"作为同构参照,逐次随机在[0, size]取插入/删除位点
与字节,两边做相同的操作,每隔 N 步对比一次to_string()与line_count()。随机移动光标
能不断搅乱间隙布局(间隙靠左/靠右/居中),把"只在特定 layout 下才现形"的扩容、越界问题
逼出来。教程 30 的 GapBuffer 事故正是靠这类用例钉死的。
4. 测试集二:搜索语义(test_search.cpp)
正好把教程 22 + 30 的行为钉死(防 KMP 重构回归):
#include <vicarus/core/search.hpp>
#include <vicarus/core/buffer.hpp>
#include <gtest/gtest.h>
TEST(Search, ForwardThenWrap) {
vicarus::core::Buffer buf;
buf.replace_all("foo bar foo");
auto h1 = vicarus::core::search_forward(buf, "foo", {0, 0});
ASSERT_TRUE(h1);
EXPECT_EQ(h1->pos.col, 0);
auto h2 = vicarus::core::search_forward(buf, "foo", h1->pos, /*wrap=*/true);
ASSERT_TRUE(h2);
EXPECT_EQ(h2->pos.col, 8);
}
TEST(Search, NoWrapReturnsNullopt) {
vicarus::core::Buffer buf;
buf.replace_all("foo");
auto h = vicarus::core::search_forward(buf, "foo", {0, 1}, /*wrap=*/false);
EXPECT_FALSE(h);
}
TEST(Search, BackwardFindsPrevious) {
vicarus::core::Buffer buf;
buf.replace_all("aa bb aa");
auto h = vicarus::core::search_backward(buf, "aa", {0, 7});
ASSERT_TRUE(h);
EXPECT_EQ(h->pos.col, 6);
}
TEST(Search, ReplaceAllCounts) {
vicarus::core::Buffer buf;
buf.replace_all("foo foo bar FOO");
EXPECT_EQ(vicarus::core::replace_all(buf, "foo", "x"), 3); // 大小写不敏感
EXPECT_EQ(buf.to_string(), "x x bar x");
}
5. 测试集三:撤销/解析/配置
// test_undo.cpp
#include <vicarus/core/undo_redo.hpp>
TEST(UndoRedo, InsertStepThenUndo) {
vicarus::core::Buffer buf;
vicarus::core::UndoRedo ur;
ur.begin(buf, {0, 0});
buf.insert_text(0, "abc");
ur.end(buf, {0, 3});
EXPECT_EQ(buf.to_string(), "abc");
ur.undo(buf);
EXPECT_EQ(buf.to_string(), "");
ur.redo(buf);
EXPECT_EQ(buf.to_string(), "abc");
}
// test_parser.cpp
#include <vicarus/core/command_parser.hpp>
TEST(CommandParser, ParseOperatorRange) {
const auto r = vicarus::core::Range{2, 6}; // 教程 12 的 Range
// 演示:d2w 解析出 Count=2、Motion=w
}
// test_config.cpp
#include <vicarus/infra/config.hpp>
TEST(ConfigStore, ParseSubset) {
vicarus::infra::ConfigStore cfg;
cfg.parse_string("[search]\nignorecase = true\n[editor]\ntabwidth = 4\nquote=\"hi\"");
EXPECT_TRUE(cfg.get_bool("search.ignorecase").value_or(false));
EXPECT_EQ(cfg.get_int("editor.tabwidth").value_or(0), 4);
EXPECT_EQ(cfg.get_str("editor.quote").value_or(""), "hi");
}
提示:
test_parser.cpp的ParseOperatorRange是给你的练习题——跑通d2w的解析断言,顺便练测试驱动(先红后绿)。
6. 跑起来
cmake -B build -DBUILD_TESTING=ON
cmake --build build
ctest --test-dir build --output-on-failure
输出范本:
[----------] Global test environment tear-down
[==========] 12 tests from 5 test suites ran. (0.04s total)
[ PASSED ] 12 tests.
ctest --output-on-failure 帮你在 CI 里一眼定位炸掉的用例——这正是教程 34 会用的姿势。
本节小结(Summary)
- 测试金字塔底层的单元测试,几乎全部命中核心层纯函数,这是分层架构的红利;
- GoogleTest 经 FetchContent 一把拉入;
gtest_discover_tests让 ctest 自动枚举; - 搜索/撤销/GapBuffer/配置各有一组测试钉住语义,给未来重构(如 KMP)上保险;
- 随机差分 + 参考实现比对是抓"间隙布局/扩容"这类隐蔽 bug 的利器(教程 30 事故的防线);
BUILD_TESTING开关让无网络环境也能跳过测试子目录;- 测试同时是"文档":它告诉大家每个模块"应该怎么用"。
动手练习(Exercise)
- 补全
test_parser.cpp:d2w、G、0与$的解析断言各一个; - 给
search_forward增加ignore_case=false的用例,按教程 29 的参数签名改; - 为 SyntaxHighlighter 写测试:一行
int x = 42; // hi断言含 Type/Number/Comment 三种 span; - 挑战:为
BufferSet::remove(教程 23)写"脏缓冲拒删 + 编号善后"的边界测试。 - 给 GapBuffer 补一组随机差分("自写参考实现比对"):随机插入/删除数百步,
每步随机移动光标制造不同间隙布局,隔一定步数比对to_string()/size(),把扩容
与跨间隙的越界 bug 逼出来。
下一节
33-CPack打包交付Packaging.md:把产物从"build 目录里的二进制"变成"可安装的发行包"。

浙公网安备 33010602011771号