Perl 单元测试——子程序是可测试的吗?

Perl Unit Testing -- is the subroutine testable?

我一直在阅读和探索 Perl 中单元测试和测试驱动开发的概念。我正在研究如何将测试概念整合到我的开发中。假设我这里有一个 Perl 子程序:

sub perforce_filelist {

    my ($date) = @_;

    my $path = "//depot/project/design/...module.sv";
    my $p4cmd = "p4 files -e $path\@$date,\@now";

    my @filelist = `$p4cmd`; 

    if (@filelist) {
        chomp @filelist;
        return @filelist;
    }
    else {
        print "No new files!"
        exit 1;
    }
}

子例程执行 Perforce 命令并将该命令的输出(文件列表)存储到 @filelist 数组中。这个子程序是可测试的吗?测试返回的 @filelist 是否为空有用吗?我正在尝试自学如何像单元测试开发人员一样思考。

你问:

Is this subroutine testable?

是的,绝对是。然而一个问题马上就来了;你在做开发驱动测试还是测试驱动开发?让我来说明一下区别。

你现在的情况是你写了一个比测试更早的方法,应该驱动这个功能的开发

如果您试图遵循 TDD 的基本指导,您应该首先编写您的测试用例。在此阶段,单元测试的结果将是红色的,因为缺少要测试的部分。

然后你用最少的点点滴滴编写方法使其编译。现在完成第一个测试用例,其中包含您从要测试的方法中断言的内容。如果你做对了,你的测试用例现在是绿色的,表明你现在可以检查是否有需要重构的东西。

这会给你TDD的基本原理,即;红色、绿色和重构。

总而言之,您可以在您的方法中测试和断言至少两件事。

  • 正在断言 @filelist 是否 returned 并且不为空
  • 在 return 1
  • 时断言失败案例

还要确保您正在 单元测试 没有外部依赖项,例如文件系统等,因为那将是 集成测试 ,在您的测试中包括系统的其他移动部分。

最后一点,与所有事情一样,经验来自于尝试和学习。至少问问你自己,然后是你的业务同行,看看你是否在测试正确的东西,以及它是否为测试系统的那部分带来任何商业价值。

它是否可测试在很大程度上取决于您的环境。您需要问自己以下问题:

  • 代码是否依赖于生产 Perforce 安装?
  • 运行使用随机值对代码进行编码是否会影响生产?
  • 运行反复使用相同值的代码是否总是产生相同的结果?
  • 外部依赖有时会不可用吗?
  • 外部依赖是否在测试的控制范围之外?

其中一些事情使得运行 测试变得非常困难(但并非不可能)。有些可以通过稍微重构代码来克服。

定义您想要测试的确切内容也很重要。另一方面,unit test for the function would make sure that it returns the right thing depending on what you put in, but you control the external dependency. An integration test 会 运行 外部依赖。

为此构建集成测试很容易,但我上面提到的所有问题都适用。由于您的代码中有一个 exit,因此您无法真正捕获它。您必须将该函数放在脚本中,然后 运行 并检查退出代码,或者使用像 Test::Exit.

这样的模块

您还需要以始终获得相同结果的方式设置 Perforce。这可能意味着您可以控制那里的日期和文件。我不知道 Perforce 是如何工作的,所以我不能告诉你该怎么做,但通常这些东西被称为 fixtures。这是您控制的数据。对于数据库,您的测试程序会在 运行 测试之前安装它们,因此您有一个可重现的结果。

你也有输出到 STDOUT,所以你也需要一个工具来抓取它。 Test::Output 可以做到。

use Test::More;
use Test::Output;
use Test::Exit;

# do something to get your function into the test file...

# possibly install fixtures...
# we will fake the whole function for this demonstration

sub perforce_filelist {
    my ($date) = @_;

    if ( $date eq 'today' ) {
        return qw/foo bar baz/;
    }
    else {
        print "No new files!";
        exit 1;
    }
}

stdout_is(
    sub {
        is exit_code( sub { perforce_filelist('yesterday') } ),
            1, "exits with 1 when there are no files";
    },
    "No new files!",
    "... and it prints a message to the screen"
);

my @return_values;
stdout_is(
    sub {
        never_exits_ok(
            sub {
                @return_values = perforce_filelist('today');
            },
            "does not exit when there are files"
        );
    },
    q{},
    "... and there is no output to the screen"
);
is_deeply( \@return_values, [qw/foo bar baz/],
    "... and returns a list of filenames without newlines" );

done_testing;

如您所见,这可以相对轻松地处理函数所做的所有事情。我们涵盖了所有代码,但我们依赖于外部的东西。所以这不是真正的单元测试。

编写单元测试可以类似地完成。有 Test::Mock::Cmd 可以用另一个函数替换反引号或 qx{}。这也可以在没有该模块的情况下手动完成。如果你想知道如何,请查看模块的代码。

use Test::More;
use Test::Output;
use Test::Exit;

# from doc, could be just 'return';
our $current_qx = sub { diag( explain( \@_ ) ); return; };
use Test::Mock::Cmd 'qx' => sub { $current_qx->(@_) };

# get the function in, I used yours verbatim ...

my $qx; # this will store the arguments and fake an empty result
stdout_is(
    sub {
        is(
            exit_code(
                sub {
                    local $current_qx = sub { $qx = \@_; return; };
                    perforce_filelist('yesterday');
                }
            ),
            1,
            "exits with 1 when there are no files"
        );
    },
    "No new files!",
    "... and it prints a message to the screen"
);
is $qx->[0], 'p4 files -e //depot/project/design/...module.sv@yesterday,@now',
    "... and calls p4 with the correct arguments";

my @return_values;
stdout_is(
    sub {
        never_exits_ok(
            sub {
                # we already tested the args to `` above, 
                # so no need to capture them now
                local $current_qx = sub { return "foo\n", "bar\n", "baz\n"; };
                @return_values = perforce_filelist('today');
            },
            "does not exit when there are files"
        );
    },
    q{},
    "... and there is no output to the screen"
);
is_deeply( \@return_values, [qw/foo bar baz/],
    "... and returns a list of filenames without newlines" );

done_testing;

我们现在可以直接验证是否调用了正确的命令行,但我们不必费心将 Perforce 设置为实际拥有任何文件,这使得测试 运行 更快并使您独立的。您可以 运行 在没有安装 Perforce 的机器上进行此测试,如果该功能只是整个应用程序的一小部分,并且您仍然希望 运行 完整的测试套件,那么这很有用您正在处理应用程序的不同部分。


让我们快速看一下第二个示例的输出。

ok 1 - exits with 1 when there are no files
ok 2 - ... and it prints a message to the screen
ok 3 - ... and calls p4 with the correct arguments
ok 4 - does not exit when there are files
ok 5 - ... and there is no output to the screen
ok 6 - ... and returns a list of filenames without newlines
1..6

如您所见,它与第一个示例几乎相同。我也几乎不必更改测试。只是添加了模拟策略。

重要的是要记住,测试也是代码,同样的质量水平应该适用于它们。它们充当您的业务逻辑的文档,并作为您和您的开发伙伴(包括未来的您)的安全网。清楚地描述您正在测试的业务案例对此至关重要。

如果您想了解更多关于使用 Perl 进行测试的策略,以及不该做什么,我建议您观看讲座 Testing Lies by Curtis Poe

有几件事使得 perforce_filelist 子例程的测试比需要的更困难:

  • p4 路径是硬编码的
  • p4命令在子程序内部构造
  • p4 命令是固定的(因此,它始终是路径中的第一个 p4
  • 你直接从子程序输出
  • 您从子程序内部退出

但是,您的子例程的职责是获取文件列表并 return 它。你在这之外做的任何事情都会让测试变得更难。如果你不能改变这个因为你无法控制它,你可以在未来写这样的东西:

#!perl -T

# Now perforce_filelist doesn't have responsibility for
# application logic unrelated to the file list 
my @new_files = perforce_filelist( $path, $date );
unless( @new_files ) {
    print "No new files!"; # but also maybe "Illegal command", etc
    exit 1;
    }

# Now it's much simpler to see if it's doing it's job, and
# people can make their own decisions about what to do with
# no new files.
sub perforce_filelist {
    my ($path, $date) = @_;
    my @filelist = get_p4_files( $path, $date ); 
    }

# Inside testing, you can mock this part to simulate
# both returning a list and returning nothing. You 
# get to do this without actually running perforce.
#
# You can also test this part separately from everything
# else (so, not printing or exiting)
sub get_p4_files {
    my ($path, $date) = @_;
    my $command = make_p4_files_command( $path, $date );
    return unless defined $command; # perhaps with some logging
    my @files = `$command`;
    chomp @files;
    return @files;
    }   

# This is where you can scrub input data to untaint values that might
# not be right. You don't want to pass just anything to the shell.
sub make_p4_files_command {
    my ($path, $date) = @_;
    return unless ...; # validate $path and $date, perhaps with logging
    p4() . " files -e $path\@$date,\@now";
    }

# Inside testing, you can set a different command to fake
# output. If you are confident the p4 is working correctly,
# you can assume it is and simulate output with your own
# command. That way you don't hit a production resource.        
sub p4 { $ENV{"PERFORCE_COMMAND"} // "p4" }

但是,您还必须判断这种分解对您来说是否值得。对于您不经常使用的个人工具,它可能工作量太大。对于您必须支持并且很多人使用的东西,它可能是值得的。在这种情况下,您可能需要 official P4Perl API。这些价值判断取决于你。但是,分解问题后,进行更大的更改(例如使用 P4Perl)应该不会那么令人震惊。


作为旁注而不是我针对此问题推荐的内容,这是 & 且无参数列表的用例。在这个 "crypto context" 中,子例程的参数列表是调用它的子例程的 @_

这些调用不断在链中传递相同的参数,这很烦人地输入和维护:

    my @new_files = perforce_filelist( $path, $date );
    my @filelist = get_p4_files( $path, $date ); 
    my $command = make_p4_files_command( $path, $date );

使用 & 且没有参数列表(甚至 () 也没有),它将 @_ 传递到下一个级别:

    my @new_files = perforce_filelist( $path, $date );

    my @filelist = &get_p4_files; 
    my $command = &make_p4_files_command;