Perl Hackers Hub

第48回Perlでの今風のゲームサーバ開発とテスト(2)

前回の(1)こちらから。

Test::mysqldを使ったゲームサーバのテスト

多くのWebサービスや、ソーシャルゲームアプリのサーバ内のデータストアに用いられているMySQLですが、MySQLを使う機能を含んだアプリケーションのテストにはいくつか手法があります。

代表的な手法は、直接MySQLを使うアプリケーション内のレイヤと、そのレイヤを使う別のレイヤとの関係を、依存性注入などの手法により疎結合にする方法です。テストではMySQLを直接使うのではなく、モック化されたレイヤに入れ替えます。

ただ、この手法は、筆者の担当しているサービスではMySQLを使うレイヤを分離するのが難しく、採用できませんでした。

そこで使っているのが、テスト専用のmysqldです。テスト専用のmysqldを使うことで、テストを実行する環境に依存せず、テストの並列実行も可能になります。

Test::mysqldでテスト専用のmysqldを起動する

Test::mysqldは、コード中でmysqldを起動し、管理をしてくれるモジュールです。Test::mysqldを用いてmysqldを起動するテストコードを次に示します。

use Test::mysqld;
use DBI;
use MyApp::GameLogic;

my $mysqld = Test::mysqld->new(); (1)
my $dbh = DBI->connect($mysqld->dsn(dbname => 'test')); (2)
$dbh->do($ddl_statements); (3)
my $game_logic = MyApp::GameLogic->new(dbh => $dbh); (4)
ok $game_logic->do_something;

(1)で、まずTest::mysqldでmysqldを起動します。そのあとに、(2)で起動したmysqldのDSNData Source Nameを用いて$dbhを作ります。ここで起動したmysqldにはデータベースtestが存在しますが、その中のテーブル定義は空です。ですので(3)のように、CREATE文などのDDLData Definition Languageを実行します。

DDLを実行したあとに、(4)のように$dbhをMySQLが使われるモデルオブジェクトに渡すと、テスト中では起動したmysqldが使われます。

Test::mysqldを使うと、実行される環境にあらかじめmysqldを立てておく手間がなくなります。そして、すでに立っているmysqldの状態に依存しなくなります。常に初期化されたmysqldが使われることで、テストの結果が揺れるなどの、mysqldを使ったテストでありがちな問題が起こりません。また、独自のmysqldを起動することで、テスト向けにチューニングされた設定を常に使えます。

Test::mysqldの注意点

テスト専用のmysqldを起動するときに気を付けるべき点もいくつかあります。

mysqldを起動するためのリソースを余分に消費する

ローカルにある開発用のmysqldをテスト中でも用いる一般的な手法に比べ、専用のmysqldを起動する手法では、mysqldが2つ以上立つことになり、テスト実行マシン上のCPUやメモリ、ディスクなどを余計に消費します。テストを並列実行しないのであれば問題になることはありませんが、並列実行の際に1テストワーカあたり1つのmysqldを割り当てる実装にすると、大きな問題になります。

この問題に対処する最も良い方法は、フルテストでは良いスペックのCIContinuous Integration継続的インテグレーション)サーバを用いることです。また、1つのmysqldに1つのデータベースを収容するのではなく、複数のデータベースを受け持って、起動するmysqldの数を抑えることも考えられます。

テストが始まるまでの起動時間が長くなる

Test::mysqldを使うと、テーブルやレコードが入っていない空のmysqldが起動します。テストに用いるためには、CREATE文の流し込みや、マスタデータやテストデータのローディングが必要となります。

筆者の担当しているサービスのテーブル数は多く、DDLの実行だけでも多くの時間を使います。また、マスタデータも大量にあるため、マスタデータの入力もテスト実行時のオーバーヘッドとなります。

複数のテストを実行するときにも問題が起こります。テストファイルの中でmysqldを起動していると、mysqldはテストファイルの実行が終わったあとすぐに終了します。そして、ほかのテストファイルを実行する際にもまたmysqldを起動する作業が発生します。このままではテストコード実行よりも、テストを実行するためにmysqldを準備する時間のほうが多くなります。

Test::mysqldの起動高速化のためにデータを流用する

mysqldの起動時間のせいで、テストの実行時間が長くなってしまう問題を解決する方法を考えてみます。

Test::mysqldによるmysqldの起動には時間がかかります。その大半が、一時ディレクトリにibdataなどのデータ領域を含んだディレクトリを作成することに費やされています。このディレクトリをテスト起動時に毎回作らないようにします。あらかじめ作成しておき、できるだけ再利用して高速化を図ります。

そのために、Test::mysqldにはcopy_data_fromオプションが存在します。これは、あらかじめ作成したMySQLのデータディレクトリを保存しておき、オプションにディレクトリパスを指定することで、mysqldの起動時にコピーして使用する機能です。

以下に、データディレクトリを作るスクリプトと、それを使う例を示します。

use Test::mysqld;
use File::Spec;

my $mysqld = Test::mysqld->new;
my $dbh = DBI->connect($mysqld->dsn);

$dbh->do($_) for @ddls;
$dbh->do($_) for @fixture_sqls;

$mysqld->stop;

say 'export TEST_MYSQLD_DATA_DIR='
    . File::Spec->catdir($mysqld->data_dir, 'var');

このスクリプトを実行して表示された環境変数を作るコマンドを実行して、テストを実行します。

テストコードでの例は次のようになります。

my $mysqld = Test::mysqld->new(
    copy_data_from => $ENV{TEST_MYSQLD_DATA_DIR}
);

これで高速にmysqldが起動し、すぐにテストが実行されます。

App::Prove::Plugin::MySQLPoolによるプーリング

mysqldの起動時間を短くする方法を挙げましたが、複数のテストファイルを実行する際に、mysqldプロセスを都度立てずに再利用することを考えてみます。筆者がメンテナーをしているApp::Prove::Plugin::MySQLPoolは、ワーカごとにmysqldを流用する機能を提供するprove向けのプラグインモジュールです。

使用するには、proveの起動時に指定します。

$ prove -PMySQLPool t/something.t

テストファイルの中では、環境変数に記述されたDSNを用いてMySQLに接続します。

my $dbh = DBI->connect($ENV{PERL_TEST_MYSQLPOOL_DSN});

テストファイルごとにmysqldが起動せず、ワーカごとに再利用できます。

mysqldのプーリングを行う際の注意点

mysqldプロセスを再利用することに関して注意点があります。それは、mysqldに保存されるテーブルやレコードも、前のテストファイルが使用したまま残っていることです。

テーブルは流用できるので、むしろ残っていたほうがテスト実行の高速化に役立つと言えますが、レコードはそうではありません。テストファイルの実行順によっては、前のテストファイルが残したレコードによってfailすることが考えられます。

そのため、テストファイル全体を単一のトランザクション内とし、テスト終了時にロールバックしてレコードの保存を避ける手法がありますが、筆者はお勧めしません。テストを行うアプリケーション内でもトランザクションが実行されており、衝突するからです。

あるいは、入れ子になったトランザクションをPerlプログラム側で実現するCPANモジュールであるDBIx::TransactionManagerを用いれば、トランザクションが衝突せずに解決できたように見えます。しかし、今度はトランザクション外でのSQL実行やトランザクションを分離したテストが実行できなくなります。

プーリングされたmysqldの初期化

そこで提案したいのが、1つのテストが終了したときに、すべてのテーブルを初期化する手法です。

以下は、レコード内のテーブルを列挙するDBIx::Inspectorを使用して、すべてのテーブルを初期化する例です。

use DBIx::Inspector;

my $inspector = DBIx::Inspector->new(dbh => $dbh);
my @tables = $inspector->tables;
for my $table (@tables) {
    my $table_name = $table->name;
    $dbh->do("TRUNCATE TABLE $table_name")
        or die $dbh->errstr;
}

トランザクションを実行してロールバックするのに比べて時間はかかりますが、テーブル内のデータを確実に削除して、実行環境をそろえることができます。

Harrietを用いてプーリングを行う

App::Prove::Plugin::MySQLPoolは簡単にmysqldプロセスを再利用できるモジュールですが、デメリットがいくつかあります。copy_data_fromなどのオプションには対応していない点、mysqldの起動とテストの実行を切り離せない点などです。

mysqldをプーリングするには、Harrietを使う方法もあります。HarrietApp::Prove::Plugin::MySQLPoolと同様、proveのプラグインとして動作するモジュールです。ただし、MySQLに特化しているのではなく、テストの前に必要なミドルウェア全般に用いることができます。

mysqld専用であるApp::Prove::Plugin::MySQLPoolと比べて、汎用的なHarrietでは余計にコードを書く必要がありますが、mysqldの使われ方を柔軟にコントロールできます。また、テスト起動時に常に立ち上げて、テストが終わると終了する使い方以外にも、あらかじめmysqldを立てておき、テストではそれを用いる使い方もできます。CIサーバではなく、ローカル開発時にテストを実行する際に、このやり方は効果を発揮します。

Harrietの使用例

使用するには、t/harriet/mysqld.plに次のコードを書きます。

use Harriet;
use Test::mysqld;

$ENV{TEST_MYSQLD_DSN} ||= do {
    my $mysqld = Test::mysqld->new(
        # 前述したスクリプトで作った
        # 事前作成ディレクトリを指定できる
        copy_data_from => ...,
    );
    $HARRIET_GUARDS::TEST_MYSQLD = $mysqld;
    $mysqld->dsn;
};

そして、テスト実行時にプラグインを指定します。

$ prove -PHarriet t/something.t

テストの中では、App::Prove::Plugin::MySQLPoolのときと同様、環境変数TEST_MYSQLD_DSNにDSNが入ります。これを用いて$dbhを作ればMySQLにつなぐことができます。

Harrietを使用した場合の注意点と解決策

ただし、これでは並列実行した際に1つのmysqldにつなぐ状態です。この問題を解決するには、

  • mysqldをt/harriet/mysqld.plの中で複数個立てておき、ワーカごとに分散させる
  • 1つのmysqldの中に複数個のデータベースを用意して、ワーカごとに使う

などの手法が考えられます。

Test::mysqldには複数個のmysqldを一気に起動するstart_mysqlsメソッドがあるため、mysqldの複数起動は容易です。

ただ、⁠ワーカごとに分散させる」が難点です。手法としては、次のものが考えられます。

  • pidで使うmysqldやデータベースを分ける
  • 何らかのデータストアに使用されているmysqldやデータベースを保存しておき、テストファイル起動時に未使用のデータベースを取得する
  • proveではない並列実行を行う独自のテストコマンドを用いて、ジョブワーカ起動時にワーカ番号を環境変数で渡し、それによって使用するmysqldやデータベースを決定する

いずれもメリット/デメリットがありますが、筆者は3番目の手法を、proveを並列に走らせて結果を統合するコマンドであるgo-proveを用いて実現しています。

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

WEB+DB PRESS

本誌最新号をチェック!
WEB+DB PRESS Vol.130

2022年8月24日発売
B5判/168ページ
定価1,628円
(本体1,480円+税10%)
ISBN978-4-297-13000-8

  • 特集1
    イミュータブルデータモデルで始める
    実践データモデリング

    業務の複雑さをシンプルに表現!
  • 特集2
    いまはじめるFlutter
    iOS/Android両対応アプリを開発してみよう
  • 特集3
    作って学ぶWeb3
    ブロックチェーン、スマートコントラクト、NFT

おすすめ記事

記事・ニュース一覧