一瞬でテストを Test2::V0 対応させる
うたがわきき (utagawakiki@gmail.com)
発表資料はLTが終わったらTwitterで共有します
自己紹介
utgwkk.icon
うたがわきき (utgwkk)
京都にいます
京大マイコンクラブ (KMC)
京都大学情報学研究科 (休学中)
株式会社はてな アルバイトエンジニア
カンペ
code ~/ghq/github.com/utgwkk/App-MigrateToTest2V0
Cmd-Shift-P focus terminal
Perlのテストといえば……
Test::More
Test::Deep
Test::Fatal
Test::Mock::Guard
などなど
Test::More
code:test-more.pl
use Test::More tests => 6;
use URI;
isa_ok [], 'ARRAY';
isa_ok +{}, 'HASH';
isa_ok $uri, 'URI', 'an instance of URI';
is_deeply [], [];
Test::Deep
code:test-deep.pl
use Test::Deep;
cmp_deeply 2, 1, bag(1, 2); 2021年なので……
Test2::V0
Test::More より新しい
Test::Deep, Test::Fatal, Test::Mock::Guard などが持つ機能を内包
対応するには?
use Test::More を use Test2::V0 に置き換える
完
完ではない
直すところがいろいろある!!
Test::More (再掲)
code:test-more.pl
# Test2::V0 には tests 引数で plan を実行する機能がない
use Test::More tests => 6;
use URI;
# Test2::V0::isa_ok で第一引数が (Array|Hash)Ref かどうかは見れない
# ref_ok を使う必要がある
isa_ok [], 'ARRAY';
isa_ok +{}, 'HASH';
# Test2::V0::isa_ok では第2引数が ArrayRefStr でないと、第3引数以降についても isa をテストする isa_ok $uri, 'URI', 'an instance of URI';
# Test2::V0::is はデフォルトで構造のequalityを見る
# (Test::More::is は文字列として比較する)
# is_deeply は Test2::V0 にはない (is が is_deeply 相当になった)
is_deeply [], [];
Test::Deep (再掲)
code:test-deep.pl
use Test::Deep;
# bag は Test2::V0::bag と名前がかぶる
cmp_deeply 2, 1, bag(1, 2); 直すところがいろいろある
1つ1つは単純作業だけど……
正規表現置換でちゃちゃっと直せるかは怪しい
「isa_ok の第2引数をArrayRefにする」ために正規表現を書く??
そこでPPI
PPI - Parse, Analyze and Manipulate Perl (without perl)
Perlプログラムのトークン列の操作ができるぞ!!
やっていきましょう
まずテストファイルをパースして……
code:migrate-to-test2-v0.pl
use PPI;
my $doc = PPI::Document->new('test.t');
is_deeplyをisに置き換える
code:migrate-to-test2-v0.pl
my $tokens = $doc->find(sub {
my (undef, $elem) = @_;
return $elem->isa('PPI::Token') && $elem->content eq 'is_deeply';
});
for my $token (@$tokens) {
$token->set_content('is');
}
isa_okで(Array|Hash)Refかどうか見ているところをref_okに置き換える
code:migrate-to-test2-v0.pl
# isa_ok で始まる文を取ってきて
my $stmts = $doc->find(sub {
my (undef, $elem) = @_;
return $elem->isa('PPI::Statement') && $elem->first_token && $elem->first_token->content eq 'isa_ok';
});
for my $stmt (@$stmts) {
# isa_ok の第2引数を抜き出す
my $second_arg = App::MigrateToTest2V0::Util::get_argument_of_stmt($stmt, 1);
next unless $second_arg;
next unless $second_arg->isa('PPI::Token::Quote');
# HASH|ARRAY のとき isa_ok を ref_ok にする
if ($second_arg->content =~ /HASH|ARRAY/) {
$stmt->first_token->set_content('ref_ok');
}
}
isa_okの第2引数をArrayRefにする
code:migrate-to-test2-v0.pl
# isa_ok で始まる文を取ってきて
my $stmts = $doc->find(sub {
my (undef, $elem) = @_;
return $elem->isa('PPI::Statement') && $elem->first_token && $elem->first_token->content eq 'isa_ok';
});
for my $stmt (@$stmts) {
# isa_ok の第2引数を抜き出す
my $second_arg = App::MigrateToTest2V0::Util::get_argument_of_stmt($stmt, 1);
next unless $second_arg;
# で囲む
my $first_paren = PPI::Token::Structure->new('[');
my $last_paren = PPI::Token::Structure->new(']');
$second_arg->insert_before($first_paren);
$second_arg->insert_after($last_paren);
}
Test::DeepがEXPORTする関数と名前がかぶらないようにする
code:migrate-to-test2-v0.pl
use Test::Deep ();
# Test::Deep の関数呼び出しっぽいところを取ってきて
my $tokens = $doc->find(sub {
my (undef, $elem) = @_;
return $elem->isa('PPI::Token::Word') && any { $elem->content eq $_ } @Test::Deep::EXPORT;
});
# Test::Deep の関数は全て Test::Deep::(関数名) の形式で呼び出すようにする
for my $token (@$tokens) {
next unless App::MigrateToTest2V0::Util::is_function_call($token);
$token->set_content('Test::Deep::' . $token->content);
}
# use Test::Deep を取ってきて
my $stmts = $doc->find(sub {
my (undef, $elem) = @_;
return $elem->isa('PPI::Statement::Include') && $elem->module eq 'Test::Deep';
});
# use Test::Deep を全て use Test::Deep (); に置き換える
for my $stmt (@$stmts) {
my $module_name_token = $stmt->schild(1);
# useの引数をぜんぶ削除する
my $elem = $module_name_token->next_sibling;
while ($elem->content ne ';') {
my $next = $elem->next_sibling;
$elem->remove;
$elem = $next;
}
# () を追加する
$module_name_token->insert_after(PPI::Token::Structure->new(')'));
$module_name_token->insert_after(PPI::Token::Structure->new('('));
$module_name_token->insert_after(PPI::Token::Whitespace->new(' '));
}
use Test::Moreをuse Test2::V0に置き換える
code:migrate-to-test2-v0.pl
# use Test::More を取ってきて
my $use = $doc->find_first(sub {
my (undef, $elem) = @_;
return $elem->isa('PPI::Statement::Include') && $elem->module eq 'Test::More';
});
# Test::More を Test2::V0 に置き換える
my $module_name_token = $use->schild(1);
$module_name_token->set_content('Test2::V0');
# use の引数がなかったらここまでで終了
my $arg_kind = ($use->arguments)0; # use Test::More に渡していた引数に対応する文を追加する
if ($arg_kind eq 'tests') {
# plan tests => ...;
my $test_num = ($use->arguments)2; my $plan_stmt = PPI::Statement->new;
$plan_stmt->add_element(PPI::Token::Whitespace->new("\n"));
$plan_stmt->add_element(PPI::Token::Word->new('plan'));
$plan_stmt->add_element(PPI::Token::Whitespace->new(' '));
$plan_stmt->add_element(PPI::Token::Word->new('tests'));
$plan_stmt->add_element(PPI::Token::Whitespace->new(' '));
$plan_stmt->add_element(PPI::Token::Operator->new('=>'));
$plan_stmt->add_element(PPI::Token::Whitespace->new(' '));
$plan_stmt->add_element(PPI::Token::Number->new($test_num));
$plan_stmt->add_element(PPI::Token::Structure->new(';'));
$use->insert_after($plan_stmt);
} elsif ($arg_kind eq 'skip_all') {
# skip_all ...;
my $skip_reason = ($use->arguments)2; my $skip_all_stmt = PPI::Statement->new;
$skip_all_stmt->add_element(PPI::Token::Whitespace->new("\n"));
$skip_all_stmt->add_element(PPI::Token::Word->new('skip_all'));
$skip_all_stmt->add_element(PPI::Token::Whitespace->new(' '));
$skip_all_stmt->add_element($skip_reason);
$skip_all_stmt->add_element(PPI::Token::Structure->new(';'));
$use->insert_after($skip_all_stmt);
}
# 元々あったuseの引数を消す
my $elem = $module_name_token->next_sibling;
while ($elem->content ne ';') {
my $next = $elem->next_sibling;
$elem->remove;
$elem = $next;
}
これが
code:before.pl
use Test::More tests => 6;
use URI;
isa_ok [], 'ARRAY';
isa_ok +{}, 'HASH';
isa_ok $uri, 'URI', 'an instance of URI';
is_deeply [], [];
use Test::Deep;
cmp_deeply 2, 1, bag(1, 2); こうなった
code:after.pl
use Test2::V0;
plan tests => 6;
use URI;
ref_ok [], 'ARRAY';
ref_ok +{}, 'HASH';
use URI;
isa_ok $uri, 'URI', 'an instance of URI'; is [], [];
use Test::Deep ();
Test::Deep::cmp_deeply 2, 1, Test::Deep::bag(1, 2); めでたしめでたし?
まだ残っているぞ!!
Test2::V0の is はデフォルトで構造のequalityを見る
文字列とURIオブジェクトを比較してテストが落ちる
code:prove.log
# Failed test at eg/test.t line 13.
# +----------------------+--------+----------+
# | GOT | OP | CHECK |
# +----------------------+--------+----------+
# +----------------------+--------+----------+
回避するには
is の第2引数を string() で囲む (文字列として比較する)
is 'https://example.com/', string($uri);
is の第2引数を文字列化する
is 'https://example.com/', $uri.q();
オブジェクトがURIか静的解析しづらい
オブジェクトのisaはPPIだけでは判断できない
やるならもっと本格的な静的解析手法が必要そう
そこで動的解析
静的解析が難しいなら実行時にエイヤッとやればいいじゃない
落ちるテストがこういう is で文字列とオブジェクトを比較するものだけになったら……
片っ端から第2引数を囲んでいけばいいのでは??
Test2::Plugin::Wrap2ndArgumentOfFailedCompareTestWithString
Test2プラグイン!!!
Test2のイベントをフックしてアレコレできる
「落ちたテストを書き換える」作戦でいく
実際にやってみた
code:plugin.pm
sub listener {
my ($hub, $event) = @_;
# テスト落ちてたら
return unless $event->causes_fail;
# 落ちたテストのファイル名と行数を取ってきて
my $trace = $event->trace;
my $file = $trace->file;
my $line = $trace->line;
# おもむろにPPIでパースして
my $doc = PPI::Document->new($file);
# 落ちたテストがある行の文を取ってきて
my $stmt = $doc->find_first(sub {
my (undef, $elem) = @_;
return $elem->isa('PPI::Statement') && $elem->line_number == $line;
});
return unless $stmt;
# 第2引数を抜き出して
my $second_arg = App::MigrateToTest2V0::Util::get_argument_of_stmt($stmt, 1);
return unless $second_arg;
# string() で囲んで
$second_arg->insert_before(PPI::Token::Word->new('string'));
$second_arg->insert_before(PPI::Token::Structure->new('('));
$second_arg->insert_after(PPI::Token::Structure->new(')'));
# 書き出す!!!
$doc->save($file);
}
デモ
カンペ: VSCodeを開いてください
code:デモ用test.tにコピペしてください→
use Test2::Plugin::Wrap2ndArgumentOfFailedCompareTestWithString;
産業革命
https://gyazo.com/6f5d53fcfa2a4dc954351c0a38d343a4
App::MigrateToTest2V0
Test::More → Test2::V0に置き換えるCLIツイール
落ちたテストの第2引数を string() で囲むTest2プラグイン
Plackのテストに適用したら (no strict を数ヶ所に足すだけで) Test2::V0対応できた
整ってきたらCPANizeしたい
ご期待ください!!!!
Test::Fatalの置き換えとかも対応したらCPANizeしたい