C++ 单元测试
什么是单元测试?
单元测试是软件测试的一种方法,它检查源代码中的最小可测试单元(通常是函数或方法)是否按照设计正确工作。在C++中,一个"单元"通常是一个类、一个函数或甚至是一个模板。
单元测试的主要目的是验证代码的每个部分都能独立正确地工作,这有助于尽早发现并修复问题。
单元测试的好处包括:
- 早期发现bug:在开发周期的早期阶段发现并修复错误
- 简化调试:当测试失败时,你可以快速定位问题所在
- 重构保障:修改代码后,可以通过运行测试确保没有破坏原有功能
- 文档作用:测试代码本身可以作为代码使用方式的文档
C++ 单元测试框架简介
C++生态系统中有多种单元测试框架可供选择,最流行的包括:
- GoogleTest (GTest) - Google开发的C++测试框架
- Catch2 - 轻量级的头文件库
- Boost.Test - Boost库的一部分
- doctest - 轻量级且快速的测试框架
本文我们将主要关注GoogleTest,因为它功能全面且被广泛使用。
设置GoogleTest
安装
在开始使用GoogleTest之前,需要先安装它。不同系统的安装方法如下:
使用CMake(推荐)
- 首先,克隆GoogleTest代码库:
git clone https://github.com/google/googletest.git
- 在你的CMakeLists.txt中添加:
# 添加GoogleTest
add_subdirectory(path/to/googletest)
# 链接测试可执行文件
add_executable(my_test test.cpp)
target_link_libraries(my_test gtest gtest_main)
第一个测试用例
下面是一个简单的例子,展示如何编写基本的测试用例:
#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提供了两类断言:
- ASSERT_ 系列:致命断言,失败时终止当前函数
- EXPECT_ 系列:非致命断言,失败时继续执行
常用的断言包括:
// 基本比较
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_EQ
或 EXPECT_DOUBLE_EQ
而不是 EXPECT_EQ
,因为浮点计算可能有精度误差。
测试夹具(Test Fixtures)
当多个测试需要相同的设置和清理代码时,可以使用测试夹具:
#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]);
}
参数化测试
当需要用不同的输入值测试同一段逻辑时,参数化测试非常有用:
#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创建模拟对象:
#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]);
}
测试异常
测试代码是否正确抛出异常:
#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";
}
}
单元测试的最佳实践
-
测试应该是隔离的:每个测试应该独立运行,不依赖于其他测试
-
一个测试只测试一个功能:每个测试应该专注于验证一个特定的行为
-
使用描述性的测试名称:命名应该清晰地表明测试的内容和预期结果
-
避免测试实现细节:测试应该关注函数/方法的行为,而不是它的实现方式
-
测试边界条件:确保测试覆盖边界值和极端情况
-
保持测试简单:测试代码应该简单、清晰且易于理解
-
使用测试覆盖工具:如gcov和lcov,确保代码被充分测试
测试驱动开发(TDD)
测试驱动开发是一种开发方法,遵循以下循环:
- 编写一个测试用例(此时测试会失败)
- 编写最简代码使测试通过
- 重构代码,确保代码质量
- 重复上述步骤
实际案例:一个简单的计算器类
下面我们将开发一个简单的计算器类,并为其编写单元测试:
计算器类实现
// 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
// 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;
}
为计算器类编写测试
// 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构建测试:
# 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
- 如何编写基本的测试用例、测试夹具和参数化测试
- 如何测试异常、使用模拟对象
- 单元测试的最佳实践
- 测试驱动开发的概念
练习
-
为一个字符串处理函数编写单元测试,该函数可以将字符串中的所有小写字母转换为大写。
-
编写一个测试夹具,用于测试一个简单的堆栈(Stack)类的push、pop和isEmpty方法。
-
使用参数化测试来测试一个函数,该函数判断一个数是否是回文数(正序和倒序读都是同一个数)。
-
为一个文件读取类编写模拟测试,避免在测试中实际读取文件。
附加资源
通过掌握单元测试,你将能够编写更健壮、可维护的C++代码,提高开发效率,并减少bug的出现。随着经验的积累,你会发现单元测试不仅是一种工具,更是一种提高代码质量的思维方式。