本章将涵盖以下主题:
- 测试驱动开发概述
- 关于 TDD 的常见神话和问题
- 开发人员编写单元测试是否需要更多的努力
- 代码覆盖率度量是好是坏
- TDD 是否适用于复杂的遗留项目
- TDD 是否甚至适用于嵌入式产品或涉及硬件的产品
- C++ 的单元测试框架
- 谷歌测试框架
- 在 Ubuntu 上安装谷歌测试框架
- 构建谷歌测试和模拟作为一个单一的静态库而不安装它们的过程
- 使用谷歌测试框架编写我们的第一个测试用例
- 在 Visual Studio IDE 中使用谷歌测试框架
- 行动中的 TDD
- 测试有依赖性的遗留代码
让我们深入探讨这些 TDD 主题。
测试驱动开发 ( TDD )是一种极限编程实践。在 TDD 中,我们从一个测试用例开始,并逐步编写使测试用例成功所需的生产代码。这个想法是,一次应该专注于一个测试用例或场景,一旦测试用例通过,他们就可以继续下一个场景。在这个过程中,如果新的测试用例通过了,我们就不应该修改生产代码。换句话说,在开发一个新特性的过程中,或者在修复一个 bug 的时候,我们修改产品代码只有两个原因:要么确保测试用例通过,要么重构代码。TDD 的主要焦点是单元测试;然而,它可以在某种程度上扩展到集成和交互测试。
下图直观地展示了 TDD 过程:
当严格遵循 TDD 时,可以同时实现代码的功能和结构质量。与在开发阶段结束时编写测试用例相反,在编写产品代码之前先编写测试用例是非常重要的。这有很大的不同。例如,当开发人员在开发结束时编写单元测试用例时,测试用例不太可能在代码中发现任何缺陷。原因是当测试用例在开发结束时编写时,开发人员会不自觉地倾向于证明他们的代码正在做正确的事情。然而,当开发人员提前编写测试用例时,由于还没有编写代码,他们开始从最终用户的角度思考,这将鼓励他们从需求规范的角度提出许多场景。
换句话说,针对已经编写的代码编写的测试用例通常不会发现任何错误,因为它倾向于证明编写的代码是正确的,而不是针对需求进行测试。当开发人员在编写代码之前考虑各种场景时,这有助于他们以增量方式编写更好的代码,确保代码能够处理这些场景。然而,当代码有漏洞时,是测试用例帮助他们发现问题,因为如果测试用例不满足需求,它们就会失败。
TDD 不仅仅是使用一些单元测试框架。在开发或修复代码中的缺陷时,需要改变文化和思维方式。开发人员的重点应该是使代码在功能上正确。一旦以这种方式开发了代码,强烈建议开发人员也应该通过重构代码来消除任何代码异味;这将确保代码的结构质量也很好。从长远来看,是代码的结构质量会让团队更快地交付特性。
当每个人即将开始他们的 TDD 之旅时,他们脑海中都有很多关于 TDD 的神话和常见的疑问。让我澄清我遇到的大多数问题,因为我咨询了全球许多产品巨头。
大多数开发人员心中出现的一个常见疑问是,“当我们适应 TDD 时,我应该如何评估我的努力?”由于开发人员应该将单元和集成测试用例作为 TDD 的一部分来编写,所以难怪您会关心如何与客户或管理层协商编写代码之外的测试用例所需的额外工作。别担心,你并不孤单;作为一名自由软件顾问,很多开发者都问过我这个问题。
作为开发人员,您手动测试代码;相反,现在就编写自动化测试用例。好消息是,这是一次性的努力,从长远来看,肯定会帮助你。虽然开发人员需要重复的手动工作来测试他们的代码,但是每次他们更改代码时,已经存在的自动化测试用例将通过在他们集成一段新代码时给他们立即的反馈来帮助开发人员。
底线是它需要一些额外的努力,但从长远来看,它有助于减少所需的努力。
代码覆盖工具帮助开发人员识别自动化测试用例中的差距。毫无疑问,很多时候它会给出关于缺失测试场景的线索,这最终会进一步加强自动化测试用例。但是当一个组织开始强制执行代码覆盖率作为检查测试覆盖率有效性的措施时,它有时会把开发人员推向错误的方向。从我的实际咨询经验中,我了解到许多开发人员开始为构造函数和私有及受保护函数编写测试用例,以显示更高的代码覆盖率。在这个过程中,开发者开始追逐数字,失去了 TDD 的最终目标。
在具有 20 个方法的类的特定源代码中,可能只有 10 个方法符合单元测试的条件,而其他方法是复杂的功能。在这种情况下,代码覆盖率工具将只显示 50%的代码覆盖率,按照 TDD 的理念,这是绝对可以的。但是,如果组织策略强制要求至少 75%的代码覆盖率,那么开发人员将别无选择,只能测试构造函数、析构函数、私有函数、受保护函数和复杂函数,以显示良好的代码覆盖率。
测试私有方法和受保护方法的问题在于,当它们被标记为实现细节时,它们往往会更频繁地改变。当私有的和受保护的方法变化很大时,这就需要修改测试用例,这使得开发人员在维护测试用例方面的生活更加艰难。
因此,代码覆盖工具是发现测试场景差距的非常好的开发工具,但是应该由开发人员根据方法的复杂性,明智地选择是编写测试用例还是忽略为某些方法编写测试用例。然而,如果代码覆盖率被用作项目度量,它往往会驱使开发人员寻找错误的方法来显示更好的覆盖率,从而导致糟糕的测试用例实践。
当然可以!TDD 适用于任何类型的软件项目或产品。TDD 不仅仅意味着新产品或项目;它也被证明对复杂的遗留项目或产品更有效。在维护项目中,绝大多数情况下,一个人必须修复缺陷,很少需要支持一个新特性。即使在这样的遗留代码中,也可以在修复缺陷时遵循 TDD。
作为一个开发人员,你会很乐意同意我的观点,一旦你能够重现这个问题,从开发人员的角度来看,几乎一半的问题都可以被认为是固定的。因此,您可以从重现问题的测试用例开始,然后调试并修复问题。当您修复问题时,测试用例将开始通过;现在是时候考虑另一个可能的测试用例了,它可能会重现相同的缺陷并重复这个过程。
就像应用软件可以从 TDD 中受益一样,嵌入式项目或涉及硬件交互的项目也可以从 TDD 方法中受益。有趣的是,涉及硬件的嵌入式项目或产品从 TDD 中获益更多,因为它们可以通过隔离硬件依赖性来测试大部分代码,而无需硬件。TDD 有助于缩短上市时间,因为大多数软件都可以由团队测试,而无需等待硬件。由于大部分代码已经在没有硬件的情况下进行了彻底的测试,它有助于避免在电路板出现故障时出现最后的意外或灭火。这是因为大部分场景都经过了彻底的测试。
根据软件工程最佳实践,一个好的设计本质上是松散耦合和强内聚的。尽管我们都努力编写松散耦合的代码,但不可能一直编写绝对独立的代码。大多数情况下,代码具有某种类型的依赖性。在应用软件的情况下,依赖可以是数据库或网络服务器;在嵌入式产品的情况下,依赖性可能是一件硬件。但是使用依赖反转,被测试的代码 ( CUT )可以从它的依赖中隔离出来,使我们能够测试没有依赖的代码,这是一种强大的技术。因此,只要我们愿意重构代码,使其更加模块化和原子化,任何类型的代码和项目或产品都将从 TDD 方法中受益。
作为一名 C++ 开发人员,在单元测试框架之间进行选择时,您有相当多的选择。虽然有更多的框架,但这些是一些流行的框架:CppUnit、CppUnitLite、Boost、MSTest、Visual Studio 单元测试和谷歌测试框架。
Though older articles, I recommend you to take a look at http://gamesfromwithin.com/exploring-the-c-unit-testing-framework-jungle and https://accu.org/index.php/journals/. They might give you some insight into this topic.
不用多想,谷歌测试框架是最受欢迎的 C++ 测试框架之一,因为它在各种各样的平台上得到支持,被积极开发,最重要的是,得到了谷歌的支持。
在本章中,我们将使用谷歌测试和谷歌模拟框架。然而,本章讨论的概念适用于所有单元测试框架。在接下来的章节中,我们将深入研究谷歌测试框架及其安装过程。
谷歌测试框架是一个开源的测试框架,可以在很多平台上运行。TDD 只关注单元测试和某种程度上的集成测试,但是 Google 测试框架可以用于各种各样的测试。它将测试用例分为小、中、大、保真、弹性、精确和其他类型的测试用例。单元测试用例属于小类,集成测试用例属于中等类,复杂功能和验收测试用例属于大类。
它还捆绑了谷歌模拟框架作为其一部分。因为他们在技术上来自同一个团队,所以他们可以无缝地相互配合。然而,谷歌模拟框架可以与其他测试框架一起使用,例如 CppUnit。
可以从https://github.com/google/googletest下载谷歌测试框架作为源代码。然而,最好的下载方式是通过终端命令行的 Git 克隆:
git clone https://github.com/google/googletest.git
Git is an open source distributed version control system (DVCS). If you haven't installed it on your system, you will find more information on why you should, at https://git-scm.com/. However, in Ubuntu, it can be easily installed with the sudo apt-get install git
command.
一旦下载了代码,如图 7.1 、所示,你就可以在googletest
文件夹中找到谷歌测试框架源代码:
Figure 7.1
googletest
文件夹的googletest
和googlemock
框架都在不同的文件夹中。现在我们可以调用cmake
实用程序来配置我们的构建并自动生成Makefile
,如下所示:
cmake CMakeLists.txt
Figure 7.2
当cmake
实用程序被调用时,它会检测到从源代码构建谷歌测试框架所需的 C/C++ 头文件及其路径。此外,它将尝试找到构建源代码所需的工具。一旦找到所有必要的集管和工具,它将自动生成Makefile
。一旦你有了Makefile
,你可以用它在你的系统上编译和安装谷歌测试和谷歌模拟:
sudo make install
下面的截图展示了如何在您的系统上安装 google test:
Figure 7.3
在上图中,make install 命令已经编译并在/usr/local/lib
文件夹中安装了libgmock.a
和libgtest.a
静态库文件。由于/usr/local/lib
文件夹路径通常在系统的 path 环境变量中,因此可以从系统中的任何项目访问它。
如果您不喜欢在公共系统文件夹上安装libgmock.a
和libgtest.a
静态库文件和各自的头文件,那么还有另一种方法来构建谷歌测试框架。
以下命令将创建三个对象文件,如图图 7.4 :
g++ -c googletest/googletest/src/gtest-all.cc googletest/googlemock/src/gmock-all.cc googletest/googlemock/src/gmock_main.cc -I googletest/googletest/ -I googletest/googletest/include -I googletest/googlemock -I googletest/googlemock/include -lpthread -
Figure 7.4
下一步是使用以下命令将所有对象文件合并到一个静态库中:
ar crf libgtest.a gmock-all.o gmock_main.o gtest-all.o
如果一切顺利,你的文件夹应该有全新的libgtest.a
静态库,如图图 7.5 。让我们理解以下命令说明:
g++ -c googletest/googletest/src/gtest-all.cc googletest/googlemock/src/gmock-all.cc googletest/googlemock/src/gmock_main.cc -I googletest/googletest/ -I googletest/googletest/include
-I googletest/googlemock -I googletest/googlemock/include -lpthread -std=c++ 14
前面的命令将帮助我们创建三个目标文件: gtest-all.o 、 gmock-all.o 和 gmock_main.o 。googletest
框架利用了一些 C++ 11 的特性,我有目的地使用 c++ 14 是为了更安全。gmock_main.cc
源文件有一个主要功能,将初始化谷歌模拟框架,反过来将在内部初始化谷歌测试框架。这种方法最好的一点是,我们不必为单元测试应用提供主要功能。请注意,编译命令包括以下include
路径,以帮助 g++ 编译器在谷歌测试和谷歌模拟框架中找到必要的头文件:
-I googletest/googletest
-I googletest/googletest/include
-I googletest/googlemock
-I googletest/googlemock/include
现在下一步是创建我们的libgtest.a
静态库,将 gtest 和 gmock 框架捆绑到一个静态库中。由于谷歌测试框架使用了多线程,因此必须将pthread
库作为我们静态库的一部分进行链接:
ar crv libgtest.a gtest-all.o gmock_main.o gmock-all.o
ar
存档命令有助于将所有目标文件合并到一个静态库中。
下图演示了终端中的实际讨论过程:
Figure 7.5
学习谷歌测试框架相当容易。让我们创建两个文件夹:一个用于生产代码,另一个用于测试代码。这个想法是将生产代码和测试代码分开。一旦你创建了这两个文件夹,从Math.h
标题开始,如图 7.6 所示:
Figure 7.6
Math
类只有一个功能来演示单元测试框架的使用。首先,我们的Math
类有一个简单的 add 函数,足以理解谷歌测试框架的基本用法。
In the place of the Google test framework, you could use CppUnit as well and integrate mocking frameworks such as the Google mock framework, mockpp, or opmock.
让我们在下面的Math.cpp
源文件中实现我们简单的Math
类:
Figure 7.7
前面两个文件应该在src
文件夹,如图图 7.8 所示。所有的生产代码进入src
文件夹,任何数量的文件都可以成为src
文件夹的一部分。
Figure 7.8
由于我们已经编写了一些生产代码,让我们看看如何为前面的生产代码编写一些基本的测试用例。作为一般的最佳实践,建议将测试用例文件命名为MobileTest
或TestMobile
,这样任何人都很容易预测文件的目的。在 C++ 或谷歌测试框架中,文件名和类名保持一致并不是强制性的,但这通常被认为是最佳实践,因为它可以帮助任何人通过查看文件名来定位特定的类。
Both the Google test framework and Google mock framework go hand in hand as they are from the same team, hence this combination works pretty well in the majority of the platforms, including embedded platforms.
由于我们已经将谷歌测试框架编译为静态库,让我们直接从MathTest.cpp
源文件开始:
Figure 7.9
在图 7.9第 18 行中,我们包含了谷歌测试框架的 gtest 头文件。在谷歌测试框架中,测试用例是用一个带两个参数的TEST
宏编写的。第一个参数,即MathTest
,代表测试模块名称,第二个参数是测试用例的名称。测试模块帮助我们将一堆相关的测试用例组合在一个模块下。因此,恰当地命名测试模块和测试用例对于提高测试报告的可读性非常重要。
**如你所知,Math
是我们打算测试的班级;我们已经在第 22 行实例化了Math
对象的一个对象。在第 25 行中,我们调用了数学对象上的 add 函数,该函数应该返回实际结果。最后,在行 27 处,我们检查预期结果是否与实际结果匹配。如果预期结果和实际结果匹配,谷歌测试宏EXPECT_EQ
会将测试用例标记为通过;否则,框架会将测试用例结果标记为失败。
酷,我们都准备好了。现在让我们看看如何编译和运行我们的测试用例。以下命令将帮助您编译测试用例:
g++ -o tester.exe src/Math.cpp test/MathTest.cpp -I googletest/googletest
-I googletest/googletest/include -I googletest/googlemock
-I googletest/googlemock/include -I src libgtest.a -lpthread
请注意,编译命令包括以下包含路径:
-I googletest/googletest
-I googletest/googletest/include
-I googletest/googlemock
-I googletest/googlemock/include
-I src
此外,值得注意的是,我们还链接了我们的谷歌测试静态库libgtest.a
和 POSIX pthreads 库,因为谷歌测试框架使用了多个。
Figure 7.10
恭喜你!我们已经成功编译并执行了第一个测试用例。
首先,我们需要从https://github.com/google/googletest/archive/master.zip下载谷歌测试框架.zip
文件。下一步是提取某个目录下的.zip
文件。就我而言,我已经将其提取到googletest
文件夹中,并将googletest googletest-master\googletest-master
的所有内容复制到googletest
文件夹中,如图 7.11 所示:
Figure 7.11
是时候在 Visual Studio 中创建一个简单的项目了。我用过微软 Visual Studio 社区 2015。但是,这里遵循的过程对于 Visual Studio 的其他版本应该基本保持不变,只是选项可能在不同的菜单中可用。
您需要通过导航到新建项目| Visual Studio | Windows | Win32 | Win32 控制台应用来创建一个名为MathApp
的新项目,如图 7.12 所示。这个项目将是要测试的产品代码。
Figure 7.12
让我们将MyMath
类添加到MathApp
项目中。MyMath
类是将在MyMath.h
中声明并在MyMath.cpp
中定义的生产代码。
我们来看看图 7.13 所示的MyMath.h
头文件:
Figure 7.13
MyMath
类的定义如图 7.14 所示:
Figure 7.14
由于是控制台应用,必须提供主要功能,如图图 7.15 :
Figure 7.15
接下来,我们将为同一个MathApp
项目解决方案添加一个名为GoogleTestLib
的静态库项目,如图图 7.16 :
Figure 7.16
接下来,我们需要将 Google 测试框架中的以下源文件添加到我们的静态库项目中:
C:\Users\jegan\googletest\googletest\src\gtest-all.cc
C:\Users\jegan\googletest\googlemock\src\gmock-all.cc
C:\Users\jegan\googletest\googlemock\src\gmock_main.cc
为了编译静态库,我们需要在GoogleTestLib/Properties/VC++ Directories/Include
目录中包含以下头文件路径:
C:\Users\jegan\googletest\googletest
C:\Users\jegan\googletest\googletest\include
C:\Users\jegan\googletest\googlemock
C:\Users\jegan\googletest\googlemock\include
您可能需要根据您在系统中复制/安装谷歌测试框架的位置来定制路径。
现在是时候将MathTestApp
Win32 控制台应用添加到MathApp
解决方案中了。我们需要将MathTestApp
作为StartUp
项目,这样我们就可以直接执行这个应用。在向MathTestApp
项目添加名为MathTest.cpp
的新源文件之前,让我们确保MathTestApp
项目中没有源文件。
我们需要配置同一套谷歌测试框架,包括我们添加到GoogleTestLib
静态库中的路径。除此之外,我们还必须添加MathApp
项目目录,因为测试项目将引用MathApp
项目中的头文件,如下所示。但是,根据您在系统中为此项目遵循的目录结构自定义路径:
C:\Users\jegan\googletest\googletest
C:\Users\jegan\googletest\googletest\include
C:\Users\jegan\googletest\googlemock
C:\Users\jegan\googletest\googlemock\include
C:\Projects\MasteringC++ Programming\MathApp\MathApp
在MathAppTest
项目中,确保已经添加了对MathApp
和GoogleTestLib
的引用,以便MathAppTest
项目在感知到其他两个项目的变化时会编译它们。
太好了。我们快完成了。现在来实现MathTest.cpp
,如图图 7.17 :
Figure 7.17
现在一切都准备好了;让我们运行测试用例并检查结果:
Figure 7.18
让我们看看如何开发一个遵循 TDD 方法的逆波兰符号 ( RPN )计算器应用。RPN 也称为后缀符号。RPN Calculator 应用的期望是接受后缀数学表达式作为输入,并返回计算结果作为输出。
我想一步一步地演示如何在开发应用时遵循 TDD 方法。作为第一步,我想解释一下项目目录结构,然后我们继续。让我们创建一个名为Ex2
的文件夹,其结构如下:
Figure 7.19
googletest
文件夹是包含必要的gtest
和gmock
头文件的 gtest 测试库。现在libgtest.a
是我们在前面练习中创建的 Google 测试静态库。我们将使用make
工具来构建我们的项目,因此我在项目home
目录中放置了一个Makefile
。src
目录将保存生产代码,而测试目录将保存我们将要编写的所有测试用例。
在开始编写测试用例之前,我们先来看一个后缀数学*“2 5 * 4+3 3 * 1+/*,了解一下我们要应用于评估 RPN 数学表达式的标准后缀算法。根据后缀算法,我们将一次解析一个令牌的 RPN 数学表达式。每当我们遇到一个操作数(数字),我们就要把它推入堆栈。每当我们遇到一个运算符时,我们将从堆栈中弹出两个值,应用数学运算,将中间结果推回到堆栈中,并重复该过程,直到所有标记都在 RPN 表达式中求值。最后,当输入字符串中没有更多的标记时,我们将弹出值并将其作为结果打印出来。下图中逐步演示了该过程:
Figure 7.20
首先,让我们采用一个简单的后缀数学表达式,并将场景转换为一个测试用例:
Test Case : Test a simple addition
Input: "10 15 +"
Expected Output: 25.0
让我们将前面的测试用例翻译成测试文件夹中的谷歌测试,如下所示:
test/RPNCalculatorTest.cpp
TEST ( RPNCalculatorTest, testSimpleAddition ) {
RPNCalculator rpnCalculator;
double actualResult = rpnCalculator.evaluate ( "10 15 +" );
double expectedResult = 25.0;
EXPECT_EQ ( expectedResult, actualResult );
}
为了编译前面的测试用例,让我们编写src
文件夹中所需的最小生产代码,如下所示:
src/RPNCalculator.h
#include <iostream>
#include <string>
using namespace std;
class RPNCalculator {
public:
double evaluate ( string );
};
由于 RPN 数学表达式将以空格分隔的字符串形式提供,因此 evaluate 方法将采用字符串输入参数:
src/RPNCalculator.cpp
#include "RPNCalculator.h"
double RPNCalculator::evaluate ( string rpnMathExpression ) {
return 0.0;
}
下面的Makefile
类帮助我们在每次编译生产代码时运行测试用例:
Figure 7.21
现在让我们构建并运行测试用例,并检查测试用例的结果:
Figure 7.22
在 TDD 中,我们总是从失败的测试用例开始。失败的根本原因是预期结果是 25,而实际结果是 0。原因是我们没有实现 evaluate 方法,因此我们硬编码为返回 0,而不考虑任何输入。因此,让我们实现评估方法,以使测试用例通过。
我们需要修改src/RPNCalculator.h
和src/RPNCalculator.cpp
如下:
Figure 7.23
在 RPNCalculator.h 头文件中,观察包含的新头文件,以处理字符串标记化和字符串双转换,并将 RPN 标记复制到向量中:
Figure 7.24
根据标准的后缀算法,我们使用一个堆栈来保存我们在 RPN 表达式中找到的所有数字。每当我们遇到+
数学运算符时,我们都会从堆栈中弹出两个值,并将它们相加,并将结果推回到堆栈中。如果令牌不是+
运算符,我们可以放心地假设它是一个数字,所以我们只需将值推送到堆栈中。
有了前面的实现,让我们尝试测试用例并检查测试用例是否通过:
Figure 7.25
酷,我们的第一个测试用例已经按预期通过了。是时候考虑另一个测试用例了。这次,让我们为减法添加一个测试用例:
Test Case : Test a simple subtraction
Input: "25 10 -"
Expected Output: 15.0
让我们将前面的测试用例翻译成测试文件夹中的谷歌测试,如下所示:
test/RPNCalculatorTest.cpp
TEST ( RPNCalculatorTest, testSimpleSubtraction ) {
RPNCalculator rpnCalculator;
double actualResult = rpnCalculator.evaluate ( "25 10 -" );
double expectedResult = 15.0;
EXPECT_EQ ( expectedResult, actualResult );
}
将前面的测试用例添加到test/RPNCalculatorTest
中,现在应该是这样的:
Figure 7.26
让我们执行测试用例,并检查我们的新测试用例是否通过:
Figure 7.27
正如预期的那样,新测试失败了,因为我们还没有在应用中增加对减法的支持。这是非常明显的,基于 C++ 异常,因为代码试图将减法-
运算符转换为数字。让我们在评估方法中增加对减法逻辑的支持:
Figure 7.28
是时候测试了。让我们执行测试用例,检查事情是否正常:
Figure 7.29
酷!你注意到我们的测试用例在这个例子中失败了吗?等一下。如果测试用例失败了,我们为什么会兴奋?我们应该高兴的原因是我们的测试用例发现了一个 bug 毕竟,这是 TDD 的主要意图,不是吗?
Figure 7.30
失败的根本原因是堆栈基于后进先出 ( 后进先出)进行操作,而我们的代码假设先进先出。你有没有注意到我们的代码假设它会先弹出第一个数字,而实际情况是它应该先弹出第二个数字?有意思,这个 bug 也在加法运算中;然而,由于加法是关联的,这个错误被抑制了,但是减法测试用例检测到了它。
Figure 7.31
让我们修复前面截图中显示的错误,并检查测试用例是否会通过:
Figure 7.32
太棒了。我们修复了这个错误,我们的测试用例似乎证明它们已经被修复了。让我们添加更多的测试用例。这次,让我们添加一个测试用例来验证乘法:
Test Case : Test a simple multiplication
Input: "25 10 *"
Expected Output: 250.0
让我们将前面的测试用例翻译成测试文件夹中的 google 测试,如下所示:
test/RPNCalculatorTest.cpp
TEST ( RPNCalculatorTest, testSimpleMultiplication ) {
RPNCalculator rpnCalculator;
double actualResult = rpnCalculator.evaluate ( "25 10 *" );
double expectedResult = 250.0;
EXPECT_EQ ( expectedResult, actualResult );
}
我们知道这一次测试用例将会失败,所以让我们快进并看看分部测试用例:
Test Case : Test a simple division
Input: "250 10 /"
Expected Output: 25.0
让我们将前面的测试用例翻译成测试文件夹中的 google 测试,如下所示:
test/RPNCalculatorTest.cpp
TEST ( RPNCalculatorTest, testSimpleDivision ) {
RPNCalculator rpnCalculator;
double actualResult = rpnCalculator.evaluate ( "250 10 /" );
double expectedResult = 25.0;
EXPECT_EQ ( expectedResult, actualResult );
}
让我们跳过测试结果,继续进行涉及许多操作的最终复杂表达式测试用例:
Test Case : Test a complex rpn expression
Input: "2 5 * 4 + 7 2 - 1 + /"
Expected Output: 25.0
让我们将前面的测试用例翻译成测试文件夹中的 google 测试,如下所示:
test/RPNCalculatorTest.cpp
TEST ( RPNCalculatorTest, testSimpleDivision ) {
RPNCalculator rpnCalculator;
double actualResult = rpnCalculator.evaluate ( "250 10 /" );
double expectedResult = 25.0;
EXPECT_EQ ( expectedResult, actualResult );
}
让我们用下面的测试用例来检查我们的 RPNCalculator 应用是否能够在单个表达式中计算复杂的 RPN 表达式,该表达式涉及加法、减法、乘法和除法:
test/RPNCalculatorTest.cpp
TEST ( RPNCalculatorTest, testComplexExpression ) {
RPNCalculator rpnCalculator;
double actualResult = rpnCalculator.evaluate ( "2 5 * 4 + 7 2 - 1 + /" );
double expectedResult = 2.33333;
ASSERT_NEAR ( expectedResult, actualResult, 4 );
}
在前面的测试案例中,我们正在检查预期结果是否与实际结果匹配,接近小数点后四位。如果这些值超出了这个近似值,那么测试用例应该会失败。
现在让我们检查测试用例输出:
Figure 7.33
太好了。所有的测试用例都是绿色的。
现在让我们看看我们的生产代码,看看是否有改进的空间:
Figure 7.34
代码在功能上是好的,但是有很多代码异味。这是一个嵌套了if-else
条件和重复代码的长方法。TDD 不仅仅是测试自动化;它也是关于编写没有代码味道的好代码。因此,我们必须重构代码,使其更加模块化,并降低代码复杂性。
我们可以在这里应用多态或者策略设计模式来代替嵌套的if-else
条件。此外,我们可以使用工厂方法设计模式来创建各种子类型。还可以使用空对象设计模式。
最棒的是,我们不必担心在重构过程中破坏代码的风险,因为我们有足够数量的测试用例来给我们反馈,以防我们破坏代码。
首先,让我们了解如何重构图 7.35 所示的 RPNCalculator 设计:
Figure 7.35
基于前面的设计重构方法,我们可以重构 RPNCalculator,如图图 7.36 :
Figure 7.36
如果你对比一下重构前后的RPNCalculator
代码,你会发现重构后的代码复杂度已经下降到了一个相当可观的数量。
MathFactory
类可以实现,如图图 7.37 :
Figure 7.37
尽可能地,我们必须努力避免if-else
条件,或者一般来说,我们必须尽可能地避免代码分支。因此,STL 映射用于避免 if-else 条件。这也促进了相同数学对象的重用,而不管 RPN 表达式的复杂性如何。
如果你参考图 7.38 ,你会了解到MathOperator Add
类是如何实现的:
Figure 7.38
Add
类定义如图图 7.39 所示:
Figure 7.39
减法、乘法和除法类可以以类似的方式实现,如Add
类。底线是,重构之后,我们可以将单个RPNCalculator
类重构为更小且可维护的类,这些类可以单独测试。
让我们看看图 7.40 中重构的Makefile
类,并在重构过程完成后测试我们的代码:
Figure 7.40
如果一切顺利的话,如果没有任何功能被破坏,我们应该会看到重构后的所有测试用例都通过了,如图图 7.41 :
Figure 7.41
酷!所有的测试用例都通过了,因此可以保证我们在重构过程中没有破坏功能。TDD 的主要目的是编写功能和结构都干净的可测试代码。
在前一节中,CUT 是独立的,没有依赖性,因此它测试代码的方式很简单。然而,让我们讨论如何对有依赖关系的 CUT 进行单元测试。为此,请参考下图:
Figure 7.42
在图 7.42 中,很明显 Mobile 对摄像头有依赖关系, Mobile 和摄像头之间的关联是构图。让我们看看Camera.h
头文件是如何在遗留应用中实现的:
Figure 7.43
出于演示目的,让我们来看这个简单的Camera
类,它具有ON()
和OFF()
功能。让我们假设开/关功能将在内部与摄像机硬件交互。查看图 7.44 中的Camera.cpp
源文件:
Figure 7.44
出于调试的目的,我添加了一些打印语句,当我们测试手机的powerOn()
和powerOff()
功能时,这些语句会派上用场。现在我们来查看一下图 7.45 中的Mobile
类头文件:
Figure 7.45
我们继续移动实现,如图 7.46 所示:
Figure 7.46
从Mobile
构造器的实现来看,很明显手机有摄像头或者说是精确的构图关系。换句话说,Mobile
类就是构造Camera
对象的类,如图图 7.46 、第 21 行所示,在构造器中。让我们试着看看测试移动powerOn()
功能的复杂性;依赖关系与移动的 CUT 有合成关系。
假设摄像机开启成功,我们编写powerOn()
测试用例,如下所示:
TEST ( MobileTest, testPowerOnWhenCameraONSucceeds ) {
Mobile mobile;
ASSERT_TRUE ( mobile.powerOn() );
}
现在让我们尝试运行Mobile
测试用例并检查测试结果,如图图 7.47 所示:
Figure 7.47
从图 7.47 可以了解到Mobile
的powerOn()
测试用例已经通过。然而,我们也明白真正的Camera
类的ON()
方法也被调用了。反过来,这将与相机硬件交互。归根结底,这不是一个单元测试,因为测试结果并不完全依赖于 CUT。如果测试用例失败了,我们将无法确定失败是由于移动设备的powerOn()
逻辑中的代码还是摄像机的ON()
逻辑中的代码,这将违背我们测试用例的目的。理想的单元测试应该使用依赖注入将 CUT 与其依赖项隔离开来,并测试代码。这种方法将帮助我们识别正常或异常场景中 CUT 的行为。理想情况下,当一个单元测试用例失败时,我们应该能够在不调试代码的情况下猜测失败的根本原因;只有当我们设法隔离 CUT 的依赖关系时,这才是可能的。
这种方法的主要好处是,甚至在实现依赖项之前就可以测试 CUT,这有助于测试 60 ~ 70%没有依赖项的代码。这自然会减少软件产品的上市时间。
这就是谷歌模拟或 gmock 派上用场的地方。让我们检查一下如何重构代码来启用依赖注入。虽然听起来很复杂,但是重构代码所需的工作并没有那么复杂。实际上,重构产品代码所需的工作可能更复杂,但这是值得的。让我们来看看图 7.48 中所示的重构Mobile
类:
Figure 7.48
在Mobile
类中,我添加了一个以 camera 为参数的重载构造函数。这种技术被称为构造函数依赖注入。让我们看看这种简单而强大的技术如何帮助我们在测试移动设备的powerOn()
功能时隔离相机依赖性。
此外,我们必须重构Camera.h
头文件并将ON()
和OFF()
方法声明为虚拟的,以便 gmock 框架帮助我们存根这些方法,如图 7.49 所示:
Figure 7.49
现在让我们重构我们的测试用例,如图 7.50所示:
Figure 7.50
我们都准备好构建和执行测试用例了。测试结果预计如图 7.51 所示:
Figure 7.51
酷!我们的测试用例不仅通过了,而且我们还将我们的 CUT 从它的相机依赖中分离出来,这是显而易见的,因为我们没有看到来自相机的ON()
方法的打印语句。最重要的是,您现在已经学会了如何通过隔离代码的依赖关系来进行单元测试。
TDD 快乐!
在这一章中,您学习了很多关于 TDD 的知识,以下是关键要点的总结:
- TDD 是一种极限编程实践
- TDD 是一种自下而上的方法,它鼓励我们从测试用例开始,因此它通常被称为低成本测试优先开发
- 您学习了如何在 Linux 和 Windows 中使用谷歌测试和谷歌模拟框架编写测试用例
- 您还学习了如何在 Windows 平台上编写遵循 Linux 和 Visual Studio 中 TDD 的应用
- 您已经学习了依赖项反转技术,以及如何使用谷歌模拟框架隔离代码的依赖项来进行单元测试
- 谷歌测试框架支持单元测试、集成测试、回归测试、性能测试、功能测试等等
- TDD 主要坚持单元测试、集成测试和交互测试,而复杂的功能测试必须通过行为驱动开发来完成
- 您学习了如何将代码重构为整洁的代码,同时您编写的单元测试用例给出持续的反馈
您已经学习了 TDD 以及如何以自下而上的方式自动化单元测试用例、集成测试用例和交互测试用例。使用 BDD,您将学习自上而下的开发方法,编写端到端的功能和测试用例以及其他复杂的测试场景,这些我们在讨论 TDD 时没有涉及到。
在下一章中,您将学习行为驱动开发。**