• このエントリーをはてなブックマークに追加


はじめに

新しく Web アプリケーション的なものを作る際のデータベースを経由するテストの方法あたりで(以前からもそうだけど,特に)昨日 1日悩んだり試行錯誤で書いたものなどを.

要件

やりたいこと

  • 「このテーブルのデータはこのパタン,あのテーブルのデータはあのパタン」な感じの組み合わせで fixture を構成したい
  • fixture はそれなりに可読に,場合によっては直接修正可能に
  • prove 実行の際に読み込み!とかではなく,テストファイル単位とかでも実行できたい
  • データベース上のデータをクリアしたい(AUTO_INCREMENT もリセット)
  • DBIx::FixtureLoader が便利そうなので利用する(fixture のファイルフォーマットで悩まずに済むし)

fixture ファイルの構成

  • テーブルごと
  • テーブルごとに複数のデータパタンに相当するファイルがある

で, 次のような感じに:

/path/to/myapp/t/fixture
├── event
│   └── 0.yml
└── user
    ├── 0.yml
    └── 1.yml

インタフェイス

とりあえず名前を Test::FixtureManager とかにして,次のような感じで使いたい:

# set env name
local $Test::FixtureManager::ENV_NAME = 'MYAPP_ENV';  # default: PLACK_ENV
 
# new ( dies when $ENV{MYAPP_ENV} is not "test" )
my $dbh = DBI->connect( ... );
my $fm = Test::FixtureManager->new(
    dbh      => $dbh,
    data_dir => '/path/to/myapp/t/fixture',
);
 
# テーブル "user" の,パタン "0" を読み込む
$fm->load( data => 'user:0' );
 
# データ全消し
$fm->clear_all();
# or テーブル "user" のデータを消す
$fm->clear( table => 'user' );
 
# テーブル "user" のパタン "1", テーブル "event" のパタン "0" を読み込む
$fm->load( data => [qw( user:1 event:0 )] );
 
# テーブル "user" "event" のデータを消す
$fm->clear( table => [qw( user event )] );
 
# データを消した後,テーブル "user" のパタン "1", テーブル "event" のパタン "0" を読み込む
$fm->reload( data => [qw( user:1 event:0 )] );

fixture をどうこうしたいテストの冒頭とかでこういったことができればよさげ.

モジュールのプロトタイプ

上記要件とかインタフェイスとかを基にプロトタイプ.

今のところ MySQL のみしか考えていないので SQL 直書きです><

package Test::FixtureManager::Types;
use 5.12.0;
use warnings;
use MouseX::Types -declare => [qw( TableVersionToken )];
use MouseX::Types::Mouse qw( Str );
 
subtype TableVersionToken, as Str, where { /^[^:]+:[^:]+$/ };
 
1;
 
package Test::FixtureManager;
use strict;
use warnings;
use Mouse;
use DBIx::FixtureLoader;
use Data::Validator;
 
use MouseX::Types::Mouse qw(
    Bool Str ArrayRef
    is_ArrayRef
);
 
use constant +{
    TableVersionToken => Test::FixtureManager::Types::TableVersionToken(),
};
 
our $ENV_NAME = 'PLACK_ENV';
 
has 'dbh' => ( is => 'ro', isa => 'DBI::db' );
has 'loader' => ( is => 'ro', isa => 'DBIx::FixtureLoader' );
has 'data_dir' => ( is => 'ro', isa => Str );
 
sub BUILDARGS {
    my ($class, %args) = @_;
    my $env = $ENV{$ENV_NAME} || '';
    unless ( $env eq 'test' ) {
        die qq{available only in environment "${ENV_NAME}=test"};
    }
 
    my $v = Data::Validator->new(
        dbh      => { isa => 'DBI::db' },
        data_dir => { isa => Str },
    );
    %args = %{ $v->validate( \%args ) };
 
    my $loader = DBIx::FixtureLoader->new( dbh => $args{dbh} );
 
    %args = (
        %args,
        loader => $loader,
    );
 
    return \%args;
}
 
sub clear {
    my ($self, %args) = @_;
    my $v = Data::Validator->new(
        table => { isa => Str|ArrayRef[Str] },
    );
    %args = %{ $v->validate( \%args ) };
 
    my @table = is_ArrayRef( $args{table} ) ? @{$args{table}} : ( $args{table} );
    my $dbh = $self->dbh;
    for my $table ( @table ) {
        my $sth = $dbh->prepare( "SHOW CREATE TABLE ${table}" );
        $sth->execute();
        my (undef, $sql_create) = $sth->fetchrow_array();
        $dbh->do( "DROP TABLE ${table}" );
        $dbh->do( $sql_create );
    }
 
    return @table;
}
 
sub clear_all {
    my ($self, %args) = @_;
    my $v = Data::Validator->new();
    %args = %{ $v->validate( \%args ) };
 
    my @table;
 
    my $sth = $self->dbh->prepare( "SHOW TABLES" );
    $sth->execute();
    while ( my @row = $sth->fetchrow_array() ) {
        push @table, $row[0];
    }
 
    return $self->clear( table => \@table );
}
 
sub load {
    my ($self, %args) = @_;
    my $v = Data::Validator->new(
        data   => { isa => TableVersionToken|ArrayRef[TableVersionToken] },
        _clear => { isa => Bool, default => 0 },  # for internal use
    );
    %args = %{ $v->validate( \%args ) };
 
    my @table;
    my @data = is_ArrayRef( $args{data} ) ? @{$args{data}} : ( $args{data} );
    @data = map {
        my ($table, $ver) = split /:/, $_;
        my $g = sprintf '%s/%s/%s.*', $self->data_dir, $table, $ver;
        my ($f) = glob $g;
        unless ( defined $f ) {
            die "$f: fixture data file does not exist";
        }
        +{ file => $f, table => $table, version => $ver };
    } @data;
 
    if ( $args{_clear} ) {
        $self->clear_all();
    }
 
    for my $data ( @data ) {
        my ($file, $table) = @$data{qw(file table)};
        $self->loader->load_fixture( $file, table => $table );
        push @table, $table;
    }
 
    return @table;
}
 
sub reload {
    my ($self, %args) = @_;
    return $self->load( _clear => 1, %args );
}
 
__PACKAGE__->meta->make_immutable();

おわりに

とりあえず GitHub に上げるところからですかね.

このコードがもうちょっとプロジェクトローカルな感じに書き換わったものは,そのテスト用データベースを利用することで最低限テストできているけど,モジュールとして切り出した場合,データベース test を利用するにしても ->clear_all() とかやばいな,どうしようw

そのテスト独自かつ他で使いまわすこともないようなデータパタンであれば,そのテストファイルの __DATA__ セクションに記述するっていうのもよいかもしれない.