内容

名称

Test::Harness::Beyond - 超越 make test

超越 make test

Test::Harness 负责运行测试脚本,分析其输出并报告成功或失败。当我为模块键入 make test(或 ./Build test)时,通常使用 Test::Harness 来运行测试(并非所有模块都使用 Test::Harness,但大多数模块都使用)。

要开始探索 Test::Harness 的一些功能,我需要从 make test 切换到 prove 命令(随 Test::Harness 一起提供)。对于以下示例,我还需要安装最新版本的 Test::Harness;在我编写本文时,3.14 是最新的版本。

对于这些示例,我将假设我们正在使用“正常”的 Perl 模块发行版。具体来说,我假设键入 make./Build 会导致已构建的、准备安装的模块代码在 ./blib/lib 和 ./blib/arch 下可用,并且有一个名为“t”的目录包含我们的测试。Test::Harness 并没有硬编码到这种配置中,但这可以让我免于解释每个示例中哪些文件位于哪里。

回到prove;就像make test一样,它运行测试套件 - 但它提供了更多关于哪些测试被执行、以什么顺序以及如何报告其结果的控制。通常make test运行't'目录下所有的测试脚本。要使用prove做同样的事情,我输入

prove -rb t

这里的开关是 -r 递归进入't'目录下的任何目录,以及 -b 将 ./blib/lib 和 ./blib/arch 添加到 Perl 的包含路径中,以便测试可以找到它们将要测试的代码。如果我正在测试一个已经安装了早期版本的模块,我需要小心包含路径,以确保我没有针对已安装的版本运行我的测试,而不是我正在处理的新版本。

make test不同,输入prove不会自动重建我的模块。如果我在prove之前忘记make,我将针对这些文件的旧版本进行测试 - 这不可避免地会导致混乱。我习惯于输入

make && prove -rb t

或者 - 如果我没有需要构建的 XS 代码,我使用lib下面的模块

prove -Ilib -r t

到目前为止,我还没有向你展示make test无法做到的事情。让我们解决这个问题。

保存状态

如果我的测试套件中包含多个脚本,并且运行时间超过几秒钟,那么如果我的测试套件中包含多个脚本,并且运行时间超过几秒钟,那么在跟踪问题时,反复运行整个测试套件就会变得很繁琐。

我可以告诉prove只运行失败的测试,就像这样

prove -b t/this_fails.t t/so_does_this.t

这加快了速度,但我必须记下哪些测试失败,并确保我运行这些测试。相反,我可以使用prove的--state开关,让它帮我跟踪失败的测试。首先,我进行测试套件的完整运行,并告诉prove保存结果

prove -rb --state=save t

这将测试运行的可读摘要存储在一个名为'.prove'的文件中,该文件位于当前目录中。如果我遇到失败,我可以像这样只运行失败的脚本

prove -b --state=failed

我也可以告诉prove再次保存结果,以便它更新它对哪些测试失败的认识

prove -b --state=failed,save

一旦我的一个失败测试通过,它将从失败测试列表中删除。最终,我修复了所有问题,prove找不到任何失败的测试来运行

Files=0, Tests=0, 0 wallclock secs ( 0.00 usr + 0.00 sys = 0.00 CPU)
Result: NOTESTS

当我处理模块的特定部分时,最有可能的是,覆盖该代码的测试会失败。我想运行整个测试套件,但让它优先考虑这些“热门”测试。我可以告诉prove这样做

prove -rb --state=hot,save t

所有测试都将运行,但最近失败的测试将首先运行。如果自从我开始保存状态以来没有测试失败,所有测试将按其正常顺序运行。这将完整的测试覆盖率与早期故障通知相结合。

--state 开关支持多种选项;例如,要先运行失败的测试,然后按测试脚本的时间戳顺序运行所有剩余的测试 - 并保存结果 - 我可以使用

prove -rb --state=failed,new,save t

查看 prove 文档(输入 prove --man)以获取完整的 state 选项列表。

当我告诉 prove 保存 state 时,它会在当前目录中写入一个名为 '.prove'(在 Windows 上为 '_prove')的文件。它是一个 YAML 文档,因此编写自己的工具来处理保存的测试 state 非常容易 - 但格式没有正式记录,因此将来可能会在没有(太多)警告的情况下更改。

并行测试

如果我的测试运行时间过长,我可以通过并行运行多个测试脚本来加快速度。如果测试是 I/O 绑定的,或者我有多个 CPU 内核,这将特别有效。我告诉 prove 并行运行我的测试,如下所示

prove -rb -j 9 t

-j 开关启用并行测试;后面的数字是并行运行的测试的最大数量。有时,按顺序运行时通过的测试在并行运行时会失败。例如,如果两个不同的测试脚本使用同一个临时文件或尝试监听同一个套接字,我就会遇到问题,无法并行运行它们。如果我看到意外的失败,我需要检查我的测试,找出哪些测试在践踏同一个资源,并相应地重命名临时文件或添加锁。

为了获得最大的性能优势,我希望运行时间最长的测试脚本先开始 - 否则,在所有其他测试完成后,我将不得不等待一个运行时间接近一分钟的测试。我可以使用 --state 开关以从最慢到最快的顺序运行测试

prove -rb -j 9 --state=slow,save t

非 Perl 测试

Test Anything Protocol (http://testanything.org/) 不仅仅适用于 Perl。几乎任何语言都可以用来编写输出 TAP 的测试。有基于 TAP 的测试库用于 C、C++、PHP、Python 和许多其他语言。如果我找不到适合我选择的语言的 TAP 库,生成有效的 TAP 很容易。它看起来像这样

1..3 
ok 1 - init OK 
ok 2 - opened file 
not ok 3 - appended to file

第一行是计划 - 它指定了我要运行的测试数量,以便于检查测试脚本是否在运行所有预期测试之前退出。接下来的几行是测试结果 - 'ok' 表示通过,'not ok' 表示失败。每个测试都有一个编号,以及可选的描述。就是这样。任何能够在 STDOUT 上生成类似输出的语言都可以用来编写测试。

最近,我重新燃起了对 Forth 的兴趣,这已经持续了二十年。显然,我有一种受虐倾向,即使是 Perl 也无法满足。我想用 Forth 编写测试,并使用 prove 运行它们(你可以在 https://svn.hexten.net/andy/Forth/Testing/ 找到我的 gforth TAP 实验)。我可以使用 --exec 开关告诉 prove 使用 gforth 运行测试,如下所示

prove -r --exec gforth t

或者,如果我用来编写测试的语言允许使用shebang行,我可以使用它来指定解释器。以下是用PHP编写的测试。

#!/usr/bin/php 
<?php
  print "1..2\n"; 
  print "ok 1\n"; 
  print "not ok 2\n";
?>

如果我将它保存为t/phptest.t,shebang行将确保它与我的所有其他测试一起正确运行。

混合使用

测试程序之间微妙的相互依赖关系可能会掩盖问题 - 例如,较早的测试可能忽略了删除影响后续测试行为的临时文件。为了找到这种问题,我使用--shuffle和--reverse选项以随机或反向顺序运行我的测试。

自己动手

如果我需要prove没有提供的功能,我可以轻松地编写自己的功能。

通常,您需要更改TAP如何输入到解析器中以及如何从解析器输出App::Prove 支持任意插件,而 TAP::Harness 支持自定义格式化程序源处理程序,您可以使用 proveModule::Build 加载它们;有很多例子可以作为我的基础。有关更多详细信息,请参阅 App::ProveTAP::Parser::SourceHandlerTAP::Formatter::Base

如果编写插件还不够,您可以编写自己的测试框架;Test::Harness 3.00 重写的动机之一是使其更容易子类化和扩展。

Test::Harness 模块是 TAP::Harness 的兼容性包装器。对于新应用程序,我应该直接使用 TAP::Harness。正如我们将看到的,prove 使用 TAP::Harness。

当我运行 prove 时,它会处理其参数,找出要运行的哪些测试脚本,然后将控制权传递给 TAP::Harness 以运行测试、解析、分析和呈现结果。通过子类化 TAP::Harness,我可以自定义测试运行的许多方面。

我想将我的测试结果记录到数据库中,以便我可以跟踪它们随时间的变化。为此,我覆盖了 TAP::Harness 中的 summary 方法。我从一个简单的原型开始,它将结果作为 YAML 文档转储

package My::TAP::Harness;

use base 'TAP::Harness';
use YAML;

sub summary {
  my ( $self, $aggregate ) = @_; 
  print Dump( $aggregate );
  $self->SUPER::summary( $aggregate );
}

1;

我需要告诉 prove 使用我的 My::TAP::Harness。如果 My::TAP::Harness 位于 Perl 的 @INC 包含路径中,我可以

prove --harness=My::TAP::Harness -rb t

如果我没有在 @INC 上安装 My::TAP::Harness,则需要在运行 prove 时提供正确的 perl 路径

perl -Ilib `which prove` --harness=My::TAP::Harness -rb t

我可以将这些选项合并到我自己的 prove 版本中。这很简单。prove 的大部分工作由 App::Prove 处理。prove 中重要的代码只是

use App::Prove;

my $app = App::Prove->new; 
$app->process_args(@ARGV); 
exit( $app->run ? 0 : 1 );

如果我编写 App::Prove 的子类,我可以自定义测试运行器的任何方面,同时继承所有 prove 的行为。这是 myprove

#!/usr/bin/env perl use lib qw( lib );      # Add ./lib to @INC
use App::Prove;

my $app = App::Prove->new;

# Use custom TAP::Harness subclass
$app->harness( 'My::TAP::Harness' );

$app->process_args( @ARGV ); exit( $app->run ? 0 : 1 );

现在我可以像这样运行我的测试

./myprove -rb t

更深入的自定义

现在我知道如何子类化和替换 TAP::Harness,我可以替换测试运行器的任何其他部分。为此,我需要知道哪些类负责哪些功能。以下是一个简短的导览;每个组件的默认类显示在括号中。通常,我编写的任何替换都将是这些默认类的子类。

当我运行测试时,TAP::Harness 会创建一个调度器 (TAP::Parser::Scheduler) 来确定测试的运行顺序,一个聚合器 (TAP::Parser::Aggregator) 来收集和分析测试结果,以及一个格式化程序 (TAP::Formatter::Console) 来显示这些结果。

如果我并行运行测试,还可能存在一个多路复用器 (TAP::Parser::Multiplexer) - 允许多个测试同时运行的组件。

创建完这些助手后,TAP::Harness 开始运行测试。对于每个测试,它都会创建一个新的解析器 (TAP::Parser),该解析器负责运行测试脚本并解析其输出。

要替换任何这些组件,我需要使用替换类的名称调用其中一个测试运行器方法

aggregator_class 
formatter_class 
multiplexer_class 
parser_class
scheduler_class

例如,要替换聚合器,我将

$harness->aggregator_class( 'My::Aggregator' );

或者,我可以将我的替代类的名称提供给 TAP::Harness 构造函数

my $harness = TAP::Harness->new(
  { aggregator_class => 'My::Aggregator' }
);

如果我需要更深入地了解测试运行器的内部机制,我可以替换 TAP::Parser 用于执行测试脚本和标记其输出的类。在运行测试脚本之前,TAP::Parser 会创建一个语法 (TAP::Parser::Grammar) 来将原始 TAP 解码为标记,一个结果工厂 (TAP::Parser::ResultFactory) 将解码的 TAP 结果转换为对象,以及一个源或迭代器 (TAP::Parser::IteratorFactory),具体取决于它是在运行测试脚本还是从文件、标量或数组中读取 TAP。

可以通过调用以下解析器方法中的一个来替换每个对象

source_class
perl_source_class 
grammar_class 
iterator_factory_class
result_factory_class

回调

作为子类化需要更改的组件的替代方法,我可以将回调附加到默认类。TAP::Harness 公开了以下回调

parser_args      Tweak the parameters used to create the parser 
made_parser      Just made a new parser 
before_runtests  About to run tests 
after_runtests   Have run all tests 
after_test       Have run an individual test script

TAP::Parser 也支持回调;bailout、comment、plan、test、unknown、version 和 yaml 分别针对相应的 TAP 结果类型调用,ALL 针对所有结果调用,ELSE 针对所有未安装命名回调的结果调用,EOF 在每个 TAP 流结束时调用一次。

要安装回调,我将回调的名称和子例程引用传递给 TAP::Harness 或 TAP::Parser 的回调方法

$harness->callback( after_test => sub {
  my ( $script, $desc, $parser ) = @_;
} );

我也可以将回调传递给构造函数

  my $harness = TAP::Harness->new({
    callbacks => {
	    after_test => sub {
        my ( $script, $desc, $parser ) = @_; 
        # Do something interesting here
	    }
    }
  });

当需要改变测试工具的行为时,有多种方法可以选择。哪种方法最适合取决于我的需求。一般来说,如果我只想观察测试执行过程,而不需要改变测试工具的行为(例如将测试结果记录到数据库),我会选择回调。如果我想让测试工具表现得不同,子类化会给我更多控制权。

解析 TAP

也许我并不需要一个完整的测试工具。如果我已经有一个需要解析的 TAP 测试日志,我只需要 TAP::Parser 和它所依赖的各种类。以下是运行测试并解析其 TAP 输出所需的代码

use TAP::Parser;

my $parser = TAP::Parser->new( { source => 't/simple.t' } );
while ( my $result = $parser->next ) {
  print $result->as_string, "\n";
}

或者,我可以将一个打开的文件句柄作为源传递,让解析器从该句柄中读取,而不是尝试运行测试脚本

open my $tap, '<', 'tests.tap' 
  or die "Can't read TAP transcript ($!)\n"; 
my $parser = TAP::Parser->new( { source => $tap } );
while ( my $result = $parser->next ) {
  print $result->as_string, "\n";
}

这种方法在需要将基于 TAP 的测试结果转换为其他表示形式时非常有用。请参阅 TAP::Convert::TET (http://search.cpan.org/dist/TAP-Convert-TET/) 以了解此方法的示例。

获取支持

Test::Harness 开发人员在 tapx-dev 邮件列表[1] 上交流。对于一般性、语言无关的 TAP 问题,可以使用 tap-l[2] 列表进行讨论。最后,还有一个专门用于 Test Anything Protocol 的维基[3]。欢迎对维基进行贡献、提交补丁和建议。

[1] http://www.hexten.net/mailman/listinfo/tapx-dev [2] http://testanything.org/mailman/listinfo/tap-l [3] http://testanything.org/