今年テストで頑張ったことまとめ

この記事は tech.kayac.com Advent Calendar 2015 2日目です。

こんにちは、最近よく過激派と呼ばれている穏健派のshogo82148です。 今年一年、安心して開発ができるようテストに特に力を入れてきました。 そこで今年テストでおこなった取り組みを振り返ってみようと思います。 残念ながらGoではなくPerlのテストのお話です。

テストをとにかく速くする!

最初に手をつけたのはテストのスピードです。 まず全部のテストが通るようリファクタリングをしてから機能追加というスタイルで開発していたんですが、 全部のテストが終わるまでに10分20分もかかっていてはいつまでたっても機能追加に着手できません。

Jenkins EC2 Plugin

とりあえずマシンパワーで解決だ!ということでEC2でマシンパワーの高いインスタンスを使いました。 Spot Instanceを必要なときだけ立ち上げるという構成になっています。 詳しい設定等は弊社インフラチームtkuchikiさんのJenkins EC2 Plugin で Spot Instance を使ってテストを回すを参考にどうぞ。

Test::mysqld::Multi

マシンパワーを上げてテストの並列度を上げたものの、 闇雲に並列度をあげてもテストは速くなりませんでした。 よくテストの挙動を観察したところ、 App::Prove::Plugin::MySQLPoolを使ってテスト用の MySQLサーバを立ち上げている部分に時間がかかっているらしいことがわかりました。 CPUを全く使っていないようだったので、この部分も並列化したら早くなるのでは?と書いたのが Test::mysqld::Multiです。 これを使うことでMySQLサーバを立ち上げるための2分程度を削減することができました。 プロセスの一覧にmysqldが一度に現われるのは楽しいですね。

go-prove

Perl標準のテストランナーであるproveは一応並列実行できるのですが、あまり性能がよくありません。 並列度が高くなるとCPUを使い切れずに遅くなってしまいます。 テスト実行するだけならPerlにこだわる必要ないのでは!と proveを並行処理が得意なGo言語で実装したのがgo-proveです。 僕の携わっているプロジェクトではすでにgo-proveに移行しており、 マシンパワーをフルに活かせるので助かっています。

Test::SharedForkと仲良くする

Test::SharedForkを使うと、 テストの中でforkしても安全になるのですごく便利なのですが、 その代わりTest::Moreの呼び出し速度が多少犠牲になります。 テストが大量にあるとこの速度低下を無視できなくなるケースがあったので、 以下の様に最後にokとだけ出力する工夫をしています。

use Test::More;
my @errors;
for my $test (@tests) {
    push @errors, "error!!!" if test_fail();
}
is scalar(@errors), 0 or diag @rrors;

本当は必要なときだけuse Test::SharedForkするのが正しいと思うのですが、 直接Test::SharedForkを使っていなくても、 Test::TCPのように内部でTest::ShardForkと使っているモジュールがあると速度が低下してしまいます。

こんな感じのコードを書いて回避しようともしましたが、 劇的な効果は無かったので今は前述の方法に落ち着いています。

BEGIN {
    no strict 'refs';
    for my $name (qw/ok skip todo_skip current_test is_passing/) {
        $orig{$name} = *{"Test::Builder::${name}"}{CODE};
    }
}

use Test::TCP;

use Class::Unload;

BEGIN {
    Class::Unload->unload('Test::SharedFork');
    Class::Unload->unload('Test::SharedFork::Contextual');
    my $builder = Test::Builder::Module->builder;
    untie $builder->{Curr_Test};
    untie $builder->{Is_Passing};
    untie @{$builder->{Test_Results}};

    no strict 'refs';
    no warnings 'redefine';
    for my $name (qw/ok skip todo_skip current_test is_passing/) {
        *{"Test::Builder::${name}"} = $orig{$name};
    }
}

その他工夫

警告に素早く気がつくようにする

テストのログを眺めいたら、「Use of uninitialized value」にような警告がそのまま放置されていることに気が付きました。 一応テストはパスしていて動いてはいるのですが、気持ちのいいものではありませんね・・・。 そこで以下のようなモジュールを書いて、警告が出ていたらテスト結果を不安定にするようにしました。 proveのオプションに--exec 'perl -MWarningReporter'とつけることで全てのテストにこのモジュールを組み込むことができます。

package WarningReporter;

our $mark_as_unstable = 0;

unless (defined $SIG{__WARN__}) {
    $SIG{__WARN__} = sub {
        my $msg = shift;
        $msg =~ s/\n+$//;
        warn "$msg (in test file $0)\n";

        if ($msg =~ /deprecated|Use of uninitialized value|Wide character in print|Useless use of .* in void context|masks earlier declaration in same scope|experimental|isn't numeric|Odd number of elements in hash/i) {
            $mark_as_unstable = 1;
        }
    };
}

END {
    if ($mark_as_unstable) {
        warn "marked UNSTABLE\n";
    }
}

JenkinsのText finder Pluginを使って、 UNSTABLEという文字列を見つけたら、テストの状態を不安定にするようにしています。

AutoIncrementの初期値をランダムにする

テストの最初に ALTER TABLE hogehoge AUTO_INCREMENT = xxx; を実行して、 AutoIncrementの初期値をずらすようにしました。 「user.idで検索しないといけないのにitem.idで検索していた!」のようなミスをなくすためです。

なるべくUnix Domain Socketを使う

テストの並列度が高くなるとTest::TCPで確保したポートがぶつかり テスト用サーバの立ち上げに失敗するということが時々発生するようになりました。 これを避けるために極力Unix Domain Socketを使ってテストするようにしています。 このためだけにgo-katsubushiをUnix Domain Socket対応したり、 Test::TCPのUnix Domain Socket版「Test::UNIXSock」を作ったりしました。

最近困っていること

以下のようにuseし忘れたモジュールがあった場合に

package FooBar;
# use HogeHoge; # し忘れた!!!
sub test { HogeHoge->new }

テスト側でたまたまuseしていたためにテストは通るけど、 それ以外のところでこのモジュールと使おうとしてコケるというケースに遭遇して困ってます。

use FooBar;
use HogeHoge;
test();

誰か良い検出方法をご存じないでしょうか・・・。

明日はki_230さんの「一流のフロントエンドエンジニアが引っ越しの達人である理由」です。