C++ 测试驱动开发(TDD):编写健壮可靠的代码

好的,各位观众老爷们,今天咱们来聊聊 C++ 界的“先上车后买票”——测试驱动开发(TDD)。别害怕,不是让你真的先花钱再看电影,而是先写测试,再写代码。听起来有点反直觉,但信我,这玩意儿能让你少掉头发,代码更靠谱。

啥是 TDD?别跟我拽英文!

TDD,Test-Driven Development 的缩写。简单来说,就是按照“红-绿-重构”的循环来写代码:

  1. 红(Red): 先写一个测试用例,这个测试用例肯定会失败,因为对应的功能还没实现呢!想象一下,你给你的代码提了个需求,它现在还做不到,所以报错了,一片红。

  2. 绿(Green): 用最快的速度,写出能让测试用例通过的代码。注意,是“最快”,不是“最好”。别想着一步到位,先让它跑起来再说。这时候,你的代码终于能满足需求了,测试通过,一片绿油油的。

  3. 重构(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::stringstreamstd::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 高手!
下次见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注