10、TDD实战-字符串工具库:用TDD开发字符串分割、反转、大小写转换等工具函数
字符串处理,几乎是每个C++项目都绕不开的活儿。我见过太多团队,上来就撸一个StringUtil类,塞满各种静态方法,然后扔给QA去测。结果呢?边界条件炸裂,Unicode乱码,空指针崩溃……说白了,就是没把测试当回事儿。
这一章,咱们就实打实地用TDD来开发一个字符串工具库。目标很明确:先写测试,再写代码,每一步都有红绿重构的节奏感。我会带着你从最基础的分割函数开始,一步步构建出反转、大小写转换等实用工具。
10.1 项目结构与测试框架选型
我个人习惯用Google Test作为单元测试框架。它成熟、文档全,而且对TDD的支持很友好。项目结构大致如下:
string_utils/
├── include/
│ └── string_utils.h // 工具函数声明
├── src/
│ └── string_utils.cpp // 工具函数实现
├── tests/
│ └── string_utils_test.cpp // 单元测试
└── CMakeLists.txt
嗯,这里要注意:测试文件要和源码文件一一对应。这样当你修改某个函数时,能立刻定位到对应的测试用例。
10.2 第一轮TDD:字符串分割(Split)
分割函数是最常用的,但也是最容易出错的。我记得有一次在项目中,一个同事写的分割函数在遇到连续分隔符时直接返回空字符串,导致下游解析逻辑全部崩溃。从那以后,我对分割函数的测试就格外严格。
10.2.1 先写测试用例
我们先列出所有要考虑的场景:
| 测试场景 | 输入 | 分隔符 | 期望输出 |
|---|---|---|---|
| 正常分割 | "a,b,c" | ',' | ["a", "b", "c"] |
| 空字符串 | "" | ',' | [] |
| 无分隔符 | "abc" | ',' | ["abc"] |
| 连续分隔符 | "a,,b,c" | ',' | ["a", "", "b", "c"] |
| 分隔符在首尾 | ",a,b," | ',' | ["", "a", "b", ""] |
你想想看,如果这些边界条件没覆盖到,上线后出bug的概率有多大?所以,测试用例一定要写全。
对应的测试代码(使用Google Test):
#include <gtest/gtest.h>
#include "string_utils.h"
TEST(StringUtilsTest, Split_NormalCase) {
auto result = Split("a,b,c", ',');
ASSERT_EQ(result.size(), 3);
EXPECT_EQ(result[0], "a");
EXPECT_EQ(result[1], "b");
EXPECT_EQ(result[2], "c");
}
TEST(StringUtilsTest, Split_EmptyString) {
auto result = Split("", ',');
EXPECT_TRUE(result.empty());
}
TEST(StringUtilsTest, Split_NoDelimiter) {
auto result = Split("abc", ',');
ASSERT_EQ(result.size(), 1);
EXPECT_EQ(result[0], "abc");
}
TEST(StringUtilsTest, Split_ConsecutiveDelimiters) {
auto result = Split("a,,b,c", ',');
ASSERT_EQ(result.size(), 4);
EXPECT_EQ(result[0], "a");
EXPECT_EQ(result[1], "");
EXPECT_EQ(result[2], "b");
EXPECT_EQ(result[3], "c");
}
TEST(StringUtilsTest, Split_DelimiterAtEdges) {
auto result = Split(",a,b,", ',');
ASSERT_EQ(result.size(), 4);
EXPECT_EQ(result[0], "");
EXPECT_EQ(result[1], "a");
EXPECT_EQ(result[2], "b");
EXPECT_EQ(result[3], "");
}
10.2.2 编写最小实现
测试写好了,现在运行一下——当然是全部失败(红色)。接下来,我们写最简实现让测试通过:
#include <vector>
#include <string>
#include <sstream>
std::vector<std::string> Split(const std::string& str, char delimiter) {
std::vector<std::string> tokens;
std::string token;
std::istringstream tokenStream(str);
while (std::getline(tokenStream, token, delimiter)) {
tokens.push_back(token);
}
return tokens;
}
运行测试,全部通过(绿色)。嗯,这里有个小坑:std::getline在遇到连续分隔符时,会返回空字符串,这正好符合我们的预期。但如果你的需求是忽略空字段,那就要额外处理了。
10.3 第二轮TDD:字符串反转(Reverse)
反转函数看起来简单,但如果你要处理Unicode字符(比如中文、表情符号),那就复杂了。不过这一章我们只处理ASCII字符,先打好基础。
10.3.1 测试用例
TEST(StringUtilsTest, Reverse_NormalString) {
EXPECT_EQ(Reverse("hello"), "olleh");
}
TEST(StringUtilsTest, Reverse_EmptyString) {
EXPECT_EQ(Reverse(""), "");
}
TEST(StringUtilsTest, Reverse_SingleCharacter) {
EXPECT_EQ(Reverse("a"), "a");
}
TEST(StringUtilsTest, Reverse_Palindrome) {
EXPECT_EQ(Reverse("racecar"), "racecar");
}
10.3.2 实现
std::string Reverse(const std::string& str) {
return std::string(str.rbegin(), str.rend());
}
你看,用反向迭代器一行搞定。但如果你用下标循环,就要小心边界了。我曾经见过有人写for (int i = str.size(); i >= 0; --i),结果size()返回的是无符号数,i >= 0永远为真,直接死循环。嗯,这种坑踩过一次就记住了。
10.4 第三轮TDD:大小写转换(ToUpper / ToLower)
大小写转换,说白了就是查表。但要注意,std::toupper和std::tolower的行为是依赖本地化(locale)的。在默认的"C" locale下,只处理ASCII字符。
10.4.1 测试用例
TEST(StringUtilsTest, ToUpper_NormalString) {
EXPECT_EQ(ToUpper("hello"), "HELLO");
}
TEST(StringUtilsTest, ToUpper_MixedCase) {
EXPECT_EQ(ToUpper("Hello World"), "HELLO WORLD");
}
TEST(StringUtilsTest, ToUpper_AlreadyUpper) {
EXPECT_EQ(ToUpper("HELLO"), "HELLO");
}
TEST(StringUtilsTest, ToLower_NormalString) {
EXPECT_EQ(ToLower("HELLO"), "hello");
}
TEST(StringUtilsTest, ToLower_MixedCase) {
EXPECT_EQ(ToLower("Hello World"), "hello world");
}
TEST(StringUtilsTest, ToLower_AlreadyLower) {
EXPECT_EQ(ToLower("hello"), "hello");
}
TEST(StringUtilsTest, ToUpper_EmptyString) {
EXPECT_EQ(ToUpper(""), "");
}
TEST(StringUtilsTest, ToLower_EmptyString) {
EXPECT_EQ(ToLower(""), "");
}
10.4.2 实现
#include <algorithm>
#include <cctype>
std::string ToUpper(const std::string& str) {
std::string result = str;
std::transform(result.begin(), result.end(), result.begin(),
[](unsigned char c) { return std::toupper(c); });
return result;
}
std::string ToLower(const std::string& str) {
std::string result = str;
std::transform(result.begin(), result.end(), result.begin(),
[](unsigned char c) { return std::tolower(c); });
return result;
}
unsigned char,否则当字符值大于127时,std::toupper的行为是未定义的。这是C++标准库的一个经典陷阱。
10.5 知识体系与核心逻辑
下面这张图展示了本章TDD实战的核心流程和各个函数之间的关系:
10.6 避坑指南与最佳实践
经过这几轮TDD实战,我总结了几条经验,希望能帮你少走弯路:
- 测试用例要覆盖边界:空字符串、单字符、连续分隔符、首尾特殊字符,一个都不能少。我曾经因为漏掉"连续分隔符"这个场景,导致线上数据解析错误,被运维同事追着骂了一下午。
- 每个测试只测一个逻辑点:如果一个测试用例里塞了五六个断言,一旦失败,你很难快速定位问题。拆分成多个小测试,每个测试只关注一个行为。
- 不要过度设计:TDD的核心是"刚好让测试通过"。不要一开始就想着性能优化、泛型支持。先让功能跑起来,再考虑怎么跑得更快。
- 测试也是代码,需要重构:测试代码同样会有重复和坏味道。当你发现测试代码难以维护时,说明该重构了。
10.7 小结
这一章,我们用TDD的方式,从零开始构建了一个字符串工具库。你看到了红绿重构的完整循环,也体会到了测试先行带来的安全感。说白了,TDD不是一种负担,而是一种投资——前期多花10分钟写测试,后期可能省下10小时的调试时间。
字符串处理看似简单,但边界条件多如牛毛。有了测试用例的守护,你可以放心地修改实现、优化性能,而不用担心引入回归bug。这就是TDD的魅力所在。
公众号:蓝海资料掘金营,微信deep3321