1 测试内容和常用模块

CPAN上有很多成熟的模块可以拿来帮助我们对perl脚本做单元测试,本文整理了它们的用法。

· perl模块推荐

Perl单元测试_Perl


2代码覆盖度测试

Devel::Cover是一个代码覆盖度测试的很棒的模块,它能自动分析并且生成一份详细的报告,而且可以生成html版本的,方便阅读

2.1模块安装

1、root帐号下,用CPAN方式安装,以解决模块依赖问题。(命令行输入perl -MCPAN -e 'install Devel::Cover')

2、安装完毕后,运行cover -v,
查看是否能找到cover命令
i.如果能找到cover命令

打开cover脚本(which cover),看看第一行的perl路径是否是系统perl的路径(which perl),不是的话,替换成后者。这样做的一个原因,是我们有可能会更新了Perl版本(譬如从5.8版本升级到5.12版本),而cover程序却可能依然调用老版本的Perl,这样可能会造成版本不一致带来的bug --事实上在我的机子上就发生了这样的问题。
ii.如果找不到cover命令

从CPAN上下载Devel::Cover安装包,解压缩后,将其中的cover、cpancover、gco2perl三个文件修改首行的perl路径,并放到perl路径下去(或任何一个PATH环境变量的目录)

2.2从一个例子开始

如下是一个名叫wiki.pl的perl脚本,它定义了GetDivInt函数和echo函数,并且两次调用GetDivInt函数,最后脚本退出

use strict;

use warnings;

sub GetDivInt

{

my ($a,$b) = @_;

return 0 if($a== 0 || $b == 0);

my $r = $a/$b;

if($r < 0.0001){

$r = 0;

}

$r = $1 if($r =~ /^(\d+)\./);

return $r;

}

 

sub echo {

print "@_\n";

}

 

print GetDivInt(3,4);

print GetDivInt(0,2);

运行如下命令

$ cover -delete

$ perl -MDevel::Cover wiki.pl

$ cover

2.2.1查看覆盖率报告

运行完cover后,会在当前目录下生成一份包括HTML格式的表格化的总结报告。

2.2.2分析覆盖率报告

覆盖测试有哪些类型?

语句覆盖

看是否有一项测试执行了某一条语句。对于给定的语句 $flag = 1; 任何测试项目在运行时执行了这条语句的话,就认为已经覆盖到了该语句。

 

分支覆盖

跟踪是否有测试项目执行了分支语句的各个部分。对于给定的代码 print "True!" if $flag; ,该语句必须被执行两次(分别当$flag为真和假),才算打到百分百的分支覆盖率。

 

条件覆盖

考察逻辑表达式的各种可能性。对于这样的短路表达式 $a = $x || $y; ,需要三种不同的测试来覆盖(00、01、1x)。

 

子程序覆盖

检查测试项目是否至少运行了子程序的一部分。

· 覆盖率报告

Perl单元测试_测试_02



从图上可以看到,这次只对wiki.pl进行了覆盖率测试,其语句覆盖率、分支覆盖率、条件覆盖率、子程序覆盖率分别为87.5、66.7、66.7、75.0,总的覆盖率为79.3。
点击图上的wiki.pl,可以查看wiki.pl的覆盖率完整详细的报告。

· 分支覆盖率报告

Perl单元测试_测试_03


· 条件覆盖率报告

Perl单元测试_单元_04



· 子程序覆盖率报告

Perl单元测试_单元_05



需要注意子程序覆盖率报告中出现了两个BEGIN块,这是由于wiki.pl调用了strict.pm、warnings.pm的原因。

2.2.3我们刚才做了什么?

我们刚才运行了三条语句,然后就得到了覆盖率报告,下面讲一下这三条语句的含义。

· cover -delete
删除当前目录下的cover_db文件夹,这个文件夹包含覆盖率统计的一些数据文件。
如果一个新的测试忘掉删除这个文件夹,则会带入上次的统计数据从而影响正确的统计结果。
同理,如果一个perl脚本需要运行多次(譬如带不同的参数),则每次运行完不应该删除该文件夹

· perl -MDevel::Cover wiki.pl
这是对一个脚本进行覆盖率统计的语法,实际上就是调用Devel::Cover模块,运行wiki.pl脚本,然后生成统计数据文件到cover_db文件夹中。

· cover
读取cover_db文件夹中的统计数据,生成覆盖列表报表,打印覆盖率统计结果。

以上是对一个脚本进行统计,除此外,还有以下的几种情况。

· 对一个未安装模块的测试

cover -test

cover -delete

HARNESS_PERL_SWITCHES=-MDevel::Cover make test

cover

· 对一个未安装模块的测试,如果它是用Module::Build模块来安装的

./Build testcover

· 如果这些模块不使用t/*.t的测试架构

PERL5OPT=-MDevel::Cover make test

更具体的内容,可以上CPAN搜索Devel::Cover进行查看

2.2.4缺陷

Devel::Cover模块没有重大的功能缺陷或bug,除了官方公布的缺陷外,我在使用过程中还发现它目前并不支持对多线程的脚本的覆盖率测试。

3性能检测

性能检测一方面是查看脚本运行时CPU、内存等使用情况,另一方面是检查脚本的运行效率,了解脚本速度瓶颈在哪,继而解决瓶颈问题。前者可以用top、vmstat等系统命令观察,后者才是下面讨论的内容。
检查脚本的运行效率,有三个很好用的模块,分别是Benchmark、Devel::DProf和Devel::SmallProf。
Benchmark模块主要用来比较一段或几段代码的运行时间,比较适合在需要对特定函数或代码进行性能优化时。
Devel::DProf模块的功能是记录每个子例程的运行时间。它会向一个叫tmon.out的文件倾倒计时信息,随后用dprofpp程序(随Perl一起发布,默认已安装)分析tmon.out并产生输出。
Devel::SmallProf跟Devel::DProf功能类似,但它输出的是每一行程序的执行时间,而不是一个子例程。
我们可以先使用Devel::DProf模块获取所有子进程的运行时间情况,如果有进一步的需要,再选择Benchmark或Devel::SmallProf模块做细致的调节。因此以下重点介绍Devel::DProf模块的使用方法。

3.1模块安装

Benchmark和Devel::DProf(包括dprofpp程序)都是默认安装的模块,Devel::SmallProf需要人工安装,依然建议CPAN方式安装。
同前面所描述的,我们可以检查并更新一下dprofpp程序的调用的perl路径。

3.2一个例子

以下名为test.pl的Perl脚本定义了三个函数,功能都是在一个或两个有序数组中查找一个数字是否存在。(为分析简单,没有use strict;use warnings;)

@a = (1..999999);

@b = (11111..1111111);

 

sub in_a {

$num = $_[0];

@arr = @{$_[1]};

foreach(@arr){

return 1 if $_ == $num;

}

return 0;

}

 

sub in_b {

$num = $_[0];

@arr = @{$_[1]};

 

($ta,$tb) = (0,scalar @arr - 1);

while($ta<$tb) {

$mid = int( ($tb+$ta) / 2 );

return 1 if $arr[$mid] == $num;

if($arr[$mid] > $num){

$tb = $mid;

}else{

$ta = $mid;

}

}

return 0;

}

 

sub in_both {

return 0 if in_b($_[0],$_[1]) == 0;

return 0 if in_b($_[0],$_[2]) == 0;

return 1;

}

 

in_a(999998,\@a);

in_b(999998,\@a);

in_both(999998,\@a,\@b);

获取运行时间报告
首先,我们执行如下命令:

$ perl -d:DProf test.pl

$ dprofpp

获取到的输出如下:

Total Elapsed Time = 0.969994 Seconds

User+System Time = 0.969994 Seconds

Exclusive Times

%Time ExclSec CumulS #Calls sec/call Csec/c Name

35.0 0.340 0.340 3 0.1133 0.1133 main::in_b

18.5 0.180 0.180 1 0.1800 0.1800 main::in_a

0.00 - -0.000 1 - - main::BEGIN

0.00 - 0.240 1 - 0.2400 main::in_both

第一行表示程序的运行时间,第二行显示了代码执行时间和系统调用时间的。
Exclusive Times表示在计算in_both函数的%Time时,不会把被它调用的in_b函数的运行时间算进去(这也就解释了为什么报告中in_both函数的%Time值为0)。如果需要改变这个模式,给dprofpp加上-I参数。

%Time表示这个子进程调用花的时间的百分比;ExclSec表示在这个子进程上花的时间(单位为秒,不包括它所调用子进程的运行时间);CumulS 表示在这个子进程上花的时间(单位为秒,包括它所调用子进程的运行时间);#Calls表示调用该子进程的次数;sec/call表示每次调用该子过程花 费的时间(单位为秒,不包括它所调用子进程的运行时间);Csec/c,平均每次调用该子过程花费的时间(单位为秒,包括它所调用子进程的运行时间)。

从报告中,我们可以看到,用二分查找算法的in_b函数,运行时间比简单遍历的in_a函数节省了30%多。

除了前面讲到的-I参数,dprofpp还有很多其它参数,给我们分析运行时间提供了很多遍历,具体可以参考CPAN上的帮助文档(其实你直接perldoc dprofpp也行)。

4内存相关

Perl脚本的内存问题,一般都不是问题,这是因为我们写的perl脚本都比较短小,很少涉及大内存的操作,并且不会长时间运行从而造成内存泄漏。


以下是几个节省内存的小建议:

1、不要将大文件一次性读入。譬如@arr = < FH >; 或undef $/; $str = < FH >; 。这是因为Perl的哈希和数组结构在记录数值的同时,还需要记录大量其它标志信息,因此内存开销非常大。如果需要对大文件进行原地修改,并且非得一次性读入文件才行的话,推荐使用Tie::File模块,需要注意的是,对于单条记录数据长度很短的文件,该模块在处理时存放偏移量的内存开销非常大,因此并不适用于处理大量短小记录的文件。

2、速度不是瓶颈的话,少用多线程。多线程除了耗内存外,还是不安全的,perl的多线程机制由于是后期版本硬加入的,一直以来都没有完全解决它的一些基本的设计缺陷。

 

其它,推荐两个模块,Devel::Memalyzer和Devel::Size。 前者是一个perl程序内存使用情况的分析框架,后者很方便用来测试一个数据结构的占用内存。Devel::Memalyzer需要下载安装(注意将安装 包中script目录下的memalyzer.pl、memalyzer-combine.pl更改perl执行路径后放到PATH目录 下),Devel::Size默认随Perl一起发布。

5单元测试框架

Test::Class和Test::Unit是两个最常用的perl脚本单元测试框架的模块。
Test::Class提供了类似于xUnit风格的测试模型,能够很方便地用来创建测试类和测试对象。由于Test::Class是基于Test::Builder模 块创建的,因此你可以配合Test::Class使用任何Test::Builder系列的测试模块,例如Test::More、 Test::Exception或是Test::Deep,这一点是Test::Class相对于Test::Unit的一大优势。除此外,通过创建 Test::Class的子类、孙子类等等,你可以很容易地重用测试类以及管理测试。
Test::Unit类似于JUnit框架,它也是一个很好的perl脚本单元测试框架模块,并且同样支持通过子类的方式扩展测试类。但由于它并不基于 Test::Builder,无法用到Test::Builder系列测试模块的强大作用,这里并不多做介绍,有兴趣可以看文档了解一下。

5.1模块安装

Test::Class不是标准模块,因此需要我们自己进行安装。方便起见,依然推荐root帐号下,用CPAN方式安装。此处不再赘述。

5.2一个例子

5.2.1 待测库

如下是一个名叫Game.pm的库,它定义了reversHash和getPlayers两个方法。
reversHash将形如 ( A => [a,b], B => [a,c] ) 这样的初始hash,转换成 ( a => [A,B], b => [A], c => [B] )。
getPlayers则是在给定初始hash和参数a时,返回数组(A,B)。
譬如我们的QA趣味活动,A参加了比赛项目a和b,B参加了比赛项目a和c,reversHash和getPlayers这两个函数是以比赛项目为key,获取参加该项目的同学名单。
以下是Game.pm的代码:

package Game;

use strict;

use warnings;

sub reversHash {

my %in = %{$_[0]};

my %out;

foreach my $key (keys %in){

foreach(@{$in{$key}}){

push @{$out{$_}},$key;

}

}

return \%out;

}

 

sub getPlayers {

my ($player_hash_ref,$game) = @_;

my $game_hash_ref = Game::reversHash($player_hash_ref);

if(exists $game_hash_ref->{$game}){

return @{$game_hash_ref->{$game}};

}else{

return "";

}

}

 

1;

5.2.2 测试程序

针对这个库,我们利用Test::Class来编写测试程序test.pl,如下:

use Game;

use Test::More;

use base qw(Test::Class);

 

my %players;

 

sub initial : Test(setup) {

%players = ( '小A' => ['拔河','嗒嗒球'],

'小B' => ['嗒嗒球','绑腿跑'],

'小C' => ['拔河','嗒嗒球'] );

print "Begin One Test...\n";

}

 

sub end : Test(teardown) {

print "End One Test...\n";

}

 

sub test_reverse : Test(1) {

my $games_ref = Game::reversHash(\%players);

my %games = ( '拔河' => ['小A','小C'],

'嗒嗒球' => ['小A','小B','小C'],

'绑腿跑' => ['小B'] );

is_deeply($games_ref,\%games,"reverse_Hash test");

}

 

sub test_getPlayers : Test(2) {

my @players = Game::getPlayers(\%players,'绑腿跑');

is_deeply(['小B'],\@players,"getPlayers test: match set");

ok( Game::getPlayers(\%players,'篮球') eq "","getPlayers test: empty set");

}

 

Test::Class->runtests();

然后我们运行一下

$ perl test.pl

Begin One Test...

1..3

ok 1 - getPlayers test: match set

ok 2 - getPlayers test: empty set

End One Test...

Begin One Test...

not ok 3 - reverse_Hash test

# Failed test 'reverse_Hash test'

# at test.pl line 23.

# (in main->test_reverse)

# Structures begin differing at:

# $got->{嗒嗒球}[0] = '小C'

# $expected->{嗒嗒球}[0] = '小A'

End One Test...

# Looks like you failed 1 test of 3.

5.2.3测试结果分析

由于我们的测试程序派生于Test::Class,因此我们 use base qw(Test::Class); (关于base的问题,可以查看base模块说明文档);由于我们的测试程序需要用到Test::More的is_deeply、ok方法,因此我们 use Test::More; 。

Test::Class非常聪明,它能跟踪任何从它派生出来的子类的信息,通过调用runtests()方法,所有测试case得到执行。

Test(setup) 和 Test(teardown) 是一套测试夹具,Test::Class会在每个普通测试方法之前调用 initial ,并在结束之后调用 end 。因为我们的测试程序定义了两个测试方法, test_reverse 和 test_getPlayers ,因此我们在测试结果输出中看到打印了两遍“Begin One Test..."、"End One Test..."。 除了setup和teardown两种方法外,还有startup和shutdown两种方法,它们对单个测试文件来说,只在启动第一个测试方法和结束最后一个测试方法时被运行,而不是每个测试方法都运行。你可以看到,其实我们这个测试程序,使用starup和shutdown更为合适,因为我们的两个测 试方法共用同一份初始化数据,并且,shutdown在这里还是完全可以省略的,因为它实际上没干什么必要的活。

因为 test_getPlayers 中一共进行了两个测试( is_deep 和 ok ),因此我们函数定义时,用了 Test(2) ,同理对于 test_reverse 。

is_deeply 是Test::More模块中用来比较复杂数据结构的方法(除此外,Test::Builder系列还有很多其它的好用的方法--要不我怎么说比之Test::Unit我更喜欢Test::Class呢 )。那么为什么我们的 test_reverse 里面的 is_deeply 会测试失败呢?原因在于Game.pm中的reversHash函数,是用 keys %in 获取hash的key的,而我们知道用这种办法获取出来的key是乱序的,并不保证跟我们定义hash时key的先后顺序相同。可以用一行代码做个实验:

$ perl -e '%hash = qw(小A 1小B 2小C 3);print keys %hash'

小C小A小B

可以看到,输出key时已经乱序了。

5.2.4更进一步

前面我们看到Game.pm的reversHash函数处理后的数组值,都是乱序排列的,如果作为研发的某甲决定在Game.pm的基础上扩展一个子类,在这个子类里更改reversHash函数的行为,使得原先乱序排列的结果变成按字典排序,某甲决定命名该子类名字为Game::Sort.pm,因此他在Game.pm文件的同级目录上,创建了一个Game目录,并在Game目录中编写了Sort.pm库,代码如下:

package Game::Sort;

 

use strict;

use warnings;

 

use base 'Game';

 

sub reversHash {

my %in = %{$_[0]};

my %out;

foreach my $key (sort keys %in){

foreach(@{$in{$key}}){

push @{$out{$_}},$key;

}

}

return \%out;

}

 

1;

可以看到,Game::Sort.pm修改了Game.pm的reversHash函数,把原先的 keys %in 改成了 sort keys %in ,从而实现字典排序。
既然Game::Sort.pm扩展于Game.pm,那我们的测试脚本能不能也在原先test.pl基础上进行扩展,从而复用我们先前的测试代码?
Test::Class给我们赋予了这种能力,不过我们这么做,首先得调整一下test.pl,把它改成一个真正的类。以下我们一步步来实现。

1.把test.pl改成Game::Test.pm模块,除了把test.pl改名Test.pm,并且扔到Game目录下以外,我们还得把代码修改如下:

package Game::Test;

 

use Game;

use Test::More;

use base qw(Test::Class);

 

my %players;

 

sub initial : Test(starup) {

%players = ( '小A' => ['拔河','嗒嗒球'],

'小B' => ['嗒嗒球','绑腿跑'],

'小C' => ['拔河','嗒嗒球'] );

print "Begin Game::Test...\n";

}

 

sub end : Test(shutdown) {

print "End Game::Test...\n";

}

 

sub test_reverse : Test(1) {

my $games_ref = Game::reversHash(\%players);

my %games = ( '拔河' => ['小C','小A'],

'嗒嗒球' => ['小C','小A','小B'],

'绑腿跑' => ['小B'] );

is_deeply($games_ref,\%games,"reverse_Hash test");

}

 

sub test_getPlayers : Test(2) {

my @players = Game::getPlayers(\%players,'绑腿跑');

is_deeply(['小B'],\@players,"getPlayers test: match set");

ok( Game::getPlayers(\%players,'篮球') eq "","getPlayers test: empty set");

}

 

1;

在这里,我们除了把整个文件package成Game::Test包以外,还去掉了runtests(),因为我们之后会把测试执行命令写到一个单独的脚本中去。并且修改setup、teardown为starup、shutdown。当然,由于Game.pm的reversHash函数的乱序问题,我们 也相应修改了test_reverese测试方法,以使得测试能成功。

2.新建一个Game::Sort::Test.pm模块,继承Game::Test类,为此我们在Game目录下新建Sort目录,并在Sort目录下编写Test.pm,代码如下:

package Game::Sort::Test;

 

use base 'Game::Test';

 

use Game::Sort;

use Test::More;

use strict;

use warnings;

 

my %players;

 

sub initial : Test(starup) {

%players = ( '小A' => ['拔河','嗒嗒球'],

'小B' => ['嗒嗒球','绑腿跑'],

'小C' => ['拔河','嗒嗒球'] );

print "Begin Game::Sort::Test...\n";

}

 

sub end : Test(shutdown) {

print "End Game::Sort::Test...\n";

}

 

sub test_reverse : Test(1) {

my $games_ref = Game::Sort::reversHash(\%players);

my %games = ( '拔河' => ['小A','小C'],

'嗒嗒球' => ['小A','小B','小C'],

'绑腿跑' => ['小B'] );

is_deeply($games_ref,\%games,"reverse_Hash test");

}

 

1;

在这里,我们重定义了test_reverse函数,以期对Game::Sort的reversHash函数进行测试。

3.将测试执行语句写入a.t

use Game::Sort::Test;

use Game::Test;

 

Test::Class->runtests();

这里的runtests()方法,会将父类和子类的测试case一并执行。

4.执行测试脚本

$ prove a.t

a.t .. ok

All tests successful.

Files=1, Tests=6, 0 wallclock secs ( 0.02 usr 0.00 sys + 0.05 cusr 0.00 csys = 0.07 CPU)

Result: PASS

因为我们不关心执行过程的细节,只关注测试结果报告,因此这里用prove,这是一个执行测试的命令,并且默认不显示测试细节(当然,你可以用-v参数打开测试细节)。关于prove的细节,你可以查看prove文档。

 

5.3总结

从上面的例子,我们可以看到用Test::Class来做单元测试的强大与便利,虽然后面"更进一步"的类继承的示例看上去稍复杂了一点,但实际情况是我 们用前面的test.pl就已经能满足绝大部分的单元测试需求了。Test::Class还可以设置SKIP和TODO标签,以跳过或临时跳过某个类的测 试,具体可以查看Test::Class文档。
在前面的例子中,我们也看到了与Test::Class一起使用的Test::More模块的方便,关于Test::More的其它方法,以及其它常用Test::Builder类模块,总结如下表:

Perl单元测试_测试_06



6参考文档

《Perl Testing程序高手秘笈》
《Mastering Perl》
《Perl Hacks》
perldoc Test::*
perldoc Devel::*

 

(全文完)