Test::Classのテストメソッド内でsubtestを使うときに起こる問題の原因と対策

id:nobuokaさんがこういう感じの問題に直面していたので調査してみた。

Test::Class と subtest を組み合わせた場合に、とあるテストメソッドの subtest 内で例外が発生してしまうと、それ以降の別のテストメソッドの subtest も全部失敗してしまうっぽい? - えっちなのはいけないと思います

結論から言うと、subtest内で例外が送出されたときにTest::Classのテストメソッドが例外をキャッチしてしまい、Test::Builderインスタンスが初期化されないまま次のテストメソッドを実行することが原因である。

テストメソッドt2を呼び出した時に具体的に失敗する箇所は以下のコード。 Test::Builderインスタンスの'Child_Name'メンバが初期化されていないから例外が投げられるようにみえる。

package Test::Builder;
# ...
# finalizeはsubtest実行時の最後に呼ばれる
sub finalize {
    my $self = shift;

    return unless $self->parent;
    if( $self->{Child_Name} ) {
        $self->croak("Can't call finalize() with child ($self->{Child_Name}) active"); # ここ
    }
    # …  
    $self->parent->{Child_Name} = undef;  # ここでChild_Nameが初期化される
    # ...  
}

Test::Classのテストメソッドの中でsubtestを呼ばなければcroakした時点でテストは終了するから問題はない。

しかし、Test::Classのテストメソッドの中でsubtestを呼ぶとcroakしてもTest::Class側でevalされて、死なずに次のテストメソッドを実行する。

package Test::Class;
# ...
sub _run_method {
# ...
    $skip_reason = eval {$self->$method}; # 例外補足
# ... 
}

Test::BuilderインスタンスはsingletonなのでMyTest名前空間スコープでは生存したままになる。

package Test::Builder;
# ...
our $Test = Test::Builder->new; # singletonだ

sub new {
    my($class) = shift;
    $Test ||= $class->create;
    return $Test;
}

したがって,次のテストメソッド(t2)の中でsubtestを呼ぶとTest::Builderインスタンスのメンバである'Child_Name'は初期化されていないので,finalize実行時にコケる。

ちなみにfinalizeはsubtestを実行しないと呼ばれないのでテストメソッド内でsubtestを使わない場合は何も問題ない。

対策

Test::Builderインスタンスを初期化してやればよいので下記のコードの

__PACKAGE__->builder->reset;

を追加すればt1が失敗してもt2はちゃんと成功する。 ただし,resetを実行すると'Child_Name'以外にもいろいろ初期化してしまうため副作用がありそう。 実際に初期化が必要なものは'Child_Name'メンバだけなので、

__PACKAGE__->builder->{Child_Name} = undef;

のほうがいいかもしれない。 teardownで呼べばよさそう。

package MyTest;
use utf8;
use strict;
use warnings;
use parent qw(Test::Class);

use Test::More;

sub t1 : Tests {
    subtest 'ok if I die?' => sub {
        die "I'l die";
    };
}

__PACKAGE__->builder->reset;

sub t2 : Tests {
    subtest 'okokokok!!!' => sub {
        ok 1;
    };
}

__PACKAGE__->runtests();

__END__

prove test.t
test.t .. #
# MyTest->t1
    # Child (is it ok if I die?) exited without calling finalize()

not ok 1 - is it ok if I die?
not ok 2 - t1 died (I'l die at test.t line 11.)

#   Failed test 'is it ok if I die?'
#   at /Users/yuuki/.plenv/versions/5.14.4/lib/perl5/site_perl/5.14.4/Test/Class.pm line 289.
#   (in MyTest->t1)

#   Failed test 't1 died (I'l die at test.t line 11.)'
#   at test.t line 22.
#   (in MyTest->t1)
#
# MyTest->t2
    ok 1 - t2
    1..1
ok 1 - okokokok!!!
# Tests were run but no plan was declared and done_testing() was not seen.
Failed 2/3 subtests

Test Summary Report
-------------------
test.t (Wstat: 0 Tests: 3 Failed: 2)
  Failed tests:  1-2
  Parse errors: Tests out of sequence.  Found (1) but expected (3)
                No plan found in TAP output
Files=1, Tests=3,  0 wallclock secs ( 0.03 usr  0.01 sys +  0.03 cusr  0.00 csys =  0.07 CPU)
Result: FAIL

例外補足時にちゃんと初期化しないTest::Classが悪いのかもしくはsingletonとか使ってるTest::Builderが悪いのか。