Blog::kobaken

prove t/foo/bar/baz.t

subtest の名前でテスト対象をフィルタリングするTest2::Plugin::SubtestFilterをリリースしました

jest、vitestなどの --testNamePatternのようにPerlのテストで、subtestの名前でテスト対象をフィルタリングできるTest2::Plugin::SubtestFilterをリリースしました。特定のテストだけ実行したい時、便利だと思います!

# t/test.t
use Test2::V0;
use Test2::Plugin::SubtestFilter;

subtest 'foo' => sub {
    subtest 'nested foo1' => sub { ok 1 };
    subtest 'nested foo2' => sub { ok 1 };
};

subtest 'baz' => sub {
    ok 1;
};

done_testing;
# foo1 にマッチするテストだけ実行されている様子
❯ SUBTEST_FILTER=foo1 prove -lvr t/test.t
t/test.t ..
# Seeded srand with seed '20251020' from local date.
ok 1 - foo {
    ok 1 - nested foo1 {
        ok 1
        1..1
    }
    ok 2 - nested foo2 # skip
    1..2
}
ok 2 - baz # skip
1..2
ok
All tests successful.
Files=1, Tests=2,  0 wallclock secs ( 0.00 usr  0.00 sys +  0.04 cusr  0.00 csys =  0.04 CPU)
Result: PASS

仕組み解説

1. subtest 関数のオーバーライド

プラグインは Test2 の subtest 関数を動的に置き換えます:

# lib/Test2/Plugin/SubtestFilter.pm:14-26
sub import {
    my $class = shift;
    my $caller = caller;

    # 呼び出し元の名前空間から元の subtest 関数を取得
    my $orig = $caller->can('subtest') or return;

    # subtest をフィルタリング機能付きのものに置き換え
    no strict 'refs';
    no warnings 'redefine';
    *{"${caller}::subtest"} = _create_filtered_subtest($orig, $caller);
}

2. Test2 API によるサブテスト名の追跡

Test2 の context と hub を使って、ネストされたサブテストの階層を追跡します:

# lib/Test2/Plugin/SubtestFilter.pm:67-72
my $ctx = context();
my $hub = $ctx->hub;

# 現在のサブテスト名をメタデータに保存
$hub->set_meta(subtest_name => $name);

# スタック上のすべてのサブテスト名を取得
my @stacked_subtest_names = map { $_->get_meta('subtest_name') } $ctx->stack->all;

# 完全なサブテスト名を構築(例: "parent child grandchild")
my $current_subtest_fullname = join $SEPARATOR, @stacked_subtest_names;

3. B::Deparse による静的コード解析

子サブテストの存在を事前に検出するために、B::Deparse を使用してコードを文字列に変換し、正規表現で解析します:

# lib/Test2/Plugin/SubtestFilter.pm:82-92
my $obj    = B::svref_2object(\$code);
my $source = $deparse->coderef2text($code);

# サブテストの呼び出しをパターンマッチで抽出
my @child_subtest_names = $source =~ /subtest\(['"](.+?)['"]/g;

# UTF-8デコード処理(\x{XXXX} 形式を文字に変換)
if (@child_subtest_names) {
    my @child_subtest_fullnames = map {
        my $decoded = $_;
        $decoded =~ s/\\x\{([0-9a-fA-F]+)\}/chr(hex($1))/ge;
        join $SEPARATOR, $current_subtest_fullname, $decoded;
    } @child_subtest_names;
    ...
}

4. フィルタリングのロジック

3段階のチェックでフィルタリングを実行します:

# ステップ1: 現在のサブテスト名がフィルタにマッチ?
if ($current_subtest_fullname =~ $filter) {
    # マッチした場合、このサブテストとすべての子を実行
    my $pass = $original_subtest->($name, $params, $code, @args);
    $ctx->release;
    return $pass;
}

# ステップ2: 子サブテストのいずれかがマッチ?
if (@child_subtest_names) {
    my @child_subtest_fullnames = map { ... } @child_subtest_names;
    if (any { $_ =~ $filter } @child_subtest_fullnames) {
        # 子がマッチした場合、親を実行(子のフィルタリングは再帰的に行われる)
        my $pass = $original_subtest->($name, $params, $code, @args);
        $ctx->release;
        return $pass;
    }
}

# ステップ3: マッチしない場合はスキップ
$ctx->skip($name);
$ctx->release;
return 1;

以上です!