测试覆盖率从35%到80%:我用AI批量生成C++单元测试的完整方案
如果你问一个C++开发者最讨厌做什么,十个有八个会说"写单元测试"。不是不知道重要,是真的枯燥。这篇文章讲的是我怎么用AI把这件最没人愿意干的事变成了一条自动化流水线。
痛点:每次Review都在说"测试呢?"
我们组的代码仓库有大约十五万行C++代码,测试覆盖率长期在35%左右徘徊。不是没要求------组规里白纸黑字写着"新增代码必须配套单测"------但实际执行起来,大家总有理由跳过:排期紧、逻辑简单不需要测、下个版本再补......
结果就是每次Code Review都要上演同一段对话:
"测试呢?"
"这个逻辑比较直观,下个迭代补。"
"上次你也这么说的。"
然后MR还是合了,因为需求要上线。测试成了技术债里最大的一笔,而且是那种每个人都知道欠着、但没人愿意还的债。
去年有一次版本回归,一个改了三行的bugfix引入了一个边界条件的崩溃,在线上跑了两天才发现。根因分析的时候发现,那个函数压根没有单元测试------如果有一个覆盖边界值的测试用例,CI阶段就能拦住。
那次之后我下定决心:既然人不愿意写,那就让AI来写。
整体方案
思路是做一条全自动的测试生成流水线:扫描头文件提取接口签名 → 收集类的上下文信息 → 组装提示词 → 调LLM生成测试代码 → 编译验证 → 输出可直接入库的测试文件。

关键设计决策有两个:
第一,以头文件为入口,不以源文件为入口。因为头文件定义了公开接口,这才是单测应该覆盖的东西。源文件里的私有实现,不应该直接测试。
第二,生成之后必须编译验证。AI生成的代码不可能百分百编译通过,所以流水线里有一个自动编译+错误反馈+重新生成的循环。实测下来,第一次编译通过率约70%,加上一轮自动修正后提升到92%左右。
核心代码:头文件扫描器
整个流水线的起点是HeaderScanner------它负责解析C++头文件,提取出所有需要生成测试的类和函数签名。
headerscanner.h
cpp
#ifndef HEADER_SCANNER_H
#define HEADER_SCANNER_H
/// @file headerscanner.h
/// @brief C++头文件接口扫描器 v1.0
/// @details 解析头文件提取类名、公开方法签名、构造函数信息,
/// 作为测试生成的输入源
/// @usage
/// HeaderScanner scanner;
/// scanner.scan("configmanager.h");
/// auto methods = scanner.getMethods();
#include <string>
#include <vector>
#include <regex>
/// @brief 方法签名信息
struct MethodInfo
{
std::string className; // 所属类名
std::string methodName; // 方法名
std::string returnType; // 返回值类型
std::string fullSignature; // 完整签名文本
std::vector<std::string> paramTypes; // 参数类型列表
bool isConst; // 是否为const方法
bool isStatic; // 是否为静态方法
};
/// @brief 类信息
struct ClassInfo
{
std::string name; // 类名
std::vector<MethodInfo> methods; // 公开方法列表
std::vector<std::string> includes; // 该类依赖的头文件
bool hasDefaultConstructor; // 是否有默认构造函数
};
class HeaderScanner
{
public:
/// @brief 扫描指定头文件
bool scan(const std::string& filePath); // 解析一个头文件
/// @brief 批量扫描目录下所有头文件
int scanDirectory(const std::string& dirPath); // 返回扫描到的类数量
/// @brief 获取所有扫描到的类信息
const std::vector<ClassInfo>& getClasses() const; // 返回类信息列表
/// @brief 获取指定类的信息
const ClassInfo* findClass(const std::string& name) const; // 按名称查找类
/// @brief 清空扫描结果
void clear(); // 重置内部状态
private:
std::vector<ClassInfo> classes_; // 扫描结果存储
/// @brief 从文件内容中提取类定义
void extractClasses(const std::string& content, // 文件内容
const std::string& filePath); // 文件路径
/// @brief 解析类体中的公开方法
void parsePublicMethods(const std::string& classBody, // 类体文本
ClassInfo& classInfo); // 输出目标
/// @brief 从方法声明中提取参数类型
std::vector<std::string> extractParamTypes( // 解析参数列表
const std::string& paramList // 括号内的参数文本
) const;
};
#endif // HEADER_SCANNER_H
headerscanner.cpp
cpp
#include "headerscanner.h"
#include <fstream>
#include <sstream>
#include <filesystem>
bool HeaderScanner::scan(const std::string& filePath)
{
std::ifstream file(filePath); // 打开目标头文件
if (!file.is_open()) // 无法打开则返回失败
return false;
std::ostringstream oss; // 读取全部内容到字符串
oss << file.rdbuf(); // 一次性读入
std::string content = oss.str(); // 获取文件内容
extractClasses(content, filePath); // 提取类定义
return true; // 扫描完成
}
int HeaderScanner::scanDirectory(const std::string& dirPath)
{
int count = 0; // 扫描计数器
namespace fs = std::filesystem; // 文件系统命名空间
for (const auto& entry : fs::recursive_directory_iterator(dirPath)) // 递归遍历目录
{
if (entry.path().extension() == ".h" || // 筛选头文件扩展名
entry.path().extension() == ".hpp")
{
if (scan(entry.path().string())) // 逐个扫描
++count; // 成功则计数
}
}
return count; // 返回扫描到的文件数
}
void HeaderScanner::extractClasses(const std::string& content,
const std::string& filePath)
{
// 匹配 class ClassName 或 class ClassName : public Base
std::regex classPattern( // 类声明正则表达式
R"(class\s+(\w+)(?:\s*:\s*(?:public|protected|private)\s+\w+)?\s*\{)"
);
auto begin = std::sregex_iterator(content.begin(), content.end(), classPattern); // 开始匹配
auto end = std::sregex_iterator(); // 结束哨兵
for (auto it = begin; it != end; ++it) // 遍历所有匹配
{
ClassInfo info; // 新建类信息
info.name = (*it)[1].str(); // 提取类名
// 找到类体的结束位置(简化版:匹配大括号层级)
size_t classStart = it->position() + it->length(); // 类体起始偏移
int braceDepth = 1; // 大括号深度计数
size_t pos = classStart; // 当前扫描位置
while (pos < content.size() && braceDepth > 0) // 遍历直到匹配到结束大括号
{
if (content[pos] == '{') // 遇到左括号则深度加一
++braceDepth;
else if (content[pos] == '}') // 遇到右括号则深度减一
--braceDepth;
++pos; // 移动到下一个字符
}
std::string classBody = content.substr(classStart, pos - classStart - 1); // 截取类体
parsePublicMethods(classBody, info); // 解析公开方法
// 判断是否有默认构造函数
std::regex defaultCtorPattern( // 默认构造函数正则
info.name + R"(\s*\(\s*\))"
);
info.hasDefaultConstructor = // 检查是否存在
std::regex_search(classBody, defaultCtorPattern) ||
info.methods.empty(); // 无显式构造也算有默认构造
classes_.push_back(info); // 保存类信息
}
}
void HeaderScanner::parsePublicMethods(const std::string& classBody,
ClassInfo& classInfo)
{
bool inPublic = false; // 是否在public区域
std::istringstream stream(classBody); // 逐行解析类体
std::string line; // 当前行缓冲
while (std::getline(stream, line)) // 遍历每一行
{
// 去除前导空白
size_t firstNonSpace = line.find_first_not_of(" \t"); // 定位非空白字符
if (firstNonSpace == std::string::npos) // 空行跳过
continue;
line = line.substr(firstNonSpace); // 裁剪前导空白
if (line.find("public:") != std::string::npos) // 进入public区域
{
inPublic = true;
continue;
}
if (line.find("private:") != std::string::npos || // 离开public区域
line.find("protected:") != std::string::npos)
{
inPublic = false;
continue;
}
if (!inPublic) // 不在public区域则跳过
continue;
// 匹配方法声明: 返回类型 方法名(参数列表) [const];
std::regex methodPattern( // 方法声明正则
R"((\w[\w\s\*&:<>,]*?)\s+(\w+)\s*\(([^)]*)\)\s*(const)?\s*;)"
);
std::smatch match; // 匹配结果
if (std::regex_search(line, match, methodPattern)) // 尝试匹配
{
MethodInfo method; // 新建方法信息
method.className = classInfo.name; // 绑定所属类名
method.returnType = match[1].str(); // 提取返回类型
method.methodName = match[2].str(); // 提取方法名
method.fullSignature = match[0].str(); // 保存完整签名
method.isConst = match[4].matched; // 是否有const修饰
method.isStatic = (line.find("static") != std::string::npos); // 是否静态
method.paramTypes = extractParamTypes(match[3].str()); // 解析参数类型
// 跳过构造函数和析构函数
if (method.methodName != classInfo.name && // 不是构造函数
method.methodName[0] != '~') // 不是析构函数
{
classInfo.methods.push_back(method); // 保存方法信息
}
}
}
}
std::vector<std::string> HeaderScanner::extractParamTypes(
const std::string& paramList) const
{
std::vector<std::string> types; // 类型列表
if (paramList.empty()) // 无参数则返回空
return types;
std::istringstream stream(paramList); // 按逗号分割
std::string param; // 单个参数缓冲
while (std::getline(stream, param, ',')) // 逐个参数处理
{
// 去除首尾空白
size_t start = param.find_first_not_of(" \t"); // 定位有效起始
size_t end = param.find_last_not_of(" \t"); // 定位有效结尾
if (start == std::string::npos) // 空参数跳过
continue;
std::string trimmed = param.substr(start, end - start + 1); // 裁剪空白
// 提取类型(去掉变量名):取最后一个空格之前的部分
size_t lastSpace = trimmed.rfind(' '); // 查找最后一个空格
if (lastSpace != std::string::npos) // 有空格则分割
types.push_back(trimmed.substr(0, lastSpace)); // 取类型部分
else
types.push_back(trimmed); // 无空格则整体作为类型
}
return types; // 返回解析结果
}
const std::vector<ClassInfo>& HeaderScanner::getClasses() const
{
return classes_; // 返回扫描结果引用
}
const ClassInfo* HeaderScanner::findClass(const std::string& name) const
{
for (const auto& cls : classes_) // 遍历查找
{
if (cls.name == name) // 名称匹配
return &cls; // 返回指针
}
return nullptr; // 未找到返回空
}
void HeaderScanner::clear()
{
classes_.clear(); // 清空数据
}
核心代码:测试提示词构建器
有了类和方法的结构化信息,下一步是把它变成高质量的提示词。这是整个流水线效果好坏的关键。
testpromptbuilder.h
cpp
#ifndef TEST_PROMPT_BUILDER_H
#define TEST_PROMPT_BUILDER_H
/// @file testpromptbuilder.h
/// @brief 单元测试专用提示词构建器 v1.0
/// @details 基于扫描到的类信息自动构建GoogleTest风格的测试生成提示词
/// @usage
/// TestPromptBuilder builder;
/// builder.setTestFramework("GoogleTest");
/// auto prompt = builder.buildForClass(classInfo, headerContent);
#include "headerscanner.h"
#include <string>
class TestPromptBuilder
{
public:
/// @brief 设置测试框架名称
void setTestFramework(const std::string& framework); // 如"GoogleTest"
/// @brief 设置项目特定的构建约束
void setBuildConstraints(const std::string& constraints); // 如"C++17, 禁用RTTI"
/// @brief 为指定类构建测试生成的系统消息
std::string buildSystemMessage() const; // 生成system prompt
/// @brief 为指定类构建测试生成的用户消息
std::string buildForClass( // 生成user prompt
const ClassInfo& classInfo, // 目标类信息
const std::string& headerContent // 原始头文件内容
) const;
/// @brief 为编译失败构建修正提示词
std::string buildFixPrompt( // 生成修正prompt
const std::string& testCode, // 编译失败的测试代码
const std::string& compileError // 编译器错误信息
) const;
private:
std::string framework_ = "GoogleTest"; // 测试框架
std::string buildConstraints_; // 构建约束
/// @brief 生成测试用例清单
std::string generateTestCaseList( // 基于方法列表生成测试清单
const ClassInfo& classInfo
) const;
};
#endif // TEST_PROMPT_BUILDER_H
testpromptbuilder.cpp
cpp
#include "testpromptbuilder.h"
#include <sstream>
void TestPromptBuilder::setTestFramework(const std::string& framework)
{
framework_ = framework; // 存储框架名称
}
void TestPromptBuilder::setBuildConstraints(const std::string& constraints)
{
buildConstraints_ = constraints; // 存储构建约束
}
std::string TestPromptBuilder::buildSystemMessage() const
{
std::ostringstream oss; // 拼接系统消息
oss << "You are a senior C++ test engineer.\n" // 角色设定
<< "You write thorough, production-quality unit tests.\n\n"
<< "Rules:\n" // 测试编写规则
<< "- Framework: " << framework_ << "\n" // 使用的框架
<< "- Test all public methods\n" // 测试所有公开方法
<< "- Cover: normal case, boundary, error/exception, nullptr\n" // 覆盖场景
<< "- Each TEST should test ONE behavior\n" // 单一职责
<< "- Use descriptive test names: MethodName_Scenario_Expected\n" // 命名规范
<< "- Include necessary headers only\n" // 最小依赖
<< "- Must compile with C++17\n"; // 编译标准
if (!buildConstraints_.empty()) // 如果有额外约束
oss << "- Build constraints: "
<< buildConstraints_ << "\n"; // 注入约束
oss << "\nOutput ONLY the test code. " // 输出约束
<< "No explanations. No markdown fences.\n"; // 禁止多余文本
return oss.str(); // 返回系统消息
}
std::string TestPromptBuilder::generateTestCaseList(const ClassInfo& classInfo) const
{
std::ostringstream oss; // 拼接测试清单
for (const auto& method : classInfo.methods) // 遍历每个公开方法
{
oss << "- " << method.methodName << "("; // 方法名
for (size_t i = 0; i < method.paramTypes.size(); ++i) // 参数类型列表
{
if (i > 0) oss << ", "; // 逗号分隔
oss << method.paramTypes[i]; // 参数类型
}
oss << ")"; // 结束括号
if (method.isConst) oss << " const"; // const标记
oss << " -> " << method.returnType << "\n"; // 返回类型
// 建议的测试场景
oss << " Suggested tests:\n"; // 建议测试项
oss << " * Normal case\n"; // 正常路径
// 有参数的方法建议边界测试
if (!method.paramTypes.empty()) // 有参数的方法
{
for (const auto& ptype : method.paramTypes) // 遍历参数类型
{
if (ptype.find("string") != std::string::npos) // 字符串参数
oss << " * Empty string input\n"; // 空字符串边界
if (ptype.find("int") != std::string::npos || // 整型参数
ptype.find("size_t") != std::string::npos)
oss << " * Zero / negative / max value\n"; // 数值边界
if (ptype.find("*") != std::string::npos) // 指针参数
oss << " * Nullptr input\n"; // 空指针测试
}
}
// 返回bool的方法建议正反测试
if (method.returnType == "bool") // 布尔返回值
oss << " * True case and false case\n"; // 正反路径
}
return oss.str(); // 返回测试清单
}
std::string TestPromptBuilder::buildForClass(const ClassInfo& classInfo,
const std::string& headerContent) const
{
std::ostringstream oss; // 拼接用户消息
oss << "Generate " << framework_ // 开头指令
<< " unit tests for class `"
<< classInfo.name << "`.\n\n"; // 目标类名
oss << "## Header file content:\n" // 原始头文件
<< "```cpp\n" << headerContent
<< "\n```\n\n";
oss << "## Methods to test:\n" // 需要测试的方法
<< generateTestCaseList(classInfo) << "\n"; // 注入测试清单
oss << "## Requirements:\n" // 额外要求
<< "- Create the test class instance in each TEST or use a fixture\n" // 实例化方式
<< "- If the class needs file I/O, create temp files in tests\n" // 文件IO处理
<< "- Clean up any resources after tests\n" // 资源清理
<< "- Add comments for each test explaining what it verifies\n"; // 注释要求
if (classInfo.hasDefaultConstructor) // 有默认构造
oss << "- Class has a default constructor, use it directly\n";
else
oss << "- Class has NO default constructor, check header for required params\n";
return oss.str(); // 返回用户消息
}
std::string TestPromptBuilder::buildFixPrompt(const std::string& testCode,
const std::string& compileError) const
{
std::ostringstream oss; // 拼接修正提示
oss << "The following test code failed to compile.\n\n" // 说明情况
<< "## Test code:\n```cpp\n" // 原始测试代码
<< testCode << "\n```\n\n"
<< "## Compiler error:\n```\n" // 编译器错误
<< compileError << "\n```\n\n"
<< "Fix the code so it compiles. " // 修正指令
<< "Output ONLY the corrected test code. " // 输出约束
<< "No explanations.\n";
return oss.str(); // 返回修正提示
}
使用示例:
cpp
#include "headerscanner.h"
#include "testpromptbuilder.h"
#include <iostream>
#include <fstream>
int main()
{
// 第一步:扫描头文件
HeaderScanner scanner; // 创建扫描器
scanner.scan("configmanager.h"); // 扫描目标头文件
// 第二步:构建提示词
TestPromptBuilder builder; // 创建提示词构建器
builder.setTestFramework("GoogleTest"); // 指定测试框架
builder.setBuildConstraints("C++17, no RTTI"); // 指定构建约束
// 读取头文件原文
std::ifstream file("configmanager.h"); // 打开头文件
std::ostringstream oss; // 内容缓冲
oss << file.rdbuf(); // 读取全部内容
std::string headerContent = oss.str(); // 获取内容字符串
// 为每个类生成提示词
for (const auto& cls : scanner.getClasses()) // 遍历扫描到的类
{
std::string systemMsg = builder.buildSystemMessage(); // 系统消息
std::string userMsg = builder.buildForClass(cls, headerContent); // 用户消息
std::cout << "=== Generating tests for: " // 输出当前处理的类
<< cls.name << " ===\n"
<< "Methods found: " // 输出方法数量
<< cls.methods.size() << "\n\n";
// 在实际流水线中,这里会调用LLM API
// auto testCode = llmClient.chat(systemMsg, userMsg);
// compileChecker.verify(testCode);
std::cout << "System prompt length: " // 输出提示词长度
<< systemMsg.size() << " chars\n"
<< "User prompt length: "
<< userMsg.size() << " chars\n\n";
}
return 0;
}
生成效果:真实案例
拿我上一篇文章里的ConfigManager类举例。把它的头文件丢进流水线,AI生成了这样的测试(摘取关键部分):
cpp
#include <gtest/gtest.h>
#include "configmanager.h"
#include <fstream>
#include <cstdio>
/// @brief 测试夹具:创建和清理临时配置文件
class ConfigManagerTest : public ::testing::Test
{
protected:
ConfigManager cfg; // 被测对象
std::string tempFile = "test_config.ini"; // 临时文件路径
/// @brief 创建临时INI文件
void createTempConfig(const std::string& content) // 写入指定内容
{
std::ofstream file(tempFile); // 创建文件
file << content; // 写入内容
}
/// @brief 清理临时文件
void TearDown() override // 每个测试后执行
{
std::remove(tempFile.c_str()); // 删除临时文件
}
};
/// @brief 正常加载有效的INI文件
TEST_F(ConfigManagerTest, Load_ValidFile_ReturnsTrue)
{
createTempConfig("[database]\nport=3306\nhost=localhost\n"); // 创建有效配置
EXPECT_TRUE(cfg.load(tempFile)); // 期望加载成功
}
/// @brief 加载不存在的文件应返回false
TEST_F(ConfigManagerTest, Load_NonexistentFile_ReturnsFalse)
{
EXPECT_FALSE(cfg.load("nonexistent.ini")); // 期望加载失败
}
/// @brief 加载空文件应返回true(空配置也是合法配置)
TEST_F(ConfigManagerTest, Load_EmptyFile_ReturnsTrue)
{
createTempConfig(""); // 创建空文件
EXPECT_TRUE(cfg.load(tempFile)); // 期望加载成功
}
/// @brief 查询存在的键应返回正确值
TEST_F(ConfigManagerTest, GetValue_ExistingKey_ReturnsValue)
{
createTempConfig("[db]\nport=5432\n"); // 创建带数据的配置
cfg.load(tempFile); // 加载配置
EXPECT_EQ(cfg.getValue("db", "port"), "5432"); // 验证查询结果
}
/// @brief 查询不存在的键应返回默认值
TEST_F(ConfigManagerTest, GetValue_MissingKey_ReturnsDefault)
{
createTempConfig("[db]\nport=5432\n"); // 创建配置
cfg.load(tempFile); // 加载配置
EXPECT_EQ(cfg.getValue("db", "timeout", "30"), "30"); // 验证默认值回退
}
/// @brief 查询不存在的分组应返回默认值
TEST_F(ConfigManagerTest, GetValue_MissingSection_ReturnsDefault)
{
createTempConfig("[db]\nport=5432\n"); // 只有db分组
cfg.load(tempFile); // 加载配置
EXPECT_EQ(cfg.getValue("cache", "size", "0"), "0"); // 查cache分组
}
/// @brief 值中包含等号应正确解析
TEST_F(ConfigManagerTest, GetValue_ValueContainsEquals_ParsesCorrectly)
{
createTempConfig("[app]\nconnstr=host=localhost;port=3306\n"); // 值含等号
cfg.load(tempFile); // 加载配置
std::string val = cfg.getValue("app", "connstr"); // 查询
EXPECT_FALSE(val.empty()); // 值不应为空
}
/// @brief 有效分组应返回true
TEST_F(ConfigManagerTest, HasSection_ExistingSection_ReturnsTrue)
{
createTempConfig("[server]\nport=80\n"); // 创建配置
cfg.load(tempFile); // 加载配置
EXPECT_TRUE(cfg.hasSection("server")); // 分组存在
}
/// @brief 不存在的分组应返回false
TEST_F(ConfigManagerTest, HasSection_MissingSection_ReturnsFalse)
{
createTempConfig("[server]\nport=80\n"); // 创建配置
cfg.load(tempFile); // 加载配置
EXPECT_FALSE(cfg.hasSection("client")); // 分组不存在
}
/// @brief 注释行应被正确忽略
TEST_F(ConfigManagerTest, Load_CommentLines_Ignored)
{
createTempConfig("; this is a comment\n# another comment\n[app]\nname=test\n");
cfg.load(tempFile); // 加载配置
EXPECT_EQ(cfg.getValue("app", "name"), "test"); // 注释不影响解析
EXPECT_FALSE(cfg.hasKey("", "; this is a comment")); // 注释不被当作键
}
这段测试代码是AI一次性生成的,编译直接通过。注意几个亮点:用了测试夹具管理临时文件、命名遵循Method_Scenario_Expected规范、覆盖了正常路径+异常路径+边界情况。尤其是"值中包含等号"这个用例------这恰好是我在第一篇文章里提到过的一个ConfigManager的已知问题,AI自己想到了。
覆盖率变化
用了八周时间在我们的代码仓库上跑这套流水线,覆盖率变化如下:

纯靠人工补测试(灰线),八周只从35%爬到44%。用AI生成加人工校验(紫线),同期从35%提升到80%。差距的核心原因不是AI写得多好,而是它没有心理负担------让人去给一个三年前的老类补测试,大家都会拖;让AI去做,十秒钟就完事。
真正查出来的Bug
覆盖率是表面数字。更重要的问题是:AI生成的测试到底能不能查出真实Bug?
答案是能,而且在某些类型上比人工测试效果好得多。

最有价值的是两类检出:
空指针解引用 ------AI会系统性地对每个指针参数生成nullptr输入的测试。人写测试时经常"想当然"觉得调用者不会传空指针,但实际上线上崩溃有30%以上都是空指针。
资源泄漏------AI生成的测试会覆盖"构造成功但某个方法调用失败"的路径,这种半初始化状态是最容易泄漏资源的场景。人写测试时基本不会去测"用到一半出错了怎么办"。
这套方案的局限
最后说说哪些地方还做不好,避免给人一种"银弹"的错觉。
依赖复杂的类很难测。 如果一个类的构造需要注入三四个依赖,还需要初始化数据库连接、启动线程池之类的环境,AI生成的测试大概率编译不过。这类情况你需要手动写Mock,或者先重构代码降低耦合度,再让AI生成测试。
业务逻辑的正确性验证靠不住。 AI能测"这个函数不崩溃""返回值不为空",但测不了"这个计算结果在业务上是对的"。涉及业务规则的断言,还是得人来写。
模板密集型代码跳过。 和前两篇文章的结论一致,涉及大量模板技巧的代码,AI的生成质量不可控,不如不生成。
完整方案的投入产出
最后算一笔账。整个流水线我花了两个周末加上几个工作日的晚上,大约40小时写完。核心代码量大约1200行C++。
每月API调用成本约150元(我们的代码库规模)。维护成本很低,上线后基本不需要改。
回报是:测试覆盖率从35%提升到80%,八周内AI辅助发现了39个之前没有测试覆盖的Bug,其中8个被评估为线上高风险。
这笔账怎么算都值。如果你的团队也有类似的测试债务问题,我建议不要犹豫,直接动手做。最贵的成本不是API调用费,而是"再等等"。
本文为个人项目实践分享,代码经过简化处理。正则解析方案适用于中小型项目,大型项目建议使用libclang做精确的AST解析。