白盒测试心得

2019-07-13 03:51发布

我维护的一个C编写的协议栈,运行于嵌入式Linux,缺乏文档和测试。以前没有这方面的经验,所以考察了ACE(自适应通信环境,http://www.cs.wustl.edu/~schmidt/ACE.html)的测试设计。以下是几点心得。

1.嵌入式应用软件如何测试?

嵌入式软件,如果是应用层的,则应当尽量少的修改移植到Linux PC上。这样就能做好单元测试、集成测试,也能充分利用DDD、Valgrind之类的工具定位BUG。《嵌入式软件测试策略》一文指出:“所有单元级测试都可以在主机环境上进行,除非少数情况,特别具体指定了单元测试直接在目标环境进行。最大化在主机环境进行软件测试的比例,通过尽可能小的目标单元访问所有目标指定的界面。软件集成也可在主机环境上完成,在主机平台上模拟目标环境运行,当然在目标环境上重复测试也是必须的,在此级别上的确认测试将确定一些环境上的问题,比如内存定位和分配上的一些错误。”

2. 测试框架,需要吗?

单元测试和集成测试没有严格界限。影响最大的单元测试框架是OO语言Java的JUnit。针对C++的CppUnit及其他OO语言的测试框架都很大程度上参考了JUnit的设计。CppUnit测试框架还是非常容易使用的,理解了测试用例、测试集之间的Composite模式(参考GoF《设计模式》)就差不多了。几个小时之内就能像模像样地用起来。

由于C++语言特性导致维护C++测试代码花费更多精力:在Java中,你写的测试类的所有以test开头的方法会被自动加入到测试集中。但C++没有反射,所以CppUnit每个测试集(TestSuite)要定义一个成员函数以包含所有的测试方法。

另一个麻烦是:我以前写的每个测试类都声明在一个对应头文件中,这个头文件被main以及相应测试类.cpp文件两者include。要增删测试方法,要改动至少两个文件;而要增删测试类也要改动至少两个文件。

如果TDD(测试驱动开发)方式开发软件,测试代码和产品代码的修改非常频繁。修改-测试迭代周期拖长了就会导致开发速度减慢、测试少(相应地,BUG必然多)等问题。CppUnit定义了一些宏简化上述操作。下面给出的范例,不需要为测试类编写头文件,而是在Makefile中由链接关系决定那些测试类被编译入测试集。

//测试类模板:FooTest.cpp
#include
#include
class FooTest : public CppUnit::TestCase
{
    CPPUNIT_TEST_SUITE(FooTest);
    CPPUNIT_TEST(testFoo1);
    CPPUNIT_TEST(testFoo2);
    CPPUNIT_TEST_SUITE_END();
public:
    void setUp(){...}
    void tearDown(){...}
    void testFoo1(){
        CPPUNIT_ASSERT(expr);
        CPPUNIT_ASSERT_EQUAL(expected, actual);
        CPPUNIT_ASSERT_EQUAL_MESSAGE(msg, expected, actual);
        ASSERT_STR_EQUAL(expected, actual);
        ASSERT_STR_EQUAL_MESSAGE(msg, expected, actual);
    }
    void testFoo2(){...}
}
CPPUNIT_TEST_SUITE_REGISTRATION(FooTest);

//测试主文件模板:unitmain.cpp
#include
#include
int main( int argc, char* argv[] )
{
    TestRunner runner;
    CppUnit::TestFactoryRegistry ®istry = CppUnit::TestFactoryRegistry::getRegistry();
    runner.addTest(registry.makeTest());
    bool wasSucessful = runner.run();
    return !wasSucessful;
}

//Makefile模板:Makefile
testsuite_MAIN = unitmain.cpp
testsuite_SOURCES = FooTest.cpp BarTest.cpp
SOURCES = $(testsuite_MAIN) $(testsuite_SOURCES)
all: dotest
dotest: SOURCES
    $(CC) $(CFLAGS) -o $@ $^

CppUnit是针对C++的,不适合用来测C代码:通过一番努力才能让测试代码调用产品代码,但反过来让产品代码调用测试代码(有时需要使用类似“桩”的技术)怎么也做不到。CUnit(http: //cunit.sourceforge.net/)是针对C的测试框架,比较合用。

测试框架最核心的功能就是把测试用例组织起来,其他的像收集测试结果、输出报告、GUI方式启动等花哨功能绝大部分时候不需要。不要迷信权威或者懒惰:其实自己写个脚本管理测试用例是简单、可行的。

我考察了ACE的白盒测试,发现它是用Perl来做的。docs/run_test.txt 是ACE自动化测试唯一的文档。ACE的自动测试是由十多个目录下的Perl脚本run_test.pl来做的,它读取文件run_test.lst获知所有的测试用例及其平台要求。所有的run_test.pl都基于一个自定义的 Perl模块PerlACE。bin/PerlACE目录下几个.pm(Perl模块)文件实现了PerlACE。docs/run_test.txt结合例子讲解了PerlACE的用法以及run_test.pl的书写方法。PerlACE以Perl脚本封装的可执行文件的启动、等待、杀死功能,其中最重要的模块是PerlACE::Run_Test。这儿的测试用例都是单个可执行文件(当然,执行期间可以用ACE_OS::fork创建Client和Server等进程,例如SOCK_Dgram_Test.cpp),每个用例都用Perl过程run_program来执行:

sub run_program ($)
{
......
    if ($config_list->check_config ('Valgrind')) {
      $P = new PerlACE::Process ($program);
      $P->IgnoreExeSubDir(1);
     }
    else {
      $P = new PerlACE::Process ($program);
      ### Try to run the program
      if (! -x $P->Executable ()) {
          print STDERR "Error: " . $P->Executable () .
                       " does not exist or is not runnable/n";
          return;
            }
     }
    print "auto_run_tests: tests/$program/n";
    my $start_time = time();
    $status = $P->SpawnWaitKill (400);
    my $time = time() - $start_time;
    ### Check for problems
    if ($status == -1) {
        print STDERR "Error: $program FAILED (time out)/n";
        $P->Kill ();
        $P->TimedWait (1);
    }
    elsif ($status != 0) {
        print STDERR "Error: $program FAILED with exit status $status/n";
    }

    print "/nauto_run_tests_finished: test/$program Time:$time"."s Result:$status/n";
......
}

docs/run_test.txt还介绍了如何用PerlACE来启动Client和Server两个进程进行测试:

# 准备工作。不要问我eval这句话有什么作用,我也不熟悉Perl。
eval '(exit $?0)' && eval 'exec perl -S $0 ${1+"$@"}'
    & eval 'exec perl -S $0 $argv:q'
    if 0;
use lib '../../../bin';
use PerlACE::Run_Test;
$status = 0;
$server_ior = PerlACE::LocalFile ("server.ior");
unlink $server_ior;
# 以指定的可执行文件名、参数列表构建进程。
$SV = new PerlACE::Process ("server", "-o $server_ior");
$CL = new PerlACE::Process ("client", " -k file://$server_ior ");
# 启动Server进程。 "The PerlACE::waitforfile_timed method waits until the file is created.  In this way, we know when to start the client.  If no IOR file is used, then you'd need to use Perl's sleep method."
$SV->Spawn ();
if (PerlACE::waitforfile_timed ($server_ior, 5) == -1) {
    print STDERR "ERROR: cannot find file <$server_ior>/n";
    $SV->Kill ();
    exit 1;
}
# 启动Client进程并限时等待其结束。 " SpawnWaitKill will start the process and wait for the specified number of seconds for the process to end.  If the time limit is reached, it will kill the process and return -1. The return value of SpawnWaitKill is the return value of the process, unless it timed out."
$client = $CL->SpawnWaitKill (60);
if ($client != 0) {
    print STDERR "ERROR: client returned $client/n";
    $status = 1;
}
# 停止Server。"Servers are usually terminated either by TerminateWaitKill or just WaitKill. TerminateWaitKill is used when the server doesn't shut down itself.  WaitKill is used when it does (such as when the client calls a shutdown method)."
$server = $SV->TerminateWaitKill (5);
if ($server != 0) {
    print STDERR "ERROR: server returned $server/n";
    $status = 1;
}
# 以上任何步骤返回值非0表示本用例执行失败。无论成功与否都在退出前删除测试过程中创建的文件。
unlink $server_ior;
exit $status;

在我的实际项目中,我把每个TestCast都编译成一个可执行文件,用一个Python脚本管理了它们的编译和启动。虽然比PerlACE少了进程控制功能,但对我来说也够用了。我将测试代码与产品代码分离,为测试代码编写适当的Makefile或者SCons配置文件(不需要对产品代码中的Makefile或SCons配置文件做任何改动),使得测试代码的编译和运行动作总是绑定的:一个命令就可以将测试代码与最新的产品代码编译链接、运行测试。

3. 网络化、多线程应用程序如何测试?

我以前做的单元测试都非常简单,不过是对基本的数据结构的各种操作方法测来测去(我见到的CppUnit和JUnit所有资料上也是如此),不涉及到网络、多线程的复杂环境。我以前做SNMPv3引擎时,为管理者和代理分别做了个例子程序,然后手工启动并观察运行效果。这样的测试一点也不自动化、一点也不充分。

test/Barrier_Test.cpp测试了ACE栅栏同步机制。进行ACE_MAX_ITERATIONS轮测试,每一轮创建ACE_MAX_ITERATIONS个线程,每个线程进行ACE_MAX_ITERATIONS次栅栏同步。

test/Message_Queue_Test.cpp有以下4个作用:
0) a test that ensures key ACE_Message_Queue features are working properly, including timeouts and priorities.
1) a simple test of the ACE_Message_Queue that illustrates how to use the forward and reverse iterators;
2) a simple performance measurement test for both single-threaded (null synch) and thread-safe ACE_Message_Queues, and ACE_Message_Queue_Vx, which wraps VxWorks message queues; and
3) a test/usage example of ACE_Message_Queue_Vx.

test/Semaphor_Test.cpp测试了ACE_Thread_Semaphore的功能。创建了10个线程,每个线程10次获取锁并持有一会儿后释放它,测试超时次数。

test/SOCK_Dgram_Test.cpp测试了UDP插口ACE_SOCK_Dgram类。它用ACE_OS::fork创建了两个进程分别作为Client和Server运行定制的函数。在open之后进行了一轮send/recv然后close。

test/SOCK_Send_Recv_Test.cpp 测试了TCP插口ACE_SOCK_Stream、ACE_SOCK_Connector、ACE_SOCK_Acceptor共3个类。它用 ACE_OS::fork创建了两个进程分别作为Client和Server运行定制的函数。进行了5组数据的收发测试,双方都验证了收到的每组数据每个字节的正确性。

test/Thread_Mutex_Test.cpp测试了进程范围内的锁(相当于pthread_mutex) ACE_Thread_Mutex。创建了ACE_MAX_THREADS个线程,线程进行多次迭代,每次迭代都尝试获得锁再释放锁。获得锁的两种方式(指定或不指定超时)都做了测试。

examples/Reactor/WFMO_Reactor/run_test.pl对应着 WFMO_Reactor的测试用例(不含3个交互式的)。这儿的测试用例都是单个可执行文件就搞定的。WFMO_Reactor仅针对Win32平台,所以这儿把测试用例列表写在run_test.pl中而不是另一个文件中。

examples/Reactor/TP_Reactor/run_test.pl对应着TP_Reactor的测试用例。测试用例只有1个,是CS结构的,C端和S端各一个可执行文件。它创建一个Server进程和2个Client进程。

可见,网络化、多线程应用程序的测试并没有什么神秘的:与之前相同的是仍然要编写断言来判断用例成功与否,不同的是用例中一般需要创建若干线程并构造好交互的场景。

4. 测试桩和驱动

Java的EasyMock配合JUnit使得做测试桩非常容易。C++的模拟对象有mockpp(mockpp.sourceforge.net/)等,都不如Java的EasyMock好用。自己编写C测试桩的工作量太大。所以在我的项目实际中,我没有编写测试桩,也就没有区分单元测试和集成测试。做白盒测试时,测试代码的编译能很好地反映出模块之间的依赖关系,这对精炼模块接口是很有好处的!