一个 Test::MockObject 举例说明

人们喜欢找借口来避免为他们的代码编写测试。最常见的借口之一是:“测试这个不可行,因为它依赖于外部对象”——CGI代码、使用Apache请求对象、TCP/IP服务器等等。

Test::MockObject 模块使隔离使用此类对象的代码变得更加容易。例如,如果您的代码使用CGI对象,那么您可以调整查询字符串和伪造STDIN,试图说服CGI.pm产生可测试的值。使用Test::MockObject创建一个看起来和表现像CGI对象的对象——但完全受您控制,这要容易得多。

这在大型软件项目中很有用,其中对象封装了大量的隐藏行为。如果您的应用程序遵循良好的设计原则,通过明确定义的接口隐藏复杂性,那么您可以用其接口等效物替换几乎任何组件。(当然,内部是自由的。这就是为什么这是可能的原因。)通常,只需要接受某些参数并返回特定的值就足够了。

使用模拟对象,您可以测试相关代码是否正确使用了特定的接口。虽然可以手动完成此操作,但Test::MockObject具有几个实用方法来添加伪造方法和验证调用。它与Test::Builder集成,因此与Test::SimpleTest::More及其关联工具一起正常工作。

您需要了解的背景知识

我假设您已经熟悉Test::More。也许您已经阅读了随Test::Simple分发一起提供的Test::Tutorial。您也可能已经阅读了我对这一主题的早期介绍。[我的早期介绍](/pub/2001/12/04/testing.html)。如果没有,那么您可能希望这样做。(我的室友没有按照顺序尝试,结果头部受伤。如果您表现得更好,那么Perl QA小组对您的自然才能感兴趣!)

我的示例来自Everything Engine的单元测试。我选择它的两个原因是:首先,这是一个我非常关心和喜爱的项目;其次,它需要更多的用户、测试人员和开发者。更重要的是,这是我想出导致Test::MockObject的想法的地方。

在Engine上的同事Darrick Brown发明了一种他称之为表单对象(Form Objects)的巧妙技术。这些用于将菜单选择绑定到节点上。(在Everything中,一切都是节点。它是数据和行为的基本单元。)表单对象控制HTML小部件的创建、验证提交的数据,并最终更新节点。它们都强烈继承了Everything::FormObject,并操作节点对象,因此它们是模拟对象的理想候选者。

模拟对象

本文重点介绍使用模拟对象的白盒单元测试。“白盒”测试意味着您被允许并鼓励查看正在测试的事物内部。在Perl中,这是可怕的可能。相比之下,“黑盒”测试发生在您无法了解内部细节的情况下:您只知道允许的输入和预期的输出。(如果您不知道这一点,那么您就无法进行很多测试。)

单元测试当然是在尽可能隔离的情况下测试程序的单个组件。这与集成测试不同,集成测试是作为整体对程序进行测试,接受测试是探索程序期望的最终用户行为。没有一种测试类型比其他类型更好或更差。如果正确执行,它们是互补的:单元测试可以探索接受测试难以证明的内部行为;集成测试演示了不同组件之间的互操作性,这是单元测试通常无法保证的。

模拟对象的目的在于将正在测试的单元与其依赖项隔离开来,并让测试人员对测试环境有更全面的控制。这符合标准编程原则:如果你可以伪造单元所依赖的接口,那么你可以控制并监控其行为。

Perl使得这一点变得尤其简单。

示例

我正在为Everything::HTML::FormObject::AuthorMenu编写测试。这个类表示一个HTML选择框,用于设置节点的作者。它有两个方法。genObject()生成选择小部件所需的HTML。当引擎构建用于向用户显示的页面时,会调用它。另一个方法cgiVerify()在接收到用户提交的数据时被调用。它检查请求的作者是否存在并且是否有权写入节点。

内部查看

模块开始得相当简单

        use strict;
        use Everything;
        use Everything::HTML;
        use Everything::HTML::FormObject;

        use vars qw(@ISA);
        @ISA = ("Everything::HTML::FormObject");

测试这个非常容易。我想确保模块继续加载所有这些模块,所以我需要一些方法来捕捉它们的用法。(不要笑——我之前忘记加载重要的模块,导致出现了一些非常棘手的错误。更精确一点比较好。现在你可以笑了。)因为use在幕后调用import(),如果我在AuthorMenu编译之前安装自己的import(),那么我可以测试这些模块是否实际被使用。作为副作用,这样做可以防止这些其他类加载,使得模拟它们变得更容易。测试开始

        package Everything::HTML::FormObject::AuthorMenu;
        use vars qw( $DB );

        package main;

        use Test::More 'no_plan';

因为我正在伪造其他包,所以真正的模块通常导入的内容将不会导入。此时真正重要的是来自Everything包的$DB对象。(我有点作弊。我知道我会用到它,我知道它的定义和用法。在此阶段,我可能应该说,“除非我在这里伪造它,否则模块将无法编译,”然后就此结束。)

因为我还没有准备好实现$DB,所以我只是切换到要测试的包,并将其声明为全局变量。当包被编译时,它已经存在了,所以它会成功编译。然后我回到主包,以免不小心破坏重要内容,并使用测试模块。

        my @imports;
        for ( 'Everything', 'Everything::HTML',
                'Everything::HTML::FormObject') {
                no strict 'refs';
                *{ $_ . '::import' } = sub {
                        push @imports, $_[0];
                };
                (my $modpath = $_) =~ s!::!/!g;
                $INC{ $modpath . '.pm' } = 1;
        }

这开始变得有些棘手。因为我想确保这三个模块被正确加载,我必须让Perl认为它们已经加载。%INC来了救星。当你成功地使用use()require()一个模块时,Perl会在%INC中添加一个条目,其中包含模块路径名,相对于@INC中的一个目录。这样,如果你再次使用或需要这个模块,Perl就会知道它已经被加载。

如前所述,我检查模块是否加载的首选方式是捕捉所有对import()的调用。这就是我安装假import子例程的原因。它们简单地保存它们被识别的包名称。测试编写起来很简单

        use_ok( 'Everything::HTML::FormObject::AuthorMenu' );

        is( $imports[0], 'Everything', 'Module should use 
            Everything' );
        is( $imports[1], 'Everything::HTML',
                'Module should use Everything::HTML' );
        is( $imports[2], 'Everything::HTML::FormObject',
                'Module should use 
                     Everything::HTML::FormObject' );

因为use_ok()在运行时触发,所以没有必要在这个部分周围使用BEGIN块。(如果你对此感兴趣,看看perlfuncuse()及其等效部分的说明。)

这可以工作,但它有点混乱,并使用了一些可能会吓到(至少会混淆)普通Perl黑客的技巧。Test::*模块的一个目标就是消除你通常必须使用的“邪恶的黑魔法”。所以,现在我将向您展示一个更好的方法。

使最后一部分更容易

因为我发现自己太频繁地(至少两次)编写那段代码,所以我把它添加到了Test::MockObject中。使用该模块,相应的循环现在是

        my @imports;
        for ( 'Everything', 'Everything::HTML',
                'Everything::HTML::FormObject') {
                Test::MockObject->fake_module(
                        $_,
                        import => sub { push @imports,
                              $_[0] }
                );
        }

幕后,模块确实做了循环所做的一切。好的一点是,你不必记住如何模拟模块的加载或如何测试 import()。这已经完成,并且被很好地封装在模块中。(我在写这一部分时无意中强调了这个观点。结果发现,Test::MockObject 版本 0.04 使用了 %ENV 而不是 %INC。我经常犯这个错误。这次,它在模块和测试中都出现了。至少使用版本 0.08,因为这篇文章导致了一些错误修复和新便利性。 :)

这并不是 Test::MockObject 最重要的功能,这只是个便利的补充。在某个时刻,它可能应该被分离成 Test::MockPackageTest::MockModule。(想贡献吗?)

测试实际方法

一旦模块加载并准备就绪,我喜欢按它们出现的顺序测试我的方法。这有助于使测试套件和模块保持一定的同步。第一个方法是 genObject()。它相当简单

        my $this = shift @_;
        my ($query, $bindNode, $field, $name, $default) =
                getParamArray(
                "query, bindNode, field, name, 
                     default", @_);

        $name ||= $field;

        my $html = $this->SUPER::genObject(
                $query, $bindNode, $field, $name) . 
                     "\n";

        if(ref $bindNode)
        {
                my $author = $DB->getNode($$bindNode{$field});
                if($author && $author->isOfType('user'))
                {
                        $default ||= $$author{title};
                }
                elsif($author)
                {
                        $default ||= "";
                }
        }

        $html .= $query->textfield(-name => $name, 
                -default => $default,
                -size => 15, -maxlength => 255,
                -override => 1) . "\n";

        return $html;

我可以看到几个需要测试的地方。首先,我要确保以正确的参数调用 getParamArray()。(这个函数使得可以通过位置或以 name => value 对的方式传递参数,类似于 Sub::NamedParams。)接下来,我将测试 SUPER::genObject() 是否被正确调用,并带有正确的值。(由于引擎处理节点继承的方式,这个调用看起来比实际更奇怪。为了乐趣,请阅读 Everything::Node::AUTOLOAD(),或者去打保龄球。)之后,是条件语句。我需要至少调用该函数三次,以有效地测试各个分支。函数以我想要测试的 textfield() 调用结束,并有一个返回值,我可以检查一些其他事情。它没有看起来那么复杂。

测试的一个副作用是,你会开始写越来越小的函数。如果你在编写代码之前先编写测试来通过它们,这一点尤为明显。除了更容易阅读和调试外,这通常会产生更灵活且功能更强大的代码。

在确定了一些要测试的东西之后,我接下来编写测试名称。这些是测试意图的简短描述。面对现有的代码,我通常会试图弄清楚哪些类型的事情可能会出错,以及真正重要的行为是什么。有经验后,你可以直观地看一段写得好的代码并弄清楚。不过,当你刚开始时,你可以更具体一些。

        # genObject() should call getParamArray()
        # ... passing a string of desired parameters
        # ... and its arguments, minus the object
        # ... should call SUPER::genObject()
        # ... passing the important parameters
        # ... and should call textfield()
        # ... with the important parameters
        # ... returning its and its parents results
        # ... if node is bound, should call getNode()
        # ... on bound node field
        # ... checking for an author node
        # ... and setting the default to the author name
        # ... or nothing, if it is not an author node

这已经是一个相当好的草稿。过一遍这个列表,你会发现至少需要再次检查代码两次。正确排序需要一点工作,但一旦你知道如何正确设置模拟条件,它就真的又快又简单。我发现处理这个问题的最好方法就是直接跳进去。

        can_ok( 'Everything::HTML::FormObject::AuthorMenu', 
            'genObject' );

首先,我想确保这个方法存在。为什么?这是“做最简单的事情”原则的一部分。每当我添加一个方法时,我首先检查它是否存在。这听起来太愚蠢而没有任何用途,但这是一个可能出错的事情。首先,我偶尔会拼错方法名。这将立即捕捉到这一点。其次,它给了我一个起点,一个只需做很少的工作就能通过的测试。这是一个很好的心理提升,让我继续下一个测试。我一直在用现有的代码编写测试时保持这个习惯。

接下来,我想测试对getParamArray()的调用。由于它不是一个方法,我无法使用模拟对象。我必须从侧面进行模拟。虽然这个函数来自Everything.pm,但通常会被导出到这个包中。我将使用之前提到的import()模拟器的变体。

        my ($gpa, @gpa);
        $mock->fake_module( $package,
            getParamArray => sub { push @gpa, \@_; return $gpa }
        );

我可以计算@gpa的元素个数,以判断它是否被调用,并从数组中提取参数。$gpa允许我控制输出。测试本身很容易编写。

        my $result = genObject();
        is( @gpa, 1, 'genObject() should call getParamArray' );

好的,写起来比应该要容易。如果你注意到了,你可能想知道为什么genObject()的调用可以工作,因为我还在主包中,而这个方法在类中。我刚刚添加了一个变量,包含测试包的名称,以及一个AUTOLOAD()函数。我已经厌倦了输入这个长长的包名。

        # near the start
        use vars qw( $AUTOLOAD );
        my $package = 'Everything::HTML::FormObject::AuthorMenu';

        ...

        # way down at the end
        sub AUTOLOAD {
                my ($subname) = $AUTOLOAD =~ /([^:]+)$/;
                if (my $sub = UNIVERSAL::can( $package,
                     $subname )) {
                        $sub->( @_ );
                } else {
                        warn "Cannot call <$subname> 
                             in ($package)\n";
                }
        }

在所有这些基础设施都到位后,发现测试失败了,这有些令人失望。由于我以函数的形式调用该方法,没有对象可以调用SUPER::genObject()。这很容易解决。还记得之前创建的模拟对象$mock吗?这是Perl的一个魔法,虽然让一些OO纯理论者感到疯狂,但它使得测试变得非常容易。_方法调用是一个具有特殊第一个参数的函数调用_。如果$thisgenObject()内部可以做任何Everything::HTML::FormObject::AuthorMenu对象能做的事情,那么它就会正常工作。万岁,多态性万岁!

为了让SUPER::genObject()的调用通过,这个调用也必须被模拟。该方法解析为Everything::HTML::FormObject::genObject(),所以我将添加一个适当名称和包的函数。(这段测试代码看起来越来越熟悉了。再次提到Test::MockModule吗?)

        my @go;
        $mock->fake_module( 'Everything::HTML::FormObject',
            genObject => sub { push @go, \@_; 
                 return 'some html' }
        );

现在我将修改genObject()的调用,传入我的模拟对象。

        my $result = genObject( $mock );

在失败之前,我取得了一些进展。由于没有传入任何参数给$query变量来保存,所以textfield()调用失败了。现在我终于可以用我的模拟对象发挥作用了。首先,我将使用$package再次修改getParamArray()返回的值,以节省输入。

        my (%gpa, @gpa);
        $mock->fake_module( $package,
            getParamArray => sub { push @gpa, \@_; return 
            @gpa{qw( q bn f n d )}}
        );

由于AuthorMenu期望按顺序接收其参数,我将创建一个哈希表,在其中我可以存储它们。我可能会使用更具描述性的键名,但现在它们看起来是合适的。接下来,我将确保'q'返回可控制的内容。在这种情况下,这意味着它支持textfield()方法并返回合理的内容。

        $mock->set_always( 'textfield', 'more html' );
        $gpa{q} = $mock;

我可以为这个创建一个新的模拟对象,但由于还没有发生名称冲突,这不是一个优先事项。你是否这样做是个人风格的问题。

现在,genObject()没有失败,所有的测试都通过了。太好了。接下来,我要测试getParamArray()的第一个参数是否正确。

        is( $gpa[0][0], 'query, bindNode, field, name, default',
            '... requesting the appropriate arguments' );

它是正确的,所以我将确保它传递了除对象本身之外的所有方法参数。

        like( join(' ', @{ $gpa[0] }), qr/1 2 3$/,
                '... with the method arguments' );
        unlike( join(' ', @{ $gpa[0] }), qr/$mock/,
                '... but not the object itself' );

只有第一个失败了,这是因为我没有向方法调用传递任何其他参数。我将修改它。

        my $result = genObject( $mock, 1, 2, 3 );

这给了我九个通过的测试。我还在相当紧密地遵循测试名称。(在编写这些和实际编写代码之间,过了几天。它们的相似性让我认为我走上了正确的道路。)

由于代码的下一部分试图加载一个已绑定节点,而我没有传入一个,我将测试以确保getNode()没有被调用。由于调用是在$DB对象上,我将将其设置为模拟对象。我还将使用called()方法来确保没有发生任何事情。为了发生这种情况,我需要模拟getNode()。实现所有这些的代码相当简单。(注意,它必须放在各个地方。)

        $Everything::HTML::FormObject::AuthorMenu::DB = $mock;
        $mock->set_series( 'getNode', 0, 1, 2 );

        # genObject() calls skipped in this example...

        ok( ! $mock->called( 'getNode' ),
                '... should not fetch bound node without one' );

有两点需要更多解释。首先,由于我不知道 getNode() 应该返回什么,我将给它一个示例序列。(我相当确定我会使用 set_series(),因为我之前已经做过类似的测试。我无法用太多的话来解释,因为这更多的是一种直观经验。)其次,我取反了 called() 的返回值,以便它与 ok() 保持一致。有时这可能会有一点困难看清。

第一次遍历的最后几个测试都围绕 textfield() 调用展开。我已经模拟了这个调用,现在我将看到它是否使用了正确的参数。

        my ($method, $args) = $mock->next_call();
        shift @$args;
        my %args = @$args;

        is( $method, 'textfield', '... and should create 
             a text field' );
        is( join(' ', sort keys %args ),
        join(' ', sort qw( -name -default -size 
             -maxlength -override )), '... passing the 
             essential arguments' );

这里有几个值得注意的地方。next_call() 方法按顺序迭代模拟方法的调用栈。它不会跟踪每个调用的方法,只会跟踪你通过 Test::MockObject 的辅助方法添加的方法。next_call() 在标量上下文中返回方法名,或在列表上下文中返回一个包含参数的匿名数组的名称。

由于我想检查传递给 textfield() 的参数,我在列表上下文中调用它。因为参数作为键 => 值对传递,所以最自然的比较方式似乎是将它们作为一个散列。我经常使用 join-sort 习语,因为我从未完全适应 Test::More 的列表比较函数。如果使用这些函数,这个测试可能会更简单。

我明确地对两个数组进行排序,只是为了避免硬编码的列表顺序导致不必要的测试失败。(这在我编写应该能在 EBCDIC 机器上工作的代码时给我带来过麻烦,而不仅仅是 ASCII 机器。当然,如果你在大型机上将 Everything 运行起来,那么这可能不是你最关心的问题。)

最后,我测试返回的数据是否正确创建。

        is( $result, "some html\nmore html\n",
            '... returning the parent object plus the 
             new textfield html' );

到目前为止,所有的 13 个测试都成功了。在这个时候,我开始第二次遍历该方法,但注意到我还没有测试 $name 是否会从 $field 获取默认值。在第一次遍历之前,我将向 %gpa 添加 'field'。

        $gpa{f} = 'field';

这个测试应该放在这次遍历的最终测试之前,所以我把它也添加到那里。这完成了第一次遍历。

        is( $args{-name}, 'field',
                '... and widget name should default 
                to field name' );

对于第二次遍历,我将测试当我提供一个要绑定表单对象的节点时会发生什么。在一个未模拟的 Everything 中,这个节点作为函数的参数传递。在测试中,我 必须 从模拟的 getParamArray() 中返回它们,所以我将从那里开始。我还将模拟对象中的 'field' 值设置为稍后要检查的哨兵值。由于 $field 的值将是 'field',这很顺利。(在这些名字上有很大的创新空间,特别是如果你试图在你的软件中偷偷摸摸地加入一个朋友的名字。)

        $gpa{bn} = $mock;
        $mock->{field} = 'bound node';

由于 getNode() 上设置了序列并且之前没有调用过,它将返回 0。这意味着不会在作者对象上调用 isOfType(),并且默认选择不会从其未定义值修改。这些测试相对容易。

        genObject( $mock );

        ($method, $args) = $mock->next_call();
        isnt( $method, 'isOfType',
        '... not checking bound node type if it is not found' );

        shift @$args;
        %args = @$args;
        is( $args{-default}, undef, '... and not modifying 
             default selection' );

和之前一样,next_call() 很有用。既然我已经知道 textarea() 将是第一个(也是这次遍历的最后一个)调用的方法,我可以确保没有调用 isOfType()

接下来有两个测试。一个确保代码检查节点的类型。另一个确保如果类型不正确,默认值变成一个空字符串。为了使这个工作,我必须修改现有的 getNode() 序列以返回 $mock 的两个实例。

        $mock->{title} = 'bound title';
        $mock->set_series( 'isOfType', 0, 1 );

        genObject( $mock );

        ($method, $args) = $mock->next_call( 2 );
        is( $method, 'isOfType', '... if bound node 
            is found, should check type' );
        is( $args->[1], 'user', '... (the user type)' );

        ($method, $args) = $mock->next_call();
        shift @$args;
        %args = @$args;
        is( $args{-default}, '',
            '... setting default to blank string 
                 if it is not a user' );

这里的新想法是向 next_call() 传递一个参数。我知道 getNode() 是第一个模拟的方法,所以我可以安全地跳过它。所有这些测试都通过了。最终的可测试条件是绑定的节点存在并且是一个 'user' 类型节点。最后测试块中的 set_series() 调用使 isOfType() 在这次遍历中返回 true。

        genObject( $mock );
        ($method, $args) = $mock->next_call( 3 );
        shift @$args;
        %args = @$args;
        is( $args{-default}, 'bound title', '... but using 
             node title if it is' );

我现在有22次成功的测试。我的原始测试名称计划有14个测试,但随着我看到更多的逻辑分支,这个数字通常会增加。我可以添加更多测试,确保默认值不会被覆盖,并且textfield()的必要(硬编码)属性已设置,但我对当前的测试相当自信。如果有什么东西坏了,那么我在修复之前会添加一个测试来捕获这个错误,但剩下的内容足够简单;我怀疑它不会出错。(写下这一点是一种以后不得不食言的好方法。)

测试另一种方法(一个不太详细的例子)

对于这个表单对象,还有一个方法:cgiVerify()。当引擎处理提交的表单对象输入时,它必须重新构建对象。然后,它将输入与小部件的允许值进行对比。这个方法就是这样做的。它的代码稍微长一点,内容如下

        my ($this, $query, $name, $USER) = @_;

        my $bindNode = $this->getBindNode($query, $name);
        my $author = $query->param($name);
        my $result = {};

        if($author)
        {
                my $AUTHOR = $DB->getNode($author, 'user');

                if($AUTHOR)
                {
                        # We have an author!!  Set the CGI param 
                        # so that the
                        # inherited cgiUpdate() will just do 
                        # what it needs to!
                        $query->param($name, $$AUTHOR{node_id});
                }
                else
                {
                        $$result{failed} = "User '$author' 
                             does not exist!";
                }
        }

        if($bindNode)
        {
                $$result{node} = $bindNode->getId();
                $$result{failed} = "You do not have permission"
                        unless($bindNode->hasAccess($USER, 'w'));
        }

        return $result;

而不是描述测试的编写(以及我在此过程中的步骤和失误),我只想对测试本身进行评论。

        my $qmock = Test::MockObject->new();
        $mock->set_series( 'getBindNode', 0, 0, 0, 
             $mock, $mock );
        $qmock->set_series( 'param', 0, 'author', 'author' );

由于这种方法处理事情的方式,创建另一个模拟对象作为$query传递进来更为清晰。我还设置了用于几个通过此方法的两个主要系列。在编写测试时,我一直在向这些系列添加新值。这是最后的残留内容。这种方法比在每次通过之前设置每个模拟对象更有意义,但这只是风格问题,两种方式都可以工作。

        $result = cgiVerify( $mock, $qmock, 'boundname' );

        ($method, $args) = $mock->next_call();
        is( $method, 'getBindNode', 'cgiVerify() should get 
             bound node' );
        is( join(' ', @$args), "$mock $qmock boundname",
                '... with query object and query 
                     parameter name' );

我使用单独的模拟对象的原因是区分在getBindNode()调用中的参数。

        ($method, $args) = $qmock->next_call();
        is( $method, 'param', '... fetching parameter' );
        is( $args->[1], 'boundname', '... by name' );

        isa_ok( $result, 'HASH', '... and should return a data 
             structure which' );

这里奇怪的测试名称是我的一个小习惯。 isa_ok()在其测试名称的末尾添加‘isa (引用类型)’,我希望在显示时尽可能清楚。

        $mock->set_series( 'getNode', 0, { node_id => 
             'node_id' } );
        $result = cgiVerify( $mock, $qmock, 'boundname' );

        ($method, $args) = $mock->next_call( 2 );
        is( $method, 'getNode', '... fetching the node, if an 
             author is found' );
        is( join(' ', @$args), "$mock author user",
                '... with the author, for the user type' );

        is( $result->{failed}, "User 'author' does not exist!",
                '... setting a failure message on failure' );

我喜欢将参数连接成一个字符串并对其进行is()like()调用的方法。 like()的好处是可以忽略作为第一个参数传递的$self,因为它是模拟对象。我这里使用is()来使期望更加明确。

        $qmock->clear();
        $result = cgiVerify( $mock, $qmock, 'boundname' );
        ($method, $args) = $qmock->next_call( 2 );
        is( $method, 'param', '... setting parameters, 
             on success' );
        is( join(' ', @$args), "$qmock boundname node_id",
             '... with the name and node id' );
        is( $result->{failed}, undef, 
             '... and no failure message' );

这段代码给我带来了麻烦,直到我添加了clear()调用。值得记住的是,模拟对象的模拟调用堆栈在通过过程中会持续存在。我忘记了这一点,并使用第一个而不是第二个param()调用。哦,不。

另一个值得注意的事情是我将‘undef’作为期望结果传递给is()。方便的是,Test::More会静默地完成正确的事情。

        $mock->set_always( 'getId', 'id' );
        $mock->set_series( 'hasAccess', 0, 1 );
        $result = cgiVerify( $mock, $qmock, 
             'boundname', 'user' );

这里我练习了方法的最后一个子句。这些测试比要测试的代码更复杂!有时事情就是这样发展的。

        $mock->called_pos_ok( -2 , 'getId',
                '... should get bound node id, if it exists' );
        is( $result->{node}, 'id',
                '... setting it in the resulting node field' );

        $mock->called_pos_ok( -1, 'hasAccess', 
             '... checking node access ');
        is( $mock->call_args_string( -1, ' ' ), 
             "$mock user w", 
             '... for user with write permission' );

我已经从next_call()方法转向使用较旧的Test::MockObject行为,即使用调用堆栈中的位置。我仍然不太满意这些方法的名称,但负数下标很有用。(也许我需要添加prev_call())。我只需要记住hasAccess()是最后一个被调用的,而getId()应该在倒数第二个方法中被调用。

这里的新方法是call_args_string(),它只是将指定调用位置上的参数连接起来。它节省了一些打字,但大多数都被长方法名抵消了。

        is( $result->{failed}, 'You do not have permission',
                '... setting a failure message if user lacks 
                 write permission' );

        $result = cgiVerify( $mock, $qmock, 'boundname', 'user' );
        is( $result->{failed}, undef, '... and none if the user 
             has it' );

最后两个测试演示了在一系列漫长的测试结束时,可用的选项是如何逐渐减少的。在最后的几次通过中,我通常一次只测试一件事情。对我来说,这似乎在数学上很诗意,就像我正在用牛顿法进行精炼。

结论

这次整个练习产生了39个测试。我的下一步是使用这些信息更新测试计划。

        # way back at the top
        use Test::More tests => 39;

这样更容易看出测试是否过多或过少。我通过这种方式也得到了关于失败和成功的更好结果。结果证明,编写 cgiVerify() 的测试大约花了20分钟,包括一些与洗衣相关的干扰。对于已经好几个月没有阅读的代码,17个测试大约每分钟一个,当你知道自己在做什么时,这个时间看起来是合理的。

值得注意的是这个模块的功能,这让我考虑使用模拟对象。主要原因是获取和构建节点的过程足够复杂,我并不想只为了测试一个作者是否真的是作者,就连接一个假数据库。如果代码只是进行了一些简单的数学或文本操作,我可能会使用黑盒测试。依赖于数据库抽象层(如Everything所做的那样)的代码通常会使我转向使用 Test::MockObject

有关模块可以做什么的更多信息,请参阅其文档。当前稳定版本是0.08,尽管到这篇文章稳定发布时,0.09版本可能已经发布。

chromatic 是《Modern Perl》的作者。在业余时间,他一直在帮助新手理解股票和投资

标签

反馈

这篇文章有什么问题吗?请在GitHub上打开一个问题或拉取请求,帮助我们。