好的,各位观众老爷们,今天咱们来聊聊 C++ 界的“先上车后买票”——测试驱动开发(TDD)。别害怕,不是让你真的先花钱再看电影,而是先写测试,再写代码。听起来有点反直觉,但信我,这玩意儿能让你少掉头发,代码更靠谱。
啥是 TDD?别跟我拽英文!
TDD,Test-Driven Development 的缩写。简单来说,就是按照“红-绿-重构”的循环来写代码:
-
红(Red): 先写一个测试用例,这个测试用例肯定会失败,因为对应的功能还没实现呢!想象一下,你给你的代码提了个需求,它现在还做不到,所以报错了,一片红。
-
绿(Green): 用最快的速度,写出能让测试用例通过的代码。注意,是“最快”,不是“最好”。别想着一步到位,先让它跑起来再说。这时候,你的代码终于能满足需求了,测试通过,一片绿油油的。
-
重构(Refactor): 现在,你可以回头看看你的代码,优化一下结构,提高可读性,消除重复代码。让它变得更优雅,更易于维护。这时候,你的代码不但能干活,还长得好看。
为什么要 TDD?难道程序员都是受虐狂?
当然不是!TDD 带来的好处可多了去了:
-
代码质量更高: 先写测试,迫使你思考代码的边界情况,避免遗漏。毕竟,测试就是你的代码的“质检员”。
-
设计更清晰: TDD 让你从使用者的角度出发,设计 API 接口会更自然,更易用。想象一下,你是用户,你希望怎么用这个函数?
-
回归测试更方便: 每次修改代码后,运行测试用例,可以快速发现是否有引入新的 Bug。再也不怕改了一行代码,影响了整个系统。
-
更自信: 当所有的测试用例都通过时,你会对你的代码充满信心。晚上睡觉都更香了!
废话少说,上代码!
咱们来做一个简单的例子:一个 Calculator
类,它有一个 add
方法,可以计算两个整数的和。
1. 红:先写测试
首先,我们需要一个测试框架。这里我们用 Google Test (gtest)。 如果你没有安装 gtest,你需要先安装它。
这里假设你已经安装好gtest
#include "gtest/gtest.h"
#include "calculator.h" // 假设 calculator.h 包含了 Calculator 类的定义
TEST(CalculatorTest, AddTwoPositiveNumbers) {
Calculator calculator;
ASSERT_EQ(5, calculator.add(2, 3)); // 断言:2 + 3 应该等于 5
}
TEST(CalculatorTest, AddTwoNegativeNumbers) {
Calculator calculator;
ASSERT_EQ(-5, calculator.add(-2, -3)); // 断言:-2 + -3 应该等于 -5
}
TEST(CalculatorTest, AddPositiveAndNegativeNumbers) {
Calculator calculator;
ASSERT_EQ(1, calculator.add(3, -2)); // 断言:3 + -2 应该等于 1
}
TEST(CalculatorTest, AddZero) {
Calculator calculator;
ASSERT_EQ(5, calculator.add(5, 0)); // 断言:5 + 0 应该等于 5
}
解释一下:
#include "gtest/gtest.h"
: 引入 Google Test 头文件。TEST(CalculatorTest, AddTwoPositiveNumbers)
: 定义一个测试用例,CalculatorTest
是测试套件的名字,AddTwoPositiveNumbers
是测试用例的名字。Calculator calculator;
: 创建一个Calculator
对象。ASSERT_EQ(5, calculator.add(2, 3));
: 断言:calculator.add(2, 3)
的返回值应该等于 5。如果不一样,测试就会失败。
现在,如果你编译并运行这个测试,它会失败,因为我们还没有实现 Calculator
类和 add
方法。
2. 绿:让测试通过
接下来,我们要用最快的速度,写出能让测试通过的代码。
// calculator.h
#ifndef CALCULATOR_H
#define CALCULATOR_H
class Calculator {
public:
int add(int a, int b);
};
#endif
// calculator.cpp
#include "calculator.h"
int Calculator::add(int a, int b) {
return a + b;
}
解释一下:
calculator.h
: 定义了Calculator
类的声明。calculator.cpp
: 实现了Calculator
类的add
方法。
现在,重新编译并运行测试,所有的测试用例都应该通过了!一片绿油油的!
3. 重构:优化代码
现在,我们可以回头看看我们的代码,看看有没有可以优化的地方。
在这个例子中,我们的代码已经很简单了,没有什么需要优化的。但是在实际项目中,你可能会遇到更复杂的情况,需要重构代码,提高可读性,消除重复代码。
TDD 的进阶技巧
- 每次只写一个测试用例: 不要一次性写很多测试用例,每次只写一个,确保这个测试用例能通过后再写下一个。
- 从小处着手: 从最简单的功能开始,逐步增加复杂度。
- 保持测试代码简洁易懂: 测试代码也要像生产代码一样,保持简洁易懂。
- 使用 Mock 对象: 当你的代码依赖于外部系统时,可以使用 Mock 对象来模拟外部系统的行为。
一个更复杂的例子:字符串计算器
让我们来做一个稍微复杂一点的例子:一个字符串计算器。它可以接收一个包含数字的字符串,并计算这些数字的和。
例如:
""
应该返回 0"1"
应该返回 1"1,2"
应该返回 3
1. 红:先写测试
#include "gtest/gtest.h"
#include "string_calculator.h"
TEST(StringCalculatorTest, EmptyString) {
StringCalculator calculator;
ASSERT_EQ(0, calculator.add(""));
}
TEST(StringCalculatorTest, SingleNumber) {
StringCalculator calculator;
ASSERT_EQ(1, calculator.add("1"));
}
TEST(StringCalculatorTest, TwoNumbers) {
StringCalculator calculator;
ASSERT_EQ(3, calculator.add("1,2"));
}
2. 绿:让测试通过
// string_calculator.h
#ifndef STRING_CALCULATOR_H
#define STRING_CALCULATOR_H
#include <string>
class StringCalculator {
public:
int add(const std::string& numbers);
};
#endif
// string_calculator.cpp
#include "string_calculator.h"
#include <sstream>
#include <numeric>
#include <vector>
int StringCalculator::add(const std::string& numbers) {
if (numbers.empty()) {
return 0;
}
std::stringstream ss(numbers);
std::string token;
int sum = 0;
while (std::getline(ss, token, ',')) {
sum += std::stoi(token);
}
return sum;
}
解释一下:
- 如果输入字符串为空,返回 0。
- 使用
std::stringstream
和std::getline
将字符串分割成数字。 - 使用
std::stoi
将字符串转换为整数。 - 计算所有数字的和。
3. 重构:优化代码
现在,我们可以回头看看我们的代码,看看有没有可以优化的地方。
例如,我们可以将字符串分割成数字的代码提取到一个单独的函数中:
// string_calculator.cpp
#include "string_calculator.h"
#include <sstream>
#include <numeric>
#include <vector>
namespace {
std::vector<int> parseNumbers(const std::string& numbers) {
std::vector<int> result;
std::stringstream ss(numbers);
std::string token;
while (std::getline(ss, token, ',')) {
result.push_back(std::stoi(token));
}
return result;
}
}
int StringCalculator::add(const std::string& numbers) {
if (numbers.empty()) {
return 0;
}
std::vector<int> parsedNumbers = parseNumbers(numbers);
return std::accumulate(parsedNumbers.begin(), parsedNumbers.end(), 0);
}
解释一下:
parseNumbers
函数将字符串分割成数字,并返回一个std::vector<int>
。std::accumulate
函数计算std::vector<int>
中所有数字的和。
这样,我们的代码就更简洁易懂了。
更进一步:处理不同的分隔符
现在,让我们来增加一些复杂度:允许字符串使用不同的分隔符,例如 ;
。
例如:
"1;2"
应该返回 3
1. 红:先写测试
TEST(StringCalculatorTest, DifferentDelimiter) {
StringCalculator calculator;
ASSERT_EQ(3, calculator.add("1;2"));
}
2. 绿:让测试通过
// string_calculator.cpp
#include "string_calculator.h"
#include <sstream>
#include <numeric>
#include <vector>
#include <algorithm>
namespace {
std::vector<int> parseNumbers(const std::string& numbers, char delimiter) {
std::vector<int> result;
std::stringstream ss(numbers);
std::string token;
while (std::getline(ss, token, delimiter)) {
result.push_back(std::stoi(token));
}
return result;
}
}
int StringCalculator::add(const std::string& numbers) {
if (numbers.empty()) {
return 0;
}
char delimiter = ',';
if (numbers.find(';') != std::string::npos) {
delimiter = ';';
}
std::vector<int> parsedNumbers = parseNumbers(numbers, delimiter);
return std::accumulate(parsedNumbers.begin(), parsedNumbers.end(), 0);
}
解释一下:
- 首先,我们检查字符串中是否包含
;
。如果包含,则将分隔符设置为;
。 - 然后,我们调用
parseNumbers
函数,并将分隔符作为参数传递。
3. 重构:优化代码
现在,我们可以回头看看我们的代码,看看有没有可以优化的地方。
例如,我们可以将分隔符的判断逻辑提取到一个单独的函数中:
// string_calculator.cpp
#include "string_calculator.h"
#include <sstream>
#include <numeric>
#include <vector>
#include <algorithm>
namespace {
char determineDelimiter(const std::string& numbers) {
if (numbers.find(';') != std::string::npos) {
return ';';
}
return ',';
}
std::vector<int> parseNumbers(const std::string& numbers, char delimiter) {
std::vector<int> result;
std::stringstream ss(numbers);
std::string token;
while (std::getline(ss, token, delimiter)) {
result.push_back(std::stoi(token));
}
return result;
}
}
int StringCalculator::add(const std::string& numbers) {
if (numbers.empty()) {
return 0;
}
char delimiter = determineDelimiter(numbers);
std::vector<int> parsedNumbers = parseNumbers(numbers, delimiter);
return std::accumulate(parsedNumbers.begin(), parsedNumbers.end(), 0);
}
这样,我们的代码就更清晰易懂了。
TDD 的一些注意事项
- 不要过度测试: 测试的目的是为了保证代码的质量,而不是为了追求测试覆盖率。
- 不要测试私有方法: 私有方法是类的内部实现细节,不应该被直接测试。你应该测试的是类的公共接口。
- 保持测试代码的可维护性: 测试代码也需要维护,所以要尽量保持测试代码的简洁易懂。
总结
TDD 是一种非常有效的开发方法,它可以帮助你编写更高质量、更易于维护的代码。虽然一开始可能会觉得有点麻烦,但是一旦你掌握了 TDD 的技巧,你会发现它能大大提高你的开发效率。
希望今天的讲座对你有所帮助!记住,多练习,多实践,你也能成为 TDD 高手!
下次见!