NeverBlog::Likk::Unexistable;

見なかったことにして下さい

Perlのテストを `Test::Spec` から `Test2::Tools::Spec` に移行した

Perl でテストを書いてるみなさん。 Test2 にはもう慣れましたよね?私は全然慣れてません。

ということで、最近、個人のプロジェクトで書かれてるテストを Test2 にしました。

その際、それまでのテストを Test::Spec で書いていたので、Test::Tools::Spec に移行させることにしました。 Test::SpecRubyRspec 風に書くことができるモジュールです。

前提として Perl 5.40 での話で、Perlのバージョンによっては Test2 の最新を cpanm などでインストールする必要があります。(5.40 から Test2::Suite がコア入りした)

さくっと置き換えられたもの

use

use Test::Spec; を消して use Test2::V0; se Test2::Tools::Spec; を追加

-use strict;
-use warnings;
-use utf8;
-
-use Test::Spec;
+use 5.40.0;
+use Test2::V0;
+use Test2::Tools::Spec;

runtest

runtests unless caller を消して done_testing を追加 そのまま置き換えられるけど、それぞれ意味することは違うので、理解したうえで置換。

-# XXX: forkprove has caller
-# runtests unless caller;
-runtests();
+done_testing;

context

s/context/describe/g する

-    context 'case call run method without arguments' => sub {
+    describe 'case call run method without arguments' => sub {
         it 'when returns undef' => sub {
             my $res = Oden::Command::AYT->run();
             is $res, undef;
         };
     };

before, after,

all, each まで含めた関数名になり、名前が必要になる。単純に置換 s/before\sall/before_all "XXX"/ して後で埋めるのでもよい。

-    before all => sub {
+    before_all "setup" => sub {
 -    context 'case non-empty message' => sub {
-        before all => sub {
+    describe 'case non-empty message' => sub {
+        before_each "create_test_message" => sub {
-    after all => sub {
+    after_all "cleanup"=> sub {

around

基本的に before, all とやることは同じ。yield()shift->() に置換しても良いが、変数に逃がしておくと安全

-        context "case thread_id is not set" => sub {
-            around {
+        describe "case thread_id is not set" => sub {
+            around_all "mockup" => sub {
+                my $tests = shift;
                 $hash->{api} = Oden::API::Discord->new(token => $hash->{valid_token}, interval => 0);
 
-                yield;
+                $tests->();
 
                 delete $hash->{api};
             };

stubs(mock)

しっかり書き換えが必要。 これは Test2::Tools::Spec ではなく、Test2::Tools::Mock に機能がある。 use Test2::V0 するだけで読み込んでくれる。

Modiule->stubs(+{mock "Modiule" => ( にしつつ、override 配列にいれる必要がある。 動的にメソッドが生えるものは override できないので注意。 Moops の場合は add で通ったがダメなパターンもあるかもしれない。

overrideの例

              $hash->{api} = Oden::API::Discord->new(token => $hash->{invalid_token}, interval => 0);
 
-                $hash->{stubs}->{furl} = Furl->stubs(+{
-                    request => sub {
-                        Furl::Response->new(
-                            1,
-                            '401',
-                            "Unauthorized",
-                            +{
-                                'content-type' => 'application/json'
-                            },
-                            q|{"message": "401: Unauthorized", "code": 0}|
-                        );
-                    },
-                });
-
+                $hash->{mocks}->{furl} = mock "Furl" => (
+                    override => [
+                        request => sub {
+                            Furl::Response->new(
+                                1,
+                                '401',
+                                "Unauthorized",
+                                +{
+                                    'content-type' => 'application/json'
+                                },
+                                q|{"message": "401: Unauthorized", "code": 0}|
+                            );
+                        },
+                    ],
+                );

add の例。

-            $hash->{stubs}   = AnyEvent::Discord->stubs(
-                token            => sub { return 'your token'; },
-                user_agent       => sub { return 'Perl-AnyEventDiscord/' . shift->VERSION },
-                _ws_send_payload => sub { return $_[1]; },
-                _debug           => sub {  },
-                _discord_api     => sub { +{ url => "wss://gateway.discord.gg/" } },
+            $hash->{discord} = AnyEvent::Discord->new;
+            my $mock = mock "AnyEvent::Discord" => (
+                add => [
+                    token            => sub { return 'your token'; },
+                    user_agent       => sub { return 'Perl-AnyEventDiscord/' . shift->VERSION },
+                    _ws_send_payload => sub { return $_[1]; },
+                    _debug           => sub {  },
+                    _discord_api     => sub { +{ url => "wss://gateway.discord.gg.example/" } }
+                ],
             );

hash などに複数いれる場合は () しておくと安全

mock "Hoge" => ( ... ), ではなく mock("Hoge" => ()) にしておく。 こうすることで 2個目のmock() が1個目の mock()の第三引数として扱われないようにする。

-    before all => sub {
-        $hash->{stubs} =+{
-            connect => AnyEvent::WebSocket::Client->stubs(
-                connect => sub {
-                    $hash->{called}->{connect} = 1;
-                    return AE::cv;
-                },
-            ),
-            recv => AnyEvent::CondVar->stubs(
-                recv => sub {
-                    $hash->{called}->{recv} = 1;
-                },
-            ),
+
+    before_all setup => sub {
+        $hash->{mocks} =+{
+            connect => mock("AnyEvent::WebSocket::Client" => (
+                override => [
+                    connect => sub {
+                        $hash->{called}->{connect} = 1;
+                        return AE::cv;
+                    },
+                ],
+            )),
+            recv    =>  mock("AnyEvent::CondVar" => (
+                override => [
+                    recv => sub {
+                        $hash->{called}->{recv} = 1;
+                    },
+                ],
+            )),
         };
     };

they

ない。 Test::Spec#theyTest::Spec#itエイリアスなので素直に it 使おう。 ちなみに 、Test2::Tools::Spec#itTest2::Tools::Spec#testsエイリアスなので、it()they()tests() にするのが正解かもしれない。

ハマったこと

describe/it

Test::Speck でよく書く describe > itdescribe >describe(context) > it の構造について、 構造は正しく保ちますが、実行順は順不同になります。 Test::Speccontext1 ,context2 の順で動くことを前提で書いていたテストが Test2::Tools::Spec で通らなくなったので気付いた。 これは、そもそもテスト順に依存したテストを書くなという話で、Test::Spec で「たまたま許されていた」と受け止めている。

以下にコード例と動作例を挙げておきます

コード例

describe 'about describe' => sub {
    my $hash;                   
                                                                                    
    describe '1st describe' => sub {                           
        it '1st it' => sub {
            ok 1;
        };                      

        it '2nd it' => sub {
            ok 2;
        };                      

        it '3rd it' => sub {
            ok 3;
        };                      

    };                          
                                                                                          
    describe '2nd describe' => sub {                                          
        describe '2-1 describe' => sub {                                                                           
            it '2-1 1st it' => sub {                        
                ok '2-1-1'
            };
            it '2-1 2st it' => sub {
                ok '2-1-2'
            };
            it '2-1 3rd it' => sub {
                ok '2-1-3'
            }; 
        };                      
                                                                             
        describe '2-2 describe' => sub {
            it '2-2 1st it' => sub {
                ok '2-2-1'                                                                                                                                                                                                                                                    };                                                                                                                                                                                                                                                    
            it '2-2 2st it' => sub {                                                                                                                                                                                                                              
                ok '2-2-2'
            };
            it '2-2 3rd it' => sub {
                ok '2-2-3'
            };
        };
    };
};

done_testing();

実行結果

./t/test.t .. 
# Seeded srand with seed '20241212' from local date.
ok 1 - about describe {
    ok 1 - 2nd describe {
        ok 1 - 2-2 describe {
            ok 1 - 2-2 2st it {
                ok 1
                1..1
            }
            ok 2 - 2-2 3rd it {
                ok 1
                1..1
            }
            ok 3 - 2-2 1st it {
                ok 1
                1..1
            }
            1..3
        }
        ok 2 - 2-1 describe {
            ok 1 - 2-1 3rd it {
                ok 1
                1..1
            }
            ok 2 - 2-1 2st it {
                ok 1
                1..1
            }
            ok 3 - 2-1 1st it {
                ok 1
                1..1
            }
            1..3
        }
        1..2
    }
    ok 2 - 1st describe {
        ok 1 - 3rd it {
            ok 1
            1..1
        }
        ok 2 - 2nd it {
            ok 1
            1..1
        }
        ok 3 - 1st it {
            ok 1
            1..1
        }
        1..3
    }
    1..2
}
1..1
ok
All tests successful.

実行結果 別例

describe > it だけの場合でも起きます。 こちらは実行例だけで伝わると思うのでコードは省略してます。

./t/test.t .. 
# Seeded srand with seed '20241212' from local date.
ok 1 - about describe {
    ok 1 - 4th it {
        ok 1
        1..1
    }
    ok 2 - 1st it {
        ok 1
        1..1
    }
    ok 3 - 2nd it {
        ok 1
        1..1
    }
    ok 4 - 3rd it {
        ok 1
        1..1
    }
    ok 5 - 5th it {
        ok 1
        1..1
    }
    ok 6 - 6th it {
        ok 1
        1..1
    }
    1..6
}
1..1
ok
All tests successful.

あきらめたもの

xcontext, xdescriber, xit

ない。 個人的にはコメントアウトするより楽なので使ってただけで、コメントアウトに戻すだけの運用になった。 TODO ブロック代わりに使ってると、面倒臭いかもしれない。

share(), shared_examples_for(), it_should_behave_like()

ない。 今回は shared_examples_for(), it_should_behave_like() を使ってなかったので、 share() を消すだけの変更になった。

 describe 'about XXX' => sub {
     my $hash;
-    share %$hash

どうしても shared_examples_for(), it_should_behave_like() みたいなことをしたい場合、関数に逃がすしか無さそう。 その際は share() に入るべきリファレンスも一緒に渡す感じになる。(あるいは Test::Spec::SharedHash を再実装するか)

Test2::Tools::Spec 以外の話

ついでなので変えたところを挙げていく。 (厳密に言えば stubs(mock) もココに入るが……)

Test::Exception

手を入れれば use Test2::V0 だけで行けます。実態は Test2::Tools::Exception にある。

             it 'throw exception' => sub {
-                throws_ok {
+                my $throws = dies {
                     Oden::API::Discord->new()
-                } qr/require token parameter/;
+                };
+                like $throws, qr/require token parameter/;
             };

Test2::Tools::Exception の SYNOPSIS には

like(
    dies { die 'xxx' },
    qr/xxx/,
    "Got exception"
);

と記載されてるけど 若干見づらい 好みの問題で2行に分けてます。

my $throws = dies { die 'xxx' },
like(throws, qr/xxx/, "Got exception");

Test:Warn

warn は exception と違って use Test2::Tools::Warnings が必要。

+use Test2::V0;
+use Test2::Tools::Spec;
+use Test2::Tools::Warnings qw/waning warnings/;
 
-use Test::Spec;
-use Test::Exception;
-use Test::Warn;

使い方は基本的に Exception と一緒。

-                warning_like {
+                my $warning = warning {
                     my $package = Oden::Dispatcher->dispatch('not_exist');
-                    is $package, undef, 'undef';
-                } qr/Can't locate Oden\/Command\/NotExist.pm in \@INC/, 'throw and warning';
+                };
+                like $warning , qr/Can't locate Oden\/Command\/NotExist.pm in \@INC/, 'throw and warning';
 

、warning が複数出るケースの場合はちょっと面倒

-                warnings_like {
+                my $warnings = warnings {
                     my $res = $hash->{api}->send_attached_file(
                         $hash->{valid_channel_id},
                         $hash->{path}
                     );
                     is $res, false;
-                } [qr/401/, qr/Unauthorized/];
+                };
+
+                like $warnings->[0], qr/401/;
+                like $warnings->[1], qr/Unauthorized/;

isa_ok

isa_ok が多重継承に対応した。基本配列型にすれば大丈夫

-                isa_ok $api, 'Oden::API::Discord';
+                isa_ok $api, ['Oden::API::Discord'];

HASH, ARRAY を期待する場合は ref_ok() にする必要がある

             my $discord   = $hash->{discord};
             my $endpoints = $discord->endpoint_config();
 
-            isa_ok $endpoints, 'HASH';
+            ref_ok $endpoints, 'HASH';

is_deeply

Test2 の is で構造体比較ができるようになったので、 is_deeply は is に置き換えて行ける。 実態は Test2::Tools::Compare#is

-            is_deeply $endpoints, +{
+            is $endpoints, +{

最初は以下の様に Test2::Tools::ClassicCompare#is_deeply を use していたが is() で解決する内容だった。

+use Test2::Tools::ClassicCompare qw/is_deeply/;

explain

テスト中のデバッグ出力の神。無いと生きていけないが、基本 pushする前に消すので、普段 Test::More でテスト書いてても存在を忘れられがち

Test2::Tools::Explain にあるのでデバッグ出力が必要なときだけ use する。 use Data::Dumper; して Dump でもいいけど。

+ use Test2::Tools::Explain;

...

diag explain $hash

まとめ: というか diff 見たほうが早いって人向け

Test::Spec から Test2::Tools::Spec に移行した p-r があるのでそのコード見てください 該当リポジトリ内のコードの大部分を最近別のリポジトリに移行したので最新をみるときはそちらを。(diff の有る方のリポジトリから最新を見ようとすると not found になってます。🙇)