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

この記事は[Perl Advent Calendar 2017](https://qiita.com/advent-calendar/2017/perl)の8日目の記事です。

昨日は @masakyst さんで[Carmelとcpm](https://qiita.com/masakyst/items/8976616df1e585e42027)でした。cpm使っております！　Carmelも今度使ってみよっと。

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

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

## Test::MasterData::Declareとは

[Test::MasteData::Declare - metacpan](https://metacpan.org/pod/Test::MasterData::Declare)

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

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

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

提供する体験としては、

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

と言ったところです。

## Test::MasterData::Declareの使い方

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

```shell-session
$ cpanm Test::MasterData::Declare
```

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

```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](https://github.com/isucon/isucon7-final)

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

```perl: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::Declare`の`table`関数からは`item`という名前でcsvを参照することが出来ます。

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

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

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

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

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

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

};
```

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

`mismatch`は[Test2::V0](https://metacpan.org/pod/Test2::V0)を`use`して使用することができる関数です。この関数は[Test2::Compare::Regex](https://metacpan.org/pod/Test2::Compare::Regex)を返します。`Test::MasterData::Declare`は`Test2::Suite`をベースに作られているため、Test2のCompareオブジェクトをそのまま使うことが出来ます。

これを`prove`で実行します。

```shell-session
$ 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`に入っていないので成功します。

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

```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
```

```shell-session
$ 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](https://metacpan.org/pod/Test2::Compare::Delta)の機能です。`Test::MasterData::Declare`では工夫をして、`PATH`にファイルとIDとカラム、`LNs`にCSVファイルでの行番号を表示しています。ここめっちゃ頑張りました。便利でしょ？？？？

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

## Test2について

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

* [Test2::Suiteを使ってテストを書く](http://kichijojipm.hatenablog.com/entry/2017/04/08/234936)
* [YAPC::Fukuokaで「新時代のテストフレームワークTest2」という話をしました](http://akiym.hateblo.jp/entry/2017/07/09/214116)

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

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

一般的には`Test2::V0`を`use`すれば色々使えて便利です。

### Test2::Compare::**

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

この変換ロジックは[この辺](https://metacpan.org/source/Test2::Compare)です。

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

これをそのまま`Test2::V0`の`hash`で、

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

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

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">Test2調べてる</p>&mdash; トーカナイザの守護霊 (@mackee_w) <a href="https://twitter.com/mackee_w/status/913726661408940032?ref_src=twsrc%5Etfw">2017年9月29日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">Test2わかってきた</p>&mdash; トーカナイザの守護霊 (@mackee_w) <a href="https://twitter.com/mackee_w/status/930394119293362177?ref_src=twsrc%5Etfw">2017年11月14日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">今日だけでTest2わかる/わからないを10回ぐらいいったりきたりしてる</p>&mdash; トーカナイザの守護霊 (@mackee_w) <a href="https://twitter.com/mackee_w/status/930394517701738497?ref_src=twsrc%5Etfw">2017年11月14日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">噛めば噛むほど困難が立ち向かってくるTest2</p>&mdash; トーカナイザの守護霊 (@mackee_w) <a href="https://twitter.com/mackee_w/status/931499470755323906?ref_src=twsrc%5Etfw">2017年11月17日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

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

## まとめ

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

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