跳到主要内容

C++ 单元测试

什么是单元测试?

单元测试是软件测试的一种方法,它检查源代码中的最小可测试单元(通常是函数或方法)是否按照设计正确工作。在C++中,一个"单元"通常是一个类、一个函数或甚至是一个模板。

备注

单元测试的主要目的是验证代码的每个部分都能独立正确地工作,这有助于尽早发现并修复问题。

单元测试的好处包括:

  • 早期发现bug:在开发周期的早期阶段发现并修复错误
  • 简化调试:当测试失败时,你可以快速定位问题所在
  • 重构保障:修改代码后,可以通过运行测试确保没有破坏原有功能
  • 文档作用:测试代码本身可以作为代码使用方式的文档

C++ 单元测试框架简介

C++生态系统中有多种单元测试框架可供选择,最流行的包括:

  1. GoogleTest (GTest) - Google开发的C++测试框架
  2. Catch2 - 轻量级的头文件库
  3. Boost.Test - Boost库的一部分
  4. doctest - 轻量级且快速的测试框架

本文我们将主要关注GoogleTest,因为它功能全面且被广泛使用。

设置GoogleTest

安装

在开始使用GoogleTest之前,需要先安装它。不同系统的安装方法如下:

使用CMake(推荐)

  1. 首先,克隆GoogleTest代码库:
bash
git clone https://github.com/google/googletest.git
  1. 在你的CMakeLists.txt中添加:
cmake
# 添加GoogleTest
add_subdirectory(path/to/googletest)

# 链接测试可执行文件
add_executable(my_test test.cpp)
target_link_libraries(my_test gtest gtest_main)

第一个测试用例

下面是一个简单的例子,展示如何编写基本的测试用例:

cpp
#include <gtest/gtest.h>

// 要测试的函数
int Add(int a, int b) {
return a + b;
}

// 测试用例
TEST(AddFunctionTest, PositiveNumbers) {
EXPECT_EQ(3, Add(1, 2));
}

TEST(AddFunctionTest, NegativeNumbers) {
EXPECT_EQ(-3, Add(-1, -2));
}

int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

输出示例:

[==========] Running 2 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 2 tests from AddFunctionTest
[ RUN ] AddFunctionTest.PositiveNumbers
[ OK ] AddFunctionTest.PositiveNumbers (0 ms)
[ RUN ] AddFunctionTest.NegativeNumbers
[ OK ] AddFunctionTest.NegativeNumbers (0 ms)
[----------] 2 tests from AddFunctionTest (0 ms total)

[----------] Global test environment tear-down
[==========] 2 tests from 1 test suite ran. (0 ms total)
[ PASSED ] 2 tests.

GoogleTest的核心概念

断言

GoogleTest提供了两类断言:

  1. ASSERT_ 系列:致命断言,失败时终止当前函数
  2. EXPECT_ 系列:非致命断言,失败时继续执行

常用的断言包括:

cpp
// 基本比较
EXPECT_EQ(expected, actual); // 期望相等
EXPECT_NE(expected, actual); // 期望不相等
EXPECT_LT(val1, val2); // 期望小于
EXPECT_LE(val1, val2); // 期望小于等于
EXPECT_GT(val1, val2); // 期望大于
EXPECT_GE(val1, val2); // 期望大于等于

// 真假断言
EXPECT_TRUE(condition); // 期望条件为真
EXPECT_FALSE(condition); // 期望条件为假

// 字符串比较
EXPECT_STREQ(expected, actual); // 期望两个C字符串内容相等
EXPECT_STRNE(expected, actual); // 期望两个C字符串内容不相等

// 浮点数比较
EXPECT_FLOAT_EQ(expected, actual); // 期望两个float几乎相等
EXPECT_DOUBLE_EQ(expected, actual); // 期望两个double几乎相等
提示

对于浮点数比较,使用 EXPECT_FLOAT_EQEXPECT_DOUBLE_EQ 而不是 EXPECT_EQ,因为浮点计算可能有精度误差。

测试夹具(Test Fixtures)

当多个测试需要相同的设置和清理代码时,可以使用测试夹具:

cpp
#include <gtest/gtest.h>
#include <vector>

// 创建一个测试夹具类
class VectorTest : public ::testing::Test {
protected:
// 在每个测试之前执行
void SetUp() override {
v1.push_back(1);
v1.push_back(2);
v2.push_back(3);
}

// 在每个测试之后执行
void TearDown() override {
// 清理代码如有需要
}

// 测试夹具中的成员在测试中可用
std::vector<int> v1;
std::vector<int> v2;
};

// 使用TEST_F而不是TEST来使用测试夹具
TEST_F(VectorTest, SizeCheck) {
EXPECT_EQ(2, v1.size());
EXPECT_EQ(1, v2.size());
}

TEST_F(VectorTest, ContentCheck) {
EXPECT_EQ(1, v1[0]);
EXPECT_EQ(3, v2[0]);
}

参数化测试

当需要用不同的输入值测试同一段逻辑时,参数化测试非常有用:

cpp
#include <gtest/gtest.h>

// 要测试的函数
bool IsPrime(int n) {
if (n <= 1) return false;
if (n <= 3) return true;
if (n % 2 == 0 || n % 3 == 0) return false;

for (int i = 5; i * i <= n; i += 6) {
if (n % i == 0 || n % (i + 2) == 0) return false;
}
return true;
}

// 创建参数化测试类
class PrimeTest : public ::testing::TestWithParam<std::tuple<int, bool>> {};

// 定义参数化测试
TEST_P(PrimeTest, CheckPrime) {
int input = std::get<0>(GetParam());
bool expected = std::get<1>(GetParam());
EXPECT_EQ(expected, IsPrime(input));
}

// 实例化参数化测试用例
INSTANTIATE_TEST_SUITE_P(
PrimeTests,
PrimeTest,
::testing::Values(
std::make_tuple(2, true),
std::make_tuple(3, true),
std::make_tuple(4, false),
std::make_tuple(5, true),
std::make_tuple(15, false),
std::make_tuple(17, true)
)
);

测试复杂代码

模拟对象(Mocks)

当测试依赖于其他组件时,我们可以使用GoogleMock创建模拟对象:

cpp
#include <gtest/gtest.h>
#include <gmock/gmock.h>

// 定义接口
class Database {
public:
virtual ~Database() {}
virtual bool Connect() = 0;
virtual bool Query(const std::string& query, std::vector<std::string>* results) = 0;
virtual void Disconnect() = 0;
};

// 创建模拟类
class MockDatabase : public Database {
public:
MOCK_METHOD(bool, Connect, (), (override));
MOCK_METHOD(bool, Query, (const std::string& query, std::vector<std::string>* results), (override));
MOCK_METHOD(void, Disconnect, (), (override));
};

// 要测试的类
class UserManager {
public:
UserManager(Database* db) : db_(db) {}

bool GetUserInfo(const std::string& username, std::vector<std::string>* info) {
if (!db_->Connect()) return false;
bool result = db_->Query("SELECT * FROM users WHERE name='" + username + "'", info);
db_->Disconnect();
return result;
}

private:
Database* db_;
};

// 测试用例
TEST(UserManagerTest, GetUserInfoSuccessful) {
// 创建模拟对象
MockDatabase mockDB;

// 设置预期行为
EXPECT_CALL(mockDB, Connect()).WillOnce(testing::Return(true));
EXPECT_CALL(mockDB, Query(testing::_, testing::_))
.WillOnce(testing::DoAll(
testing::SetArgPointee<1>(std::vector<std::string>{"John", "30", "Engineer"}),
testing::Return(true)
));
EXPECT_CALL(mockDB, Disconnect());

// 使用模拟对象测试
UserManager userManager(&mockDB);
std::vector<std::string> userInfo;
bool result = userManager.GetUserInfo("john_doe", &userInfo);

EXPECT_TRUE(result);
ASSERT_EQ(3, userInfo.size());
EXPECT_EQ("John", userInfo[0]);
EXPECT_EQ("30", userInfo[1]);
EXPECT_EQ("Engineer", userInfo[2]);
}

测试异常

测试代码是否正确抛出异常:

cpp
#include <gtest/gtest.h>
#include <stdexcept>

// 要测试的函数
void DivideByZero() {
throw std::invalid_argument("Cannot divide by zero");
}

// 测试异常
TEST(ExceptionTest, TestDivideByZero) {
EXPECT_THROW(DivideByZero(), std::invalid_argument);

// 也可以检查异常信息
try {
DivideByZero();
FAIL() << "Expected std::invalid_argument";
}
catch (const std::invalid_argument& e) {
EXPECT_STREQ("Cannot divide by zero", e.what());
}
catch (...) {
FAIL() << "Expected std::invalid_argument";
}
}

单元测试的最佳实践

  1. 测试应该是隔离的:每个测试应该独立运行,不依赖于其他测试

  2. 一个测试只测试一个功能:每个测试应该专注于验证一个特定的行为

  3. 使用描述性的测试名称:命名应该清晰地表明测试的内容和预期结果

  4. 避免测试实现细节:测试应该关注函数/方法的行为,而不是它的实现方式

  5. 测试边界条件:确保测试覆盖边界值和极端情况

  6. 保持测试简单:测试代码应该简单、清晰且易于理解

  7. 使用测试覆盖工具:如gcov和lcov,确保代码被充分测试

测试驱动开发(TDD)

测试驱动开发是一种开发方法,遵循以下循环:

  1. 编写一个测试用例(此时测试会失败)
  2. 编写最简代码使测试通过
  3. 重构代码,确保代码质量
  4. 重复上述步骤

实际案例:一个简单的计算器类

下面我们将开发一个简单的计算器类,并为其编写单元测试:

计算器类实现

cpp
// calculator.h
#ifndef CALCULATOR_H
#define CALCULATOR_H

class Calculator {
public:
int Add(int a, int b);
int Subtract(int a, int b);
int Multiply(int a, int b);
double Divide(int a, int b);
};

#endif // CALCULATOR_H
cpp
// calculator.cpp
#include "calculator.h"
#include <stdexcept>

int Calculator::Add(int a, int b) {
return a + b;
}

int Calculator::Subtract(int a, int b) {
return a - b;
}

int Calculator::Multiply(int a, int b) {
return a * b;
}

double Calculator::Divide(int a, int b) {
if (b == 0) {
throw std::invalid_argument("Division by zero");
}
return static_cast<double>(a) / b;
}

为计算器类编写测试

cpp
// calculator_test.cpp
#include <gtest/gtest.h>
#include "calculator.h"

class CalculatorTest : public ::testing::Test {
protected:
Calculator calc;
};

TEST_F(CalculatorTest, AddWorks) {
EXPECT_EQ(5, calc.Add(2, 3));
EXPECT_EQ(0, calc.Add(0, 0));
EXPECT_EQ(-1, calc.Add(2, -3));
}

TEST_F(CalculatorTest, SubtractWorks) {
EXPECT_EQ(-1, calc.Subtract(2, 3));
EXPECT_EQ(0, calc.Subtract(0, 0));
EXPECT_EQ(5, calc.Subtract(2, -3));
}

TEST_F(CalculatorTest, MultiplyWorks) {
EXPECT_EQ(6, calc.Multiply(2, 3));
EXPECT_EQ(0, calc.Multiply(0, 5));
EXPECT_EQ(-6, calc.Multiply(2, -3));
}

TEST_F(CalculatorTest, DivideWorks) {
EXPECT_DOUBLE_EQ(0.5, calc.Divide(1, 2));
EXPECT_DOUBLE_EQ(-0.5, calc.Divide(1, -2));
EXPECT_DOUBLE_EQ(0.0, calc.Divide(0, 5));
}

TEST_F(CalculatorTest, DivideByZeroThrows) {
EXPECT_THROW(calc.Divide(5, 0), std::invalid_argument);
}

构建和运行测试

使用CMake构建测试:

cmake
# CMakeLists.txt
cmake_minimum_required(VERSION 3.14)
project(calculator_test)

# 设置C++标准
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 包含GoogleTest
include(FetchContent)
FetchContent_Declare(
googletest
URL https://github.com/google/googletest/archive/609281088cfefc76f9d0ce82e1ff6c30cc3591e5.zip
)
FetchContent_MakeAvailable(googletest)

# 添加库
add_library(calculator calculator.cpp)

# 添加测试可执行文件
add_executable(calculator_test calculator_test.cpp)
target_link_libraries(calculator_test
calculator
gtest_main
)

# 启用测试
enable_testing()
include(GoogleTest)
gtest_discover_tests(calculator_test)

总结

单元测试是软件开发过程中的重要环节,可以帮助确保代码质量和功能正确性。GoogleTest提供了一套全面的工具来编写和运行C++单元测试。通过本文的学习,你应该已经掌握了:

  • 单元测试的基本概念和好处
  • 如何设置和使用GoogleTest
  • 如何编写基本的测试用例、测试夹具和参数化测试
  • 如何测试异常、使用模拟对象
  • 单元测试的最佳实践
  • 测试驱动开发的概念

练习

  1. 为一个字符串处理函数编写单元测试,该函数可以将字符串中的所有小写字母转换为大写。

  2. 编写一个测试夹具,用于测试一个简单的堆栈(Stack)类的push、pop和isEmpty方法。

  3. 使用参数化测试来测试一个函数,该函数判断一个数是否是回文数(正序和倒序读都是同一个数)。

  4. 为一个文件读取类编写模拟测试,避免在测试中实际读取文件。

附加资源

通过掌握单元测试,你将能够编写更健壮、可维护的C++代码,提高开发效率,并减少bug的出现。随着经验的积累,你会发现单元测试不仅是一种工具,更是一种提高代码质量的思维方式。