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在遇到连续分隔符时,会返回空字符串,这正好符合我们的预期。但如果你的需求是忽略空字段,那就要额外处理了。

小技巧: 在TDD中,不要一开始就追求完美实现。先让测试通过,再考虑优化。这叫"红-绿-重构"三步曲。

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::toupperstd::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;
}
注意: lambda参数一定要用unsigned char,否则当字符值大于127时,std::toupper的行为是未定义的。这是C++标准库的一个经典陷阱。

10.5 知识体系与核心逻辑

下面这张图展示了本章TDD实战的核心流程和各个函数之间的关系:

TDD实战:字符串工具库核心流程 🔴 红:写测试用例 🟢 绿:写最小实现 🔵 重构:优化代码 迭代循环,直到所有功能完成 字符串工具函数 Split(分割) 处理连续分隔符、空字段 Reverse(反转) 反向迭代器实现 ToUpper/ToLower 注意unsigned char陷阱 测试驱动开发:先写测试 → 再写实现 → 持续重构

10.6 避坑指南与最佳实践

经过这几轮TDD实战,我总结了几条经验,希望能帮你少走弯路:

  • 测试用例要覆盖边界:空字符串、单字符、连续分隔符、首尾特殊字符,一个都不能少。我曾经因为漏掉"连续分隔符"这个场景,导致线上数据解析错误,被运维同事追着骂了一下午。
  • 每个测试只测一个逻辑点:如果一个测试用例里塞了五六个断言,一旦失败,你很难快速定位问题。拆分成多个小测试,每个测试只关注一个行为。
  • 不要过度设计:TDD的核心是"刚好让测试通过"。不要一开始就想着性能优化、泛型支持。先让功能跑起来,再考虑怎么跑得更快。
  • 测试也是代码,需要重构:测试代码同样会有重复和坏味道。当你发现测试代码难以维护时,说明该重构了。
我的习惯: 每次提交代码前,我会先运行一遍所有测试。如果测试不通过,绝不提交。这听起来很基础,但很多团队都做不到。

10.7 小结

这一章,我们用TDD的方式,从零开始构建了一个字符串工具库。你看到了红绿重构的完整循环,也体会到了测试先行带来的安全感。说白了,TDD不是一种负担,而是一种投资——前期多花10分钟写测试,后期可能省下10小时的调试时间。

字符串处理看似简单,但边界条件多如牛毛。有了测试用例的守护,你可以放心地修改实现、优化性能,而不用担心引入回归bug。这就是TDD的魅力所在。


公众号:蓝海资料掘金营,微信deep3321