测试驱动开发-GTest 简介Gtest 是基于 xUnit 的 C++单元测试框架,支持自动化案例自动发掘,丰富的断言功能,支持用户自定义断言,支持死亡测试和退出测试,还有异常测试控制,支持值类型和类型化的参数化测试,接口简单易用,对每个测试案例有执行时间的输出,可以帮助分析代码的执行效率,单一接口文件 gtest.h。
图 1 是 Console 模式输出用红和绿表示失败和成功的测试用例,看起来比较符合 TDD 的策略和定义
图 1.GTest 的案例测试结果输出Gtest 的断言有两种形式,致命性断言(Fatal Assertion)和非致命性断言(Nonfatal Assertion)。
除了基本的断言形式外,Gtest 还包括一些其他的高级断言形式,比如死亡断言,退出断言测试和异常断言等。
Gtest 还有其他的一些特性,比如类型参数化测试,值类型参数化的测试,测试用例分组,洗牌式测试等,可以参照附录中列出的 Gtest 的官网获取更多的信息。
在测试驱动软件开发的过程中,我们不可避免的要去依赖第三方系统,比如文件系统、第三方库、数据库访问,其他的在线数据的访问等,按照测试驱动开发的快速反馈的原则,如果在单元测试用例中去直接访问这些信息,势必在测试驱动开发过程中会依赖这些资源从而造成访问时间无法控制, 所以单元测试一般应该避免直接访问第三方系统,这就是 Mock 测试的主要目的,用模拟的接口去替换真实的接口,模拟出单元测试需要的第三方数据和接口进而隔离第三方的影响,专注于自己的逻辑实现。Gmock 就是这样一个 Mock 框架,它是类似于 jMock、EasyMock 和 Hamcres ,但是是 C++版本的 Mock 框架。 Gmock 是基于接口的 Mock 框架,在 C++中接口的定义是通过抽象函数和抽象类来实现的,这种要求势必会要求我们尽量遵循基于接口的编程原则,把交互界面上的操作抽象成接口,以便是接口可被模拟 Mock。可以在附录中列出的 Gmock 官网获取更多信息。
测试驱动开发的实践测试驱动开发和敏捷开发是相辅相成的,敏捷开发的需求一般是以故事、产品功能列表,或需求用例的方式给出,拿到这些需求后,开发团队会根据相应的需求文档分析需求,做功能分解,根据功能优先级制定迭代开发计划和测试计划。测试驱动开发可以从两个角度来看,广义的和狭义的。广义的测试驱动开发是从流程上规定测试驱动开发,这种情况下一般要求 QA 走到前面,先根据需求先开发测试用例,这些测试用例会作为功能验收的标准,然后开发人员会根据测试用例做详细的功能设计和编码实现,最后提交给 QA 做功能验收测试。 狭义的测试驱动开发是开发人员拿到功能需求后,先自己开发代码级别的测试用例,然后开发具体的实现通过这些测试用例的一种开发方法。 本文涉及的是第二种,从代码级别开始的,狭义的测试驱动开发。
相信每个人都玩过棋牌游戏,简单起见,为了实践测试驱动开发方法我想开发一款简单的三子棋游戏,如图 2 所示。三子棋的游戏规则很简单,只要是同样的三个棋子连成一条线那么持对应棋子的人就胜出,图中持 O 子棋的人获胜。总结一下三子棋游戏的基本需求:
- 我需要一个 3X3 的棋盘,可以用下三子棋。
- 我需要在棋盘上下棋和获取到棋子。
- 我要能验证和判断是不是三个棋子在同一条线上,以判断是不是有人胜出。
- 我不能放棋子到已被占用的棋位置上。
- 我要能判断是不是棋盘已满并无赢家。
- 我需要能复位棋盘,以便于重新开始下棋。
- 我需要用对记住玩家,以便于我能特例化 Player
- 我需要能保存和加载棋局能力,以便于我能下次回来继续之前的游戏。
图 2.三子棋游戏以上是三子棋游戏的基本需求列表,拿到这些需求后,我会做一些简单解决方案的设计,解决方案包括 4 个子工程(C++ Project),其中一个测试工程 TicTacToeGamingTest,其余三个分别是 TicTacToeLib,TicToeGamingLib 和 TicTacToeConsoleGaming,这三个工程的依赖关系是 TicTacToeConsoleGaming 依赖于 TicToeGaminglib 和 TicTacToeLib,TicToeGamingLib 依赖于 TicTacToeLib。 建好这些工程,有了基本的设计思路后,在测试工程里首先开发的测试代码。
图 3.解决方法设计先看第一个需求:
- 我需要一个 3X3 的棋盘,可以来下三子棋。这个需求很简单,现在的棋盘不需要包括任何的逻辑,为了便于测试我需要一个接口去访问它,现在接口是空的,也没有实现,这样一个测试用例就可以满足这个需求:
1
2
3
4
5
6
7
8
| TEST_F(TicTacToeTestFixture,IWantAGameBoard)
{
IGameBoard *gameBoard=NULL;
EXPECT_NO_THROW(gameBoard=new SimpleGameBoard("simpleGame"));
EXPECT_TRUE(gameBoard!=NULL);
EXPECT_NO_THROW(delete gameBoard);
}
|
这是第一个测试用例,稍微解释一下。TicTacToeTestFixture 是用于测试的分组的,它是一个类,继承于 Gtest 的 test 类 testing::Test,这个类可以重载 setup 和 teardown 等虚拟函数用于测试准备和清理测试现场。TEST_F 是定义测试用例的宏,IWantAGameBoard 是测试的案例的名称,会显示在输出中,测试用例很简单,只是只是保证能创建和析构 SimpleGameBoard 实例,并无异常抛出。这个测试用例现在是不能编译通过的,因为 IGameBoard 接口和 SimplegameBoard 都还没有声明和定义,接下来为了使这个案例通过,我在 TicTacToeLib 工程里,声明和定义 IGameBoard 和 SimpleGameBoard 类,IGameBoard 是纯抽象类,抽象了所有对棋盘的操作。引入声明到测试工程中,编译通过并运行,现在完成了第一测试用例,尽管测试的 IGameBoard 和 SimpleGameBoard 还是空的。可以看一下输出:
图 4 .测试用例输出 - 我需要在棋盘上下棋和获取到棋子这个需求能使棋手在棋盘上把棋子放到想要的位置上并能查看指定棋盘位置上的棋子,棋盘是 3x3。实现这个需求也很简单,我只要在 IGameBoard 接口上添加两个函数然后在 SimpleGameBoard 里实现这两个函数就可以满足这个需求:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| virtual void PutChess(int x,int y,char chess)=0;
virtual char GetChess(int x,int y)=0 ;
有了这个思路,我想这样设计这个测试用例:
TEST_F(TicTacToeTestFixture,PutandGetChess)
{
char xChess='X';
char yChess='Y';
IGameBoard *gameBoard=new SimpleGameBoard("simpleBoard");
gameBoard->utChess(0,0,xChess);
gameBoard->utChess(2,2,yChess);
EXPECT_EQ(xChess,gameBoard->GetChess(0,0));
EXPECT_EQ(yChess,gameBoard->GetChess(2,2));
delete gameBoard;
}
|
试着编译这个测试工程,失败,原因是没有实现这两个函数,接下来我回到 TicTacToeLib 工程去声明和定义这两个函数。为了实现这两个功能,在 SimpleGameBoard 定义 private 数据:vector<char> data_;用于 保存棋子和位置信息,为了简单,棋子用 Char 类型来表示,位置信息和 data_向量的下标对应,如棋盘位置(2,2)对应的是 data_[2*3+2]这个位置,数据是安行存放的。两个函数的实现是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| void SimpleGameBoard:utChess( int x,int y,char chess )
{
assert(x<xMaxDim&&y<yMaxDim);
int xy=x*3+y;
if(data_.size()==0)initboard_();
data_[xy]=chess;
}
char SimpleGameBoard::GetChess( int x,int y )
{
assert(x<xMaxDim&&y<yMaxDim);
assert(data_.size()==yMaxDim*xMaxDim);
return data_[x*3+y];
}
|
initboard_()是个 protected 函数,用于初始化 data_。 现在可以重现编译和运行测试工程,结果如下:
图 5 .测试用例输出有了两个测试用例的实现,并且运行是绿色,继续下个需求。
- 我要能验证和判断是不是三个棋子在同一条线上,以判断是不是有人胜出这个需求用于判断三个棋子是否已经在一条线上,如果是的话,那么持对应棋子的棋手就会胜出,这个测试用例可以这样设计:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| TEST_F(TicTacToeTestFixture,JugeThreeInLine)
{
IGameBoard *gameBoard=new SimpleGameBoard("simpleBoard");
IGameBoard *gameBoard2=new SimpleGameBoard("simpleboard2");
char xChess='x',yChess='o';
gameBoard->utChess(0,0,xChess); gameBoard2->utChess(0,1,yChess);
gameBoard->utChess(1,1,xChess); gameBoard2->utChess(1,1,yChess);
gameBoard->utChess(2,2,xChess); gameBoard2->utChess(2,1,yChess);
EXPECT_TRUE(gameBoard->CheckWinOut(xChess));
EXPECT_TRUE(gameBoard2->CheckWinOut(yChess));
EXPECT_FALSE(gameBoard->CheckWinOut(yChess));
EXPECT_FALSE(gameBoard2-)CheckWinOut(xChess));
delete gameBoard;
delete gameBoard2;
}
|
设计是这样的,为简单,我把判断棋子胜出的函数 CheckWinOut 定义到接口 IGameBoard 中,并在 SimpleGameBoard 中实现它,实现如下:
1
2
3
4
| bool SimpleGameBoard::CheckWinOut(char chess)
{
return IsThreeInLine_(chess);
}
|
IsThreeInLine_是受保护的成员函数,它会扫描棋盘的行,列和对角线看是否指定的棋子在一条线上,如果有三个棋子在一条线上,则说明有人胜出。编译运行测试,绿色通过。 继续下一个需求。
|