ShortCUT - 一个简短的 C++ 单元测试框架






4.60/5 (9投票s)
一个非常简单、可定制的 C++ 开发人员单元测试框架

引言
这个单元测试框架包含大约 125 行代码,位于单个头文件中。它旨在为 C++ 开发人员提供最简单的入门方法来编写单元测试。由于简单,它也很容易定制。许多单元测试框架要求链接到一个单独的库,或者需要经过几个繁琐的步骤才能开始。这会增加开始编写测试的难度。
开发人员在开始编写单元测试时经常面临的一个情况是,他们已经在处理一个项目。如果项目没有被分解成独立的库,就很难编写独立的单元测试。此外,程序的许多核心功能根本无法分解成单独的测试可执行文件。理想情况下,开发人员应该能够编写一组单元测试,在程序本身中使用单个函数调用包含它,然后轻松地 #define
掉测试。
这在软件工程方面可能不是一个强大的方法。然而,普遍的共识是,尽早、频繁地测试比不测试要好。通过尽量减少开始编写测试所需的精力,可以更容易地实现尽早测试的目标。随着测试套件的增长和项目的推进,可以随着时间的推移将测试分解为单独的库或可执行文件。
背景
我开始我的探索时,寻找现有的解决方案。在单元测试社区中,人们对 xUnit 框架表现出浓厚的兴趣和活跃的参与。它起源于 Smalltalk 的 SUnit 框架,后者启发了 JUnit 的开发者。反过来,这又启发了 NUnit、CppUnit 和其他几个类似框架的创建者。
这些框架中最普遍的一个共同点是,它们都是使用支持某种反射能力的语言构建的。这使得更容易为测试函数或设置函数分配属性,并让它们自动包含在测试运行中。C++ 开发人员就没有那么幸运了。C++ 开发人员必须更手动地进行操作(或求助于模板或宏)。但这并不是什么大问题。这正是我们所期望的。
关于 xUnit 社区中的单元测试和可用的各种库,已经写了许多文章。然而,关于 C++ 开发人员的选项,却出乎意料地写得很少。在 Games from Within 上的一篇 文章 对一些可用的框架进行了概述。
有关单元测试的总体介绍,请参阅 Code Project 上的一篇 综合性文章。
设计
在评估了几个框架之后,我决定没有一个满足我极其易于使用和修改的基本标准。我决定看看编写一个可以包含在单个头文件中并且代码行数尽可能少的框架有多难。以下是我基本设计标准列表:
- 包含在单个头文件中(没有源文件或库)
- 代码行数不超过几百行
- 易于修改和扩展
- 可重定向的消息输出
- 可选宏
- 不使用模板
- 不进行动态内存分配
- 可在嵌入式环境中使用
- 可在低版本 C++ 编译器中使用(无需复杂的 C++ 功能)
不动态分配任何内存的设计约束将允许在栈上创建测试。这将使得编写一个简单的 main
程序入口点,并一次性声明和运行测试变得容易,而无需担心清理或内存泄漏。
这些约束的一个后果是需要为每个测试创建一个类。使用函数指针的替代方法不允许在不分配内存的情况下在套件中链接测试。而且,使用函数指针似乎不符合 C++ 的精神。
Using the Code
要使一个测试运行起来,需要三件事:
- 必须编写一个测试用例
- 可以编写一个测试套件
- 必须将测试套件和测试用例添加到运行器类中,然后调用
我们将按照这个顺序进行说明。附带下载的示例有所不同。
编写测试用例
在此示例中,我们将测试用例从 TestCase
基类派生。TestCase
与框架的其他组件一样,是一个 struct
。这有助于我们避免大量的 public
访问说明符。
测试代码添加到单个 test
方法中。TestSuite
类包含跨测试用例共享的任何数据,并将其传递给每个 test
调用。在测试用例失败时,name
方法用于提供有意义的输出。
struct TestAccountWithdrawal : TestCase
{
const char* name() { return "Account withdrawal test"; }
void test(TestSuite* suite)
{
TestAccountSuite* data = (TestAccountSuite*)suite;
data->account->Deposit(10);
bool succeeded = data->account->Withdraw(11);
T_ASSERT(succeeded == false);
T_ASSERT(data->account->Balance() == 10);
}
};
添加测试套件
测试套件包含一组相关的测试。它在某些其他单元测试框架中既充当测试套件又充当测试夹具。
两个关键方法(都是可选的)是 setup
和 teardown
。每次调用测试用例都由这对调用构成。
struct TestAccountSuite : TestSuite
{
const char* name() { return "Account suite"; }
void setup()
{
account = new Account();
}
void teardown()
{
delete account;
}
Account* account;
};
整合
一旦编写了测试套件和至少一个测试用例,就可以将它们添加到运行器中并执行。
#include <stdio.h>
#include "shortcut.h"
#include "tests/account.h"
int main(int argc, char* argv[])
{
TestRunner runner;
TestAccountSuite accountSuite;
TestAccountWithdrawal accountWithdrawalTest;
accountSuite.AddTest(&accountWithdrawalTest);
runner.AddSuite(&accountSuite);
runner.RunTests();
return 0;
}
这个轻量级系统的一个好处是,所有测试代码都可以保存在头文件中。这避免了单独的类声明和实现之间的一些重复。由于单元测试框架实现为一个头文件,因此只需要一个驱动程序模块(例如,包含 main
)。
这个系统也使得向现有应用程序添加测试变得容易。例如,可以在程序启动时在 #ifdef DEBUG
部分调用测试。在发布模式下,应用程序二进制文件中不会留下任何测试的痕迹。当链接到其他单元测试库时,情况可能并非如此。
显然,这不是一个长期的解决方案。但这是一个很好的入门方式。开发人员可以立即开始编写测试,并且随着时间的推移,可以将测试分解为单独的可执行文件。
内部
此部分可以跳过。它解释了(非常少量的)活动部件是如何工作的。
基类
所有测试用例都派生自一个非常基本的类,称为 TestCase
。
struct TestCase
{
TestCase() : next(0) {}
virtual void test(TestSuite* suite) {}
virtual const char* name() { return "?"; }
TestCase* next;
};
该类有一个 name
访问器方法,用于记录错误。测试套件使用 next
指针将测试用例链接成一个链表。测试本身在虚拟 test
方法中实现。
测试套件具有相同的结构,只是它包含一个测试列表,并有不同的方法可以覆盖:setup
和 teardown
。
struct TestSuite
{
TestSuite() : next(0), tests(0) {}
virtual void setup() {}
virtual void teardown() {}
virtual const char* name() { return "?"; }
void AddTest(TestCase* tc)
{
tc->next = tests;
tests = tc;
}
TestSuite* next;
TestCase* tests;
};
如前所述,测试套件类在其他框架中扮演着与测试套件和测试夹具类相同的角色。在这些框架中,套件通常充当测试分组结构,而夹具提供设置/拆卸机制。由于 ShortCUT 是一个如此简单的框架,因此无需创建这种额外的复杂性级别。如果开发团队需要此功能,可以轻松地作为自定义添加。
运行器
测试运行器是系统的核心。它也非常直接。主例程 RunTests
为每个套件调用 RunSuite
。
struct TestRunner
{
...
void RunSuite(TestSuite* suite, int& testCount, int& passCount)
{
TestCase* test = suite->tests;
while (test)
{
try
{
suite->setup();
test->test(suite);
passCount++;
}
catch (TestException& te)
{
log->write("FAILED '%s': %s\n", test->name(), te.text());
}
catch (...)
{
log->write("FAILED '%s': unknown exception\n", test->name());
}
try
{
suite->teardown();
}
catch (...)
{
log->write("FAILED: teardown error in suite '%s'\n", suite->name());
}
test = test->next;
testCount++;
}
}
...
}
这里需要注意的关键点是,首先,log
类可以在框架外部实现和设置。这使得可以轻松地将结果显示到其他输出目标,例如窗口。
第二个令人恼火的点是,测试套件和测试用例是以单向链表的形式链接在一起的。这意味着它们是按 LIFO(后进先出)顺序遍历和执行的。这与它们添加的顺序相反。
自定义框架以解决此类烦恼是一个简单的过程。我选择不这样做,因为目标是使框架尽可能简单。
定制和结论
框架的主要目标是在设计要求和约束范围内,拥有最简单的系统。每一行代码都经过仔细审查,以确定其价值。在某些情况下,例如 TestLog
类,添加了几行代码,因为它们有助于满足设计要求。即使框架可以更简单,它也会失去基本的灵活性。
该头文件大约有 200 行代码。其中四分之一的代码实际上是不必要的。它被包含进来是为了举例说明如何通过异常实现自定义断言功能,以及如何实现几个辅助宏以避免重复代码。
该框架可以基本使用。希望它能成为根据开发者需求量身定制的系统的基础(而不是反过来)。它应该提供足够的实用性以便快速上手,并且其基本结构应该使其易于修改、定制和扩展。
历史
- 2007 年 2 月 15 日:原始文章