マスタデータを宣言的かつ高速にテストするTest::MasterData::DeclareとTest2の構造について

この記事はPerl Advent Calendar 2017の8日目の記事です。

昨日は @masakyst さんでCarmelとcpmでした。cpm使っております! Carmelも今度使ってみよっと。

はい、どうも、Perlは古臭いなんて言わせないの会の会員のマコピーです。

宣伝

12月末に発売されるWEB+DB Press vol.102のPerl Hackers Hubを担当させていただきました。内容はゲーム開発の話なんですが、その過程で生まれたモジュールであるTest::MasterData::Declareについてここで紹介させていただきます。

Test::MasterData::Declareとは

Test::MasteData::Declare - metacpan

Test::MasterData::DeclareはRDBMSや表計算ソフトのような行指向データ構造(と言いつつ現在はCSVのみですが)のテストを行うモジュールです。ここでいうテストは何かというと、

  • カラムの値の型と値域など、値自体のバリデーション
  • 複数のテーブル(RDBMSで言うテーブルで、CSVで言う別々の構造を持ったCSVファイル)間のリレーションの整合性

などを行えるツールです。って言ってもまだ開発途上で目標のすべてを達成できているわけではありません。

提供する体験としては、

  • 複雑な構造を持った行指向データの妥当性のチェック
  • DBを介さずに高速にデータのテストを行える
  • どの行でfailしたかをCSVファイルの行数で見ることが出来る

と言ったところです。

Test::MasterData::Declareの使い方

とりあえずcpanmしましょう。

$ cpanm Test::MasterData::Declare

あとテストするCSVファイルを用意します。

item.csv
id,name,power1,power2,power3,power4,price1,price2,price3,price4
1,"おじいちゃん",0,1,0,1,0,1,1,1
2,"椅子畑",0,1,1,1,0,1,2,1
3,"椅子採掘場",1,10,0,2,1,3,1,2
4,"椅子工場",1,24,1,2,1,10,0,3
5,"禁断の秘術†椅子†",1,25,100,3,2,20,20,2
6,"確定拠出椅子",1,30,147,13,1,22,69,17

元ネタ: isucon/isucon7-final

これをテストファイルから読み込みます。

t/item.t
use Test2::V0;
use Test::MasterData::Declare;

master_data {
    load_csv item => "item.csv";
    ...
};

done_testing;

master_dataというブロック内でload_csvでこのように読み込むと、Test::MasterData::Declaretable関数からはitemという名前でcsvを参照することが出来ます。

ここでは、このマスタデータを入れるRDBMSはMySQLで、nameカラムはutf8mb4ではなくutf8のカラムだと仮定してテストを書いてみます。つまり、utf8で4バイトで表現される文字(絵文字など)を検知するテストです。

4バイトのutf8文字列を引っ掛ける正規表現は

[\x{10000}-\x{10ffff}]

で表すことが出来るため、

t/item.t
master_data {
    load_csv item => "item.csv";

    table item => "name",
        mismatch(qr![\x{10000}-\x{10ffff}]!);

};

と書けばテストでfailさせることが出来ます。

mismatchTest2::V0useして使用することができる関数です。この関数はTest2::Compare::Regexを返します。Test::MasterData::DeclareTest2::Suiteをベースに作られているため、Test2のCompareオブジェクトをそのまま使うことが出来ます。

これをproveで実行します。

$ prove -l t/item.t
All tests successful.
Files=1, Tests=1,  0 wallclock secs ( 0.02 usr  0.01 sys +  0.09 cusr  0.01 csys =  0.13 CPU)
Result: PASS

そうですね、絵文字がnameに入っていないので成功します。

ここでマスタデータに絵文字を入れてみましょう。

item.csv
id,name,power1,power2,power3,power4,price1,price2,price3,price4
1,"おじいちゃん",0,1,0,1,0,1,1,1
2,"🌾椅子畑🌾",0,1,1,1,0,1,2,1
3,"椅子採掘場",1,10,0,2,1,3,1,2
4,"椅子工場🏭",1,24,1,2,1,10,0,3
5,"禁断の秘術†椅子†",1,25,100,3,2,20,20,2
6,"確定拠出椅子",1,30,147,13,1,22,69,17
$ prove -l t/item.t
t/item.t .. 1/?
# Failed test at t/item.t line 11.
# +-----------------------+-----------+----+-----------------------+-----+
# | PATH                  | GOT       | OP | CHECK                 | LNs |
# +-----------------------+-----------+----+-----------------------+-----+
# | {item.csv#id=2}{name} | 🌾椅子畑🌾  | !~ | (?^u:[\x{10000}-\x{10 | 3   |
# |                       |           |    | ffff}])               |     |
# |                       |           |    |                       |     |
# | {item.csv#id=4}{name} | 椅子工場🏭 | !~ | (?^u:[\x{10000}-\x{10 | 5   |
# |                       |           |    | ffff}])               |     |
# +-----------------------+-----------+----+-----------------------+-----+
# Seeded srand with seed '20171208' from local date.
t/item.t .. Dubious, test returned 1 (wstat 256, 0x100)
Failed 1/1 subtests

Test Summary Report
-------------------
t/item.t (Wstat: 256 Tests: 1 Failed: 1)
  Failed test:  1
  Non-zero exit status: 1
Files=1, Tests=1,  0 wallclock secs ( 0.01 usr  0.01 sys +  0.12 cusr  0.03 csys =  0.17 CPU)
Result: FAIL

こんな感じで表がべろべろっとでてきます。これはTest2::Compare::Deltaの機能です。Test::MasterData::Declareでは工夫をして、PATHにファイルとIDとカラム、LNsにCSVファイルでの行番号を表示しています。ここめっちゃ頑張りました。便利でしょ????

他にも色々機能はあるんですが、まだまだバギーなところがあるので、紹介はこの程度にしておきます。

Test2について

Test2は、従来はTest::Builderで行っていたテストの実行や集計などを全面的に書き直したモジュールです。
この説明については、

が詳しいです。簡単に言うと前と比べて、テスト自体の拡張がしやすくなったのと、テストモジュールのテストも書きやすくなったということです。Test::MasterData::Declareは前者の特徴を利用しています。

Test2はTest::SimpleとTest2::Suiteのそれぞれに実装がされています。Test::Simpleのほうではテストの実行と集計を司り、Test2::Suiteは拡張しやすくなったTest2を用いて豊富なカスタムマッチャーの実装がされています。従来のTest::Deep::Matcherの置き換えになるわけですね。

一般的にはTest2::V0useすれば色々使えて便利です。

Test2::Compare::**

Test2::V0useした状態でlike $got, $checkとした場合、$checkが、Test2::Compare::**になります。Test2::Compare::**が持っているrunというメソッドに$got`を放り込むことで検査が行われます。

この変換ロジックはこの辺です。

Test::MasterData::Declareでは、$gotに当たる部分を、CSVの各行をHashにしたものを、Test::MasterData::Declare::Rowという独自クラスに変換しています。このクラスにはCSVでいう行番号やカラムのinflateの際に使うjsonメソッドなどが実装されています。

これをそのままTest2::V0hashで、

like $rows, hash {
    field "name" => ...;
};

とすることは出来ないので、この$checkの方にも、独自のCompareクラスを使っています。それがTest::MasterData::Declare::RowHashです。Test::MasterData::DeclareTest2::V0#hashで作られるTest2::Compare::Hashを継承していますが、検査をする時に$gotからrowというメソッドで生ハッシュを取り出して本来の検査メソッドに渡しています。で、結果が返ってくると、これはTest2::Compare::Deltaというオブジェクトになっていて、これに結果を表示するための差分が入っているのですが、ここで行番号とPATHを$gotをもとに書き換えています。ここまでしてやっと表示があそこまで便利になるのです。これを編み出すのにめっちゃ苦労しました。

あとTest2::Suiteで使われているTest2::Util::HashBaseっていうクラスビルダーがわりかしキモいです。なんなんだあのプロパティアクセスの仕方は……。。。

まとめ

  • Test::MasterData::Declareで宣言的にマスタデータのテストを書いて、テストの民主化をするぞ!!!!
  • Test2を使えば便利テストモジュールが(比較的)簡単に書けます!

というわけで、みんなもテストモジュール書こうぜ。明日は @ytnobody さんで「コアモジュールに寄せてみよう」です。なるほど、ワクワクしそうなタイトルです。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.