Perl Hackers Hub

第23回Perlアプリケーションのテストと高速なCI環境構築術(1)

本連載では第一線のPerlハッカーが回替わりで執筆していきます。今回はmyfinderこと久森達郎さんで、テーマは「Perlアプリケーションのテストと高速なCI環境構築術」です。テストに利用するさまざまなモジュールから、CIContinuous Integration継続的インテグレーション)環境の構築、そして増えたテストを高速に実行する枠組みの構築までを紹介します。

なお本稿のサンプルコードは、本誌サポートサイトから入手できます。

テストの目的

まずはテストを行う目的を整理します。

コードを壊していないことを確認する

1人で開発するものであれば、どんな状況であれ責任を負うのは自分だけですが、チームや組織が複数にまたがる場合にはテストが重要になります。プロダクトが大きくなるにつれ、自分の開発によって想定外の機能にも影響を与えることが多くなります。逆にほかの開発者が行ったコミットによって自分の開発した部分に影響が出ることもあるので、これらを明確にするためにもテストは必要です。

実行・確認を自動化する

テストコードがないと、開発ごとに毎回手動で機能確認することになり、たいへん非効率です。非効率な状態を放置していると、確認漏れが起こったり、場合によっては十分にテストをせずにリリースしたりといった事態に発展しかねません。

自動化されていれば、誰でも気軽に実行でき、プログラムの改修が容易になるという副次的な効果もあります。

Perlアプリケーションのテスト

次に、Perlアプリケーションのテストについてまとめます。

Test::Moreによる基本的なテスト

Perlでテストを書くにあたって利用する最も一般的なモジュールは、Test::Moreでしょう。Perl 5.6.2からコアモジュールに入っているので、最近のPerl環境であればすぐ使い始めることができます。

Test::Moreはとてもシンプルなライブラリです。次のような単純な消費税率計算をするモジュールを例に説明します。

package ConsumptionTax::JP;
use parent qw/Class::Accessor::Fast/;

__PACKAGE__->mk_accessors(qw/ consumption_tax_rate /);

sub tax_include {
    my ($self, $price) = @_;
    return $price * (1 + $self->consumption_tax_rate);
}

1;

このモジュールのテストコードは次のようになります。

use Test::More;

use_ok("ConsumptionTax::JP");

my $consumption_jp
    = new ConsumptionTax::JP->new({
        consumption_tax_rate => 0.05,
    });

subtest " メソッド実装チェック" => sub {
    #tax_include という関数を実装しているか
    can_ok($consumption_jp, 'tax_include');
};

subtest "tax_include の動作チェック" => sub {
    my $price = 100;
    my $price_in_tax
        = $consumption_jp->tax_include($price);
    # 期待値と一致しているか
    is $price_in_tax, 105, 'match expected';
};

done_testing;

Test::Moreを使ってテストを書いた場合、テストコードの最後にdone_testing;を記述するのを忘れないようにしてください。このテストコードでは、

  • モジュールのuse
  • オブジェクトの生成
  • メソッドの実装有無
  • メソッドが期待した値を返すか

といった基本的な項目を網羅しています。

Test::Moreにはほかにも、

ok:真偽値のチェック
ok($val, "$val is true");
is_deeply:配列やハッシュの比較
is_deeply($val, { key => 'val'}, "$val is match");
like:正規表現との一致
like($val, qr/ 正規表現/, "$val is match");

といったメソッドを備えており、値やデータ構造の比較などもできるので、たいていの用途はカバーできるでしょう。

しかし実際のアプリケーションでは、Web APIなどの外部リソースに依存したコードや、時刻などテスト実行時に変化してしまうものも多くあります。そのようなテストも、ほかのテストモジュールを組み合わせて用いることでカバーできます。

Test::Stubによる外部依存テスト

Test::Stubは、外部サービスのAPIをコールしてその結果を利用するようなコードを書く場合などに役立つモジュールです。実際にはリクエストをせず、期待される結果に対してモジュールのテストを行うことができます。

たとえばTest::Stubを用いて、

use Test::More;
use Test::Stub qw/stub/;
use LWP::UserAgent;
use HTTP::Response;

my $ua = LWP::UserAgent->new;

subtest " レスポンス差し替え" => sub {
    stub($ua)->get(
        HTTP::Response->new(200, "OK")
    );
    is (
        $ua->get('http://example.com/')->code,
        200,
        'response code is ok'
    );
};

done_testing;

といったようにHTTP::Responseオブジェクトを返すだけの内容に差し替えることで、実際にHTTPリクエストを行わず結果オブジェクトを返すテストを書くことができます。

Test::MockTimeによる時刻のテスト

テストコードで時刻を扱うようなケースでは、単純にlocaltimeを用いると、実行タイミングによって取得できる時刻が異なるため、テストが通ったり失敗したりと再現性がない状態になってしまうことがあります。そういったときにはTest::MockTimeを用いることで、localtimeの挙動を書き換え、時間を固定してテストを実行できます。

use Test::More;
use Test::MockTime
    qw/set_absolute_time set_fixed_time/;

subtest " 時間指定" => sub {
    set_absolute_time(0);
    # set_absolute_time にsetした直後の時刻
    my $abs_time = time;
    sleep 1;
    # sleep から1 秒経過した直後の時刻
    is $abs_time + 1, time, '1 sec past';
};

subtest " 時間固定" => sub {
    set_fixed_time(0);
    # set_fixed_time にsetした時刻
    my $fixed_time = time;
    sleep 1;
    # set_fixed_time にsetした時刻
    is $fixed_time, time, 'fixed time';
};

done_testing;

このように、時間の経過や時間を固定したテストは、Test::MockTimeを活用することで比較的簡単に実装できます。

Plack::TestによるWebアプリケーションのテスト

Webアプリケーションのテストには、実際にプログラムをテストサーバにデプロイして行う方法、モックリクエストを使う方法など、さまざまな手法があります。近年ではPSGIとPlackの普及により、Webアプリケーションのプログラムとテストがより書きやすくなりました。アプリケーションがPlackベースの場合には、Plack::Testを用いると容易にテストを書くことができます。

use Plack::Test;
use Test::More;
use HTTP::Request;

subtest "WebAPP のテスト" => sub {
    my $app = sub {
        return [
            200,
            [ 'Content-Type', 'text/plain' ],
            [ "Hello" ]
        ];
    };

    test_psgi $app, sub {
        my $cb = shift;
        my $req = HTTP::Request->new(
            GET => 'http://localhost/'
        );
        my $res = $cb->($req);
        is $res->code, 200, '200 ok';
        is $res->content, 'Hello', 'body ok';
    };
};

done_testing;

上記の例はステータスコード200およびHelloが返されることを期待した処理です。

$appの部分が実際のWebアプリケーション実装になります。例では$appは単なるcoderefですが、実際には次に示すように、実装したアプリケーションのモジュールをテストする流れになります。

use Plack::Test;
use Test::More;
use HTTP::Request;
use MyAPP::Foo::Request;
use MyAPP::Foo::Logic;

my $app = sub {
    my $env = shift;
    $env->{HTTP_COOKIE} = "foo=var";
    my $req = MyApp::Foo::Request->new($env);
    my $logic = MyApp::Foo::Logic->new(req => $req);
    $logic->run;
};

subtest "MyAPP::Foo::Logic のレスポンステスト" => sub {
    test_psgi $app, sub {
        $cb = shift;
        my $res = $cb->(GET => '/path/to/app');

        is $res->code, 200;
        is $res->content, 'expected response';
    }
};

done_testing;

Test::mysqldによるMySQLを利用するテスト

MySQLを利用するテストの場合、Test::mysqldを用いると、テストごとにクリーンなMySQLを起動させることができます[1]⁠。

このテストで起動したMySQLは$mysqldの参照スコープを抜けた段階で自動的に終了処理が行われ、テストで使われたデータベースがシャットダウンされます。

use DBI;
use Test::More;
use Test::mysqld;

my $mysqld = Test::mysqld->new(
    my_cnf => {
        'skip-networking' => '',
    }
) or plan skip_all => $Test::mysqld::errstr;

subtest "dbh を使うテスト" => sub {
    my $dbh = DBI->connect(
            $mysqld->dsn(dbname => 'test'));

    isa_ok $dbh, 'DBI::db', 'dbh is ok';

    # 以降に$dbh を利用するテスト
};

done_testing;

1つのテーブルのみを相手にする場合は上記のような形でもそれほど問題はありませんが、通常のアプリケーションでは複数のテーブルを利用することもあるでしょう。個別にDDLData Definition Languageを書いているとテストのメンテナンスが大変になるため、そういったときはテストの共通処理モジュールを作ってそちらに委譲するか、Test::Fixture::DBIなどの支援モジュールを用いることでテスト用データを容易に取り込むことができます。Test::Fixture::DBIの使い方については、本誌ムック『Perl徹底攻略』の記事[2]を参照してください。

Test::Memcachedによるmemcachedを利用するテスト

Test::mysqld以外にも、ミドルウェアのテストモジュールはたくさん存在します。たとえばmemcachedであればTest::Memcachedを用いることで、テストごとにmemcachedを立ち上げることができます。

use Test::More;
use Test::Memcached;
use Cache::Memcached;

my $memd = Test::Memcached->new(
    options => { user => 'nobody' }
);
$memd->start;
my $port = $memd->option('tcp_port');

my $client = Cache::Memcached->new({
        servers => [ "127.0.0.1:$port" ]
    });

subtest "memcached を使うテスト" => sub {
    $client->set('key' => 'value');
    is $client->get('key'), 'value', 'value is ok';
};

$memd->stop;

done_testing;

memcached のほかにも、RedisであればTest::RedisServer など、さまざまなテストモジュールがCPANにリリースされているので、新しいミドルウェアを検討する際には探してみるとよいでしょう。

proveコマンドによるテストの実行と結果出力

ここまで、各種Perlアプリケーションにおけるテストの基本とその類型を紹介しました。本項では、それらのテストを実行する枠組みと結果出力について取り上げます。

テストが1つだけならば、そのモジュールをuseしてロジックを実行するPerlスクリプト1つあれば事足ります。しかしながら、開発していく中でテストファイルの数が増えていくのは必然です。そういったときにはproveコマンドを用いると、複数のテストをまとめて実行し、TAPTest Anything Protocol形式で結果を出力してくれます。

次の例ではtest_dir以下にあるテストスクリプトを実行し、結果を出力します。

$ prove -r /path/to/test_dir/
/path/to/test_dir/test1.t ................. ok
/path/to/test_dir/test2.t ................. ok
/path/to/test_dir/test3.t ................. ok
All tests successful.
Files=3, Tests=10, 9 wallclock secs ( 0.07 usr 0.02 sys +
5.94 cusr 0.62 csys = 6.65 CPU)
Result: PASS

このコマンドは裏でTAP::Harnessというモジュールを利用しています。

TAP::Harnessのしくみ

Perlのテスト実行を担うモジュールであるTAP::Harnessについて説明します。

TAP::HarnessはPerlのテストフレームワークで、proveコマンドで実際にテストを実行するモジュールです。このモジュールが持つruntestsメソッドにテストファイルの名前を渡すと、そのテストを実行します。

use TAP::Harness;

my $harness = TAP::Harness->new({
    verbosity => 1,
    lib => [ '/path/to/test_dir/lib', '/path/to/extlib' ],
});

my @tests = (
    '/path/to/test_dir/test1.t',
    '/path/to/test_dir/test2.t',
    '/path/to/test_dir/test3.t',
);
$harness->runtests(shuffle @tests);

このような形でPerlのテストを実行すると、先のproveコマンドと同様に、標準出力にテスト結果がTAP形式で出力されます。

Failed 0 / 30.
==========
/path/to/test_dir/test1.t .............................
/path/to/test_dir/test2.t ................................
/path/to/test_dir/test3.t ................................
Files=3, Tests=10, 9 wallclock secs ( 0.07 usr 0.02 sys +
5.94 cusr 0.62 csys = 6.65 CPU)
Result: PASS

このTAP::Harnessは後述する高速なCIクラスタの構築における中核モジュールとなるので、覚えておいてください。

<続きの(2)こちら。>

おすすめ記事

記事・ニュース一覧