HspecでQuickCheckするときもshouldBeなどが使えます

quickcheck-ioパッケージのおかげ

Posted by Yuji Yamamoto(@igrep) on February 27, 2020

タイトルがほとんどすべてなんですが詳細を解説します。

Link to
here
📣shouldBeなどはpropertyの中でも使えるので使ってください!

みなさんはHspecQuickCheckを使ったproperty testを書く際、どのように書いているでしょうか?
例えばHspecのマニュアルのように、Hspecproperty testを組み込む例として、次のような例を挙げています。

こちらのコミットの時点での話です。

property関数に渡した関数(以下、「porpertyブロック」と呼びます)の中ではHspecでおなじみのshouldBeなどのexpectation用関数を使わず、==で結果を判定してますよね。
このサンプルに倣って、Hspecで書いたテストにproperty testを書くときは、==を使ってる方が多いんじゃないでしょうか?

ところが、この記事のタイトルに書いたとおり、実際のところpropertyブロックの中でもshouldBeは利用できます。
つまりは、こちら👇のようにも書ける、ということです!

このようにpropertyブロックの中でもshouldBeshouldSatisfyといった、Hspec固有のexpectation関数を使うことの利点は、単に構文を他のテストと一貫させることができる、だけではありません。
テストが失敗したときのエラーが分かりやすくなる、という遥かに重大なメリットがあるのです。

試しにわざとテストを失敗させてみましょう。
先ほどの例:

における(x :: Int)という式を(x + 1 :: Int)に変えれば、必ず失敗するはずです。

※お手元で試す場合はこちらから元のコードを持ってきて、stack build hspecなりを実行した上で修正・実行するのが簡単でしょう。

結果、下記のようなエラーメッセージとなるでしょう。

...
  1) read, when used with ints, is inverse to show
       Falsifiable (after 1 test):
         0

このエラーでは「テストが失敗したこと」と「どんな入力をQuickCheckが生成したか」までしか教えてくれず、わかりづらいですよね。

一方、shouldBeを使用して以下のように書き換えると…

エラーメッセージはこう👇なります。

  1) read, when used with ints, is inverse to show
       Falsifiable (after 1 test):
         0
       expected: 1
        but got: 0

「テストが失敗したこと」と「どんな入力をQuickCheckが生成したか」に加えて、shouldBeに与えた両辺の式がどのような値を返したか、まで教えてくれました!
今回の例は極めて単純なのであまり役に立たないかも知れませんが、あなたが書いた関数をテストするときはやっぱり「期待される結果」と「実際の結果」両方がわかる方がデバッグしやすいですよね!

と、いうわけで今後はproperty関数(あるいはその省略版のprop関数)に渡した関数の中でもshouldBeなどを必ず使ってください!
(せっかくなんで、今回紹介したドキュメントを修正するためのPull requestを送っておきました。これがマージされればこの記事の情報の大半は時代遅れになります)

Link to
here
😕なぜ使える?

しかしここで、一つ疑問が残ります。
QuickCheckHspecのドキュメントをつぶさに読んだことがある方はお気づきでしょう。
QuickCheckproperty関数は、Testableという型クラスのメソッドであるため、Testableのインスタンスでなければ使えないはずです。
HspecshouldBeなどが返す値は型シノニムのたらい回しをたどればわかるとおり、結局のところIO ()型の値です。
ところがTestableのインスタンス一覧を見る限り、IO aTestableのインスタンスではありません。
先ほどの例のように

と書いた場合における、関数型(a -> prop)のインスタンスは、(Arbitrary a, Show a, Testable prop) => Testable (a -> prop)という定義のとおり、関数の戻り値の型がTestableのインスタンスでないと、型チェックを通らないはずです。
Testableのインスタンスでない、IO ()を返しているにも関わらず型エラーが起きなかったのは、一体なぜでしょうか?

その秘密を探るべく、GHCiを立ち上げましょう。
先ほどの例のソースコードをghciコマンドに読ませれば、まとめてHspecのモジュールもimportできるので簡単です。

GHCiが起動したら、:i Testableと入力して、Testable型クラスのインスタンス一覧を出力しましょう。

ありました!💡 最後の方にあるinstance [safe] Testable Test.HUnit.Lang.Assertionという行に注目してください。
Test.HUnit.Lang.Assertionは、IO ()の型シノニムであり、Hspecでも間接的に型シノニムとして参照されています1
要するにinstance [safe] Testable Test.HUnit.Lang.Assertionという行はinstance [safe] Testable (IO ())と読み替えることができます[safe]という表記が指しているものについてはここでは省略します!すみません!)

紹介したとおりTestableのドキュメントにはTestable Assertionなんて記載はありませんし、じゃあ一体どこで定義したのか、というとそう、続く行に-- Defined in ‘Test.QuickCheck.IO’と書かれているとおり、Test.QuickCheck.IOというモジュールで定義されています!

Test.QuickCheck.IOは、名前のとおりQuickCheckTestableについて、IOorphan instanceを定義するためのモジュールです。
これをimportしているが故に、Hspecではpropertyブロックの中でshouldBeなどが利用できるんですね!

結論:

  • orphan instanceわかりづらい😥
  • GHCi:iorphan instanceであろうとインスタンスを定義した箇所を見つけてくれるから便利!

  1. この節の冒頭で「型シノニムのたらい回し」と呼んだものを追いかけてみましょう。
    おなじみshouldBeExpectationという型の値を返します。
    そしてExpectationAssertionの型シノニムであり、クリックするとTest.HUnit.Lang.Assertionであることがわかります。
    そしてAssertionはそう、type Assertion = IO ()とあるとおりIO ()なのです。やっと知ってる型にたどり着きました😌。