我曾经在相当一段时间内想搞清楚的主题是,如何在游戏开发中应用测试驱动开发(TDD)。每当我和朋友们在会议上或者邮件列表中谈论此主题时,每个人都表示出了极大的好奇心,并且立刻想了解更多。我发誓,我可以很快搞清楚这个问题的。
与此同时,我面临着这样的情况:我必须选择一个单元测试框架,以便我的团队应用于工作中。因此,在谈论如何游戏中使用测试驱动开发或者单元测试框架的价值又或者其他任何类似的主题之前,还是让我们来深入浅出的分析一下现存的一些C++单元测试框架吧。请坚持住,这将是一个漫长而崎岖不平的马道,并且将以曲折的故事情节结尾。
如果你只是想阅读一些有关特定框架,那么你可以从这儿直接进入:
总述
我们如何选择一个单元测试框架那?这取决于我们将要使用该框架来做什么以及如何使用它。如果我的工作语言是Java,那么选择就很容易,因为对于java写的程序,Junit似乎就是那个要选择的框架。我从来都没有听说过,在java阵营讨论过其他的框架,或者频繁的推出其他一些新的框架,想必是因为Junit已经足够好吧。
不幸的是,C++中情况并非如此。在Xunit家族中,我们也有自己的成员:CppUnit,但我们显然对它不是非常满意。C++中有如此多的单元测试框架以至于你开始动摇最开始坚持的选择。这导致了很多团队不使用已有的框架而重新编写适合自己框架。为什么会这样?是C++不适合于单元测试,以至于在语言层面实施Xunit测试方法时纰漏百出吗?Not
like it’s a bad thing, mind you. Diversity is good.
Otherwise I would be stuck writing this under Windows
and you would be stuck reading it with Internet Explorer.无论如何,我显然不是第一个问这个问题的人。这个网页试图回答这样的问题,并出现了一些非常合理的解释:编译器不同、平台不一致、编程风格各异。C++有自己的一套编码标准,它确实还是无法彻底的、完全的被支持的语言。
一种好的开始方法是,列一个表,在这个表里按重要程度记录我们想要做的工作类别的特点。特别是即将进行测试驱动开发,这意味着我将持续不断的编写并且运行很多小的测试用例。由于目标框架即将被用于游戏开发中,我期望在多种不同的平台(PC、Xbox、PS2、下一代控制台等等)上运行测试用例,它还要能够满足我个人的TDD风格(很多测试项,使用了很多fixture等等)。
下面的表单按重要程度排序摘录了目标单元测试框架的特点。我将根据这份特征列表评估每一个框架。感谢Tom
Plunket在一些主题上提供的一些不同的观点,这让我重新审视了不同特征的相对重要性。
添加新的测试用例时工作量最小化。由于我将总是要添加新的测试用例,所以我不想有太多的打字输入,并且我也不希望有任何的重复输入。写入的(代码)越简单越短,则越容易被重构,这在TDD中是至关重要的。
容易被修改和移植。它应该不依赖于任何非标准库,同时它还不能过于依赖C++特有的特征手法(RTTI/异常处理等等)。由于一些用于编译基于控制台开发的代码的编译器无法准确的裁剪边缘,因此,我在Linux系统下使用g++创建了一个单元测试用例集来验证这个问题。Since
most of the tests are written with Windows and Visual
Studio in mind, it’s not a bad initial test.
支持按步骤装载/卸载(fixtures)。David Astels在他写的测试驱动开发一书中,有这样一条关于实践准则的建议:在每个测试中仅写一条断言语句。而我采纳了他的建议。这真得使测试更容易理解和维护,可这也导致大量fixture的使用。不支持fixture的框架就应该立即被排除。与必须动态分配对象的框架相比,要求我在栈上声明fixture中使用的对象的框架应该可以加分。
能很好的处理异常和崩溃。我们并不想仅因为被测试代码访问了某段无效内存位置或者除数为0而导致测试终止。单元测试框架应该报告异常,并且给出关于异常足够多的信息。他还应该能够再次运行,并且能在异常发生处设置调试断点。
良好的断言功能。断言失败时,应该能够打印出被比较变量的内容。它还应该提供一个断言语句集来判断“近似相等”(float类型数据必备)、小于、大于等等。提供了检查异常是否已被抛出的方法的框架应该得到加分。
支持多种输出。默认情况下,我喜欢使用IDE(如Visual Studio或者KDevelop)能够易于理解并能解析的格式,这样就像定位语法错误一样,很容易的定位到任何测试失败处。但我还希望能够有其他的一些不同的输出方式(更详细、更简短、友好的解析等等)。
支持工具包。因为多数框架将该特征视为最突出的特征,所以该特征位于这份按优先级排序的特征集表单尾部是件很搞笑的事情。坦白的说,我以前对这个特征的需求性不是非常强烈。有这个特征固然好,但最终都会有大量库导入,而每个库又会有自己的测试用例集,因此,我几乎不需要它。Still,
it certainly would be nice to have around in case it
starts getting slow to run the unit tests at some point.
加分项:支持时间统计。包括统计运行所有的测试用例所耗时和运行单个测试用例所耗时。我比较关注测试用时。不是因为性能原因,而是严防测试失控。我习惯于将测试时间保持在3~4秒内(这是能够进行频繁测试的唯一方法)。理想情况下,如果某项测试时间超过了预先设定值,就打印一行警告就更好。
容易安装的优先级不会很高。我毕竟只需要安装一次——我每天要做的事情是创建新的测试用例。是不是友好的商业授权(类似GPL或者LGPL)也不是非常重要的特征,因为我们不会将单元测试框架和最终的应用程序一起链接发布,所以最终的产品不会受授权的限制。
顺便说一句,在我研究这个主题过程中,我发现另外一些人曾经已经将他们希望C++单元测试框架能做的事情汇集成表了。比较这两份表并且记录期望单元测试框架能做的事情的异同一定是一件很有乐趣的事情。
理想的框架
在重温所有主流(以及部分非主流)的C++单元测试框架之前,我想我应该将隐藏在测试驱动开发背后的哲学思想应用到这些分析中,然后开始思考我想要做什么。所以我决定,在不考虑语言的限制及其其他任何因素的情况下,编写一个特定于某些理想的单元测试框架的样例测试集。这就是我期望我的单元测试在理想的世界中能够做到的事情。
最简单的测试可能就是对创建对象时琐屑事情的测试。只需一行来声明测试,然后是测试体本身:
TEST (SimplestTest)
{
float someNum = 2.00001f;
ASSERT_CLOSE (someNum, 2.0f);
}
带有fixture的测试可能要复杂一些,但还是很容易就建立起来:
SETUP (FixtureTestSuite)
{
float someNum = 2.0f;
std::string str = "Hello";
MyClass someObject("somename");
someObject.doSomething();
}
TEARDOWN (FixtureTestSuite)
{
someObject.doSomethingElse();
}
TEST (FixtureTestSuite, Test1)
{
ASSERT_CLOSE (someNum, 2.0f); someNum = 0.0f;
}
TEST (FixtureTestSuite, Test2)
{
ASSERT_CLOSE (someNum, 2.0f);
}
TEST (FixtureTestSuite, Test3)
{
ASSERT_EQUAL(str, "Hello");
}
针对这些测试集,需要指出的是,和测试项自身不同,测试集的其他部分代码量开销最小。最简单的测试只有两行代码,它只需要运行所有测试项的主文件,而不需要任何其他文件的支持。使用setup/teardown调用建立一个带fixture的测试环境也是繁琐的。我并不想从任何类继承,重载任何函数以及其他任何事情。仅需要编写setup步骤,然后继续。
重新回顾一下setup函数。在所有测试用例中使用的变量都不是被动态创建的。取而代之的是,他们被声明在栈上,并在需要的地方被直接使用。再次,我需要指出的是,对象只需要在被测试之前创建出来,而不必在所有测试开始之前。测试中是如何正确地使用这些变量呢?我不知道,但那一定是我所希望的使用方式。这就是为什么是理想框架的原因。
接下来,让我们将这个理想框架和其他六个真实的单元测试框架做一个比较,这些真实的框架需要考虑编译和运行环境。
CppUnit
在C++阵营中,CppUnit可能是最被广泛使用的单元测试框架了,因此它将是一个很好的参考,其他的单元测试框架和它比较。我曾经在三、四年前使用过CppUnit,当时的印象就是它不怎么讨人喜欢。我记得它的代码是和MFC代码杂糅在一起,示例代码和框架在一起难解难分,软件配备的GUI(控件)栏目丑陋。最后,我移除了对MFC的依赖性,创建了一个只有控制台输出的补丁包。鉴于此,我这次对他不做详细介绍。
不得不承认,自那之后,CppUnit有了长足的进步。或许是我对它不报希望,这次我发现它比之前易于使用和配置了。尽管它仍然不完美,但比之前好很多了。但是,尽管它的文档足够得体,你仍然会不得不深层次的挖掘模块的文档介绍来找出一些可用的功能。
// Simplest possible test with CppUnit
#include <cppunit/extensions/HelperMacros.h>
class SimplestCase : public CPPUNIT_NS::TestFixture
{
CPPUNIT_TEST_SUITE( SimplestCase );
CPPUNIT_TEST( MyTest );
CPPUNIT_TEST_SUITE_END();
protected:
void MyTest();
};
CPPUNIT_TEST_SUITE_REGISTRATION( SimplestCase );
void SimplestCase::MyTest()
{
float fnum = 2.00001f;
CPPUNIT_ASSERT_DOUBLES_EQUAL( fnum, 2.0f, 0.0005
);
}
修改和移植起来都很容易。It gets mixed marks on this one.(这个版本的CppUnit可以混合)。一方面,它可以运行在windows和linux两种平台下,其功能被很好的模块化了(结果集、运行器、输出设备等等)。另一方面,CppUnit仍然需要RTTI、STL以及异常处理(我认为)的支持。尽管需要这些组件的支持并不就是世界的末日,但是在产品代码不需要使能RTTI的库或者不想将STL加入工程代码时,问题就会出现。
支持fixture。是的,如果你想在每次测试之前创建对象,那么这些对象就需要在setup中被动态分配,因此在这里不加分。
#include <cppunit/extensions/HelperMacros.h>
#include "MyTestClass.h"
class FixtureTest : public CPPUNIT_NS::TestFixture
{
CPPUNIT_TEST_SUITE( FixtureTest );
CPPUNIT_TEST( Test1 );
CPPUNIT_TEST( Test2 );
CPPUNIT_TEST( Test3 );
CPPUNIT_TEST_SUITE_END();
protected:
float someValue;
std::string str;
MyTestClass myObject;
public:
void setUp();
protected:
void Test1();
void Test2();
void Test3();
};
CPPUNIT_TEST_SUITE_REGISTRATION( FixtureTest );
void FixtureTest::setUp()
{
someValue = 2.0;
str = "Hello";
}
void FixtureTest::Test1()
{
CPPUNIT_ASSERT_DOUBLES_EQUAL( someValue, 2.0f,
0.005f );
someValue = 0;
//System exceptions cause CppUnit to stop dead
on its tracks
//myObject.UseBadPointer();
// A regular exception works nicely though myObject.ThrowException();
}
void FixtureTest::Test2()
{
CPPUNIT_ASSERT_DOUBLES_EQUAL( someValue, 2.0f,
0.005f );
CPPUNIT_ASSERT_EQUAL (str, std::string("Hello"));
}
void FixtureTest::Test3()
{
// This also causes it to stop completely
//myObject.DivideByZero();
// Unfortunately, it looks like the framework
creates 3 instances of MyTestClass
// right at the beginning instead of doing it
on demand for each test. We would
// have to do it dynamically in the setup/teardown
steps ourselves.
CPPUNIT_ASSERT_EQUAL (1, myObject.s_currentInstances);
CPPUNIT_ASSERT_EQUAL (3, myObject.s_instancesCreated);
CPPUNIT_ASSERT_EQUAL (1, myObject.s_maxSimultaneousInstances);
}
能够很好的处理异常和崩溃。是的,它使用一种称作保护器(protectors)的概念,这种保护器外覆于每个测试用例。默认保护器的行为是试图捕捉所有异常并标记某些异常。你还可以编写自己定义的保护器,并将它们放在堆栈中,同已有的保护器一起组合使用。在Linux下,保护器不会捕捉系统异常,因而增加新保护器的作用是微乎其微的。我认为还没有一种很容易的方式来停止处理异常,并使得调试器暂停在异常发生处(没有define或者命令行参数)。
良好的断言功能。相当棒。它有一个小型的断言语句集,包括浮点数比较的断言语句。遗憾的是,它没有小于/大于等断言语句。一旦断言失败,如果你需要获取尽可能多的测试失败信息的话,那么被比较的变量内容被打印输出到一个流中。
支持多种不同的输出格式。符合。CppUnit的输出器搭配监听器一起,很好的定义了该功能模块,其中输出器显示测试结果,监听器获取测试过程发出的所有通知消息。It
comes with an IDE-friendly output that is perfect for
integrating with Visual Studio. Also supports GUI progress
bars and the like.
支持工具包。符合。
总体而论,CppUnit令我懊恼不已,它拥有我大部分想要的功能,却没有我最想要的功能。我不敢相信在添加新的测试用例时需要敲入如此多的字符,还时长需要重复输入。除此之外,我还要抱怨它对RTTI和异常的依赖以及源代码相对复杂,复杂的源代码会导致在跨平台移植时困难。
Boost.Test
修正:Gennadiy Rozental 指出:在boost中相当容易添加fixture。根据他的解释,我已经调整了对Boost.Test框架的评注和各项评分。
我是一名忠实的boost爱好者,但同时也不得不承认的是:直到一年前我才了解到Boost已经提供一个单元测试框架库。
于是我迫不及待的把它check out。
Boost.Test给我的第一个惊奇是:它并不仅仅是一个单元测试框架。它还可以假装成为和测试相关的其他很多东西。Nothing
terribly wrong with that, but to me is the first sign
of a “smell.”第二个惊奇就是,它其实并没有建立在XUnit家族基础上。
它的文档是顶级的,在任何我看过的单元测试框架文档中是最好的。文档清晰的解释了概念,使用了大量简单的示例来阐述不同的特征。有趣的是,从文档上来看,Boost.Test被设计用来支持一些我自认为bad
practices,比如测试项跟其他测试项之间、长测试项内部都有依赖性。
添加新测试用例的工作量最小。基本达标。在添加新测试用例时,Boost.Test确实只需要做最少的工作。它和之前描述的理想单元测试框架很相似。不幸的是,为一个测试单元添加部分新的测试项时需要更多的输入,特别还是要为每一测试项进行注册。
#include <boost/test/auto_unit_test.hpp>
#include <boost/test/floating_point_comparison.hpp>
BOOST_AUTO_UNIT_TEST (MyFirstTest)
{
float fnum = 2.00001f;
BOOST_CHECK_CLOSE(fnum, 2.f, 1e-3);
}
易于修改和移植。和CppUnit相同的原因,It gets mixed marks on this
one。由于是Boost库的一部分,Boost.Test非常看重它的可移植性。它可以可完美的工作于Linux下(比其他大多
数框架表现好)。但是,我对它是如何简单的进入源代码并开始做些修改表示质疑。它偶尔还需要引入一些必要的Boost其他库的头文件,因此准确来讲,它不是很小,也不是自包含(自成体系)。
支持fixture。Boost.Test 使用C++中构造/析构函数来规避(模仿)测试单元结构的装载/卸载。
#include <boost/test/auto_unit_test.hpp>
#include <boost/test/floating_point_comparison.hpp>
#include "MyTestClass.h"
struct MyFixture
{
MyFixture()
{
someValue = 2.0;
str = "Hello";
}
float someValue;
std::string str;
MyTestClass myObject;
};
BOOST_AUTO_UNIT_TEST (TestCase1)
{
MyFixture f;
BOOST_CHECK_CLOSE (f.someValue, 2.0f, 0.005f);
f.someValue = 13;
}
BOOST_AUTO_UNIT_TEST (TestCase2)
{
MyFixture f;
BOOST_CHECK_EQUAL (f.str, std::string("Hello"));
BOOST_CHECK_CLOSE (f.someValue, 2.0f, 0.005f);
// Boost deals with this OK and reports the problem
//f.myObject.UseBadPointer();
// Same with this
//myObject.DivideByZero();
}
BOOST_AUTO_UNIT_TEST (TestCase3)
{
MyFixture f;
BOOST_CHECK_EQUAL (1, f.myObject.s_currentInstances);
BOOST_CHECK_EQUAL (3, f.myObject.s_instancesCreated);
BOOST_CHECK_EQUAL (1, f.myObject.s_maxSimultaneousInstances);
} |