テストの自動生成

この章の目標

この章では、テスティングの問題に対する、型クラスの特に洗練された応用について示します。 どのようにテストするのかをコンパイラに教えるのではなく、コードがどのような性質を持っているべきかを教えることでテストします。 型クラスを使って無作為データ生成のための紋切り型なコードを書かずして、テスト項目を仕様から無作為に生成できます。 これは生成的テスティング(generative testing、またはproperty-based testing)と呼ばれ、HaskellのQuickCheckライブラリによって普及した手法です。

quickcheckパッケージはHaskellのQuickCheckライブラリをPureScriptにポーティングしたもので、型や構文はもとのライブラリとほとんど同じようになっています。 quickcheckを使って簡単なライブラリをテストし、Spagoでテストスイートを自動化されたビルドに統合する方法を見ていきます。

プロジェクトの準備

この章のプロジェクトには依存関係として quickcheckが追加されます。

Spagoプロジェクトでは、テストソースは testディレクトリに置かれ、テストスイートのメインモジュールは Test.Mainと名づけられます。 テストスイートは、 spago testコマンドを使用して実行できます。

性質を書く

Mergeモジュールでは簡単な関数 mergeが実装されています。 これをquickcheckライブラリの機能を実演するために使っていきます。

merge :: Array Int -> Array Int -> Array Int

mergeは2つの整列された整数の配列を取って、結果が整列されるように要素を統合します。 例えば次のようになります。

> import Merge
> merge [1, 3, 5] [2, 4, 5]

[1, 2, 3, 4, 5, 5]

典型的なテストスイートでは、手作業でこのような小さなテスト項目を幾つも作成し、結果が正しい値と等しいことを確認することでテストを実施します。 しかし、merge関数について知る必要があるものは全て、この性質に要約できます。

  • xsysが整列済みなら、merge xs ysは両方の配列が一緒に結合されて整列された結果になります。

quickcheckでは、無作為なテスト項目を生成することで、直接この性質をテストできます。 コードが持つべき性質を関数として述べるだけです。 この場合は1つの性質があります。

main = do
  quickCheck \xs ys ->
    eq (merge (sort xs) (sort ys)) (sort $ xs <> ys)

このコードを実行すると、quickcheckは無作為な入力xsysを生成してこの関数に渡すことで、主張した性質を反証しようとします。 何らかの入力に対して関数がfalseを返した場合、性質は正しくなく、ライブラリはエラーを発生させます。 幸いなことに、次のように100個の無作為なテスト項目を生成しても、ライブラリはこの性質を反証できません。

$ spago test

Installation complete.
Build succeeded.
100/100 test(s) passed.
...
Tests succeeded.

もし merge関数に意図的にバグを混入した場合(例えば、大なりのチェックを小なりのチェックへと変更するなど)、最初に失敗したテスト項目の後で例外が実行時に投げられます。

Error: Test 1 failed:
Test returned false

見ての通りこのエラー文言ではあまり役に立ちませんが、少し工夫するだけで改良できます。

エラー文言の改善

テスト項目が失敗した時に同時にエラー文言を提供する上で、quickcheck<?>演算子を提供しています。 次のように性質の定義とエラー文言を<?>で区切って書くだけです。

quickCheck \xs ys ->
  let
    result = merge (sort xs) (sort ys)
    expected = sort $ xs <> ys
  in
    eq result expected <?> "Result:\n" <> show result <> "\nnot equal to expected:\n" <> show expected

このとき、もしバグを混入するようにコードを変更すると、最初のテスト項目が失敗したときに改良されたエラー文言が表示されます。

Error: Test 1 (seed 534161891) failed:
Result:
[-822215,-196136,-116841,618343,887447,-888285]
not equal to expected:
[-888285,-822215,-196136,-116841,618343,887447]

入力 xsが無作為に選ばれた数の配列として生成されていることに注目してください。

演習

  1. (簡単)配列に空の配列を統合しても元の配列は変更されないことを確かめる性質を書いてください。 補足:この新しい性質は冗長です。 というのもこの状況は既に既存の性質で押さえられているからです。 ここでは読者がQuickCheckを使う練習のための簡単なやり方を示そうとしているだけです。
  2. (簡単)mergeの残りの性質に対して、適切なエラー文言を追加してください。

多相的なコードのテスト

Mergeモジュールでは、数の配列だけでなく、 Ord型クラスに属するどんな型の配列に対しても動作する、 merge関数を一般化した mergePolyという関数が定義されています。

mergePoly :: forall a. Ord a => Array a -> Array a -> Array a

mergeの代わりに mergePolyを使うように元のテストを変更すると、次のようなエラー文言が表示されます。

No type class instance was found for

  Test.QuickCheck.Arbitrary.Arbitrary t0

The instance head contains unknown type variables.
Consider adding a type annotation.

このエラー文言は、配列に持たせたい要素の型が何なのかわからないので、コンパイラが無作為なテスト項目を生成できなかったということを示しています。 このような場合、型註釈を使ってコンパイラが特定の型を推論するように強制できます。 例えばArray Intなどです。

quickCheck \xs ys ->
  eq (mergePoly (sort xs) (sort ys) :: Array Int) (sort $ xs <> ys)

代替案として型を指定する補助関数を使うこともできます。 こうするとより見通しのよいコードになることがあります。 例えば同値関数の同義語として関数intsを定義したとしましょう。

ints :: Array Int -> Array Int
ints = id

それから、コンパイラが引数の2つの配列の型 Array Intを推論するように、テストを変更します。

quickCheck \xs ys ->
  eq (ints $ mergePoly (sort xs) (sort ys)) (sort $ xs <> ys)

ここで、ints関数が不明な型の曖昧さを解消するために使われているため、xsysは型Array Intを持っています。

演習

  1. (簡単)xsysの型をArray Booleanに強制する関数boolsを書き、mergePolyをその型でテストする性質を追加してください。
  2. (普通)標準関数から(例えばarraysパッケージから)1つ関数を選び、適切なエラー文言を含めてQuickCheckの性質を書いてください。 その性質は、補助関数を使って多相型引数を IntBooleanのどちらかに固定しなければいけません。

任意のデータの生成

それではquickcheckライブラリが性質に対するテスト項目をどのように無作為に生成できているのかを見ていきます。

無作為に値を生成できるような型は、次のような型クラス Arbitaryのインスタンスを持っています。

class Arbitrary t where
  arbitrary :: Gen t

Gen型構築子は決定的無作為データ生成の副作用を表しています。 決定的無作為データ生成は、擬似乱数生成器を使って、シード値から決定的無作為関数の引数を生成します。 Test.QuickCheck.Genモジュールは、生成器を構築するための幾つかの有用なコンビネータを定義しています。

Genはモナドでもアプリカティブ関手でもあるので、 Arbitary型クラスの新しいインスタンスを作成するのに、いつも使っているようなコンビネータを自由に使うことができます。

例えば、quickcheckライブラリで提供されているInt型用のArbitraryインスタンスを使い、256個のバイト値上の分布を作れます。 これにはGen用のFunctorインスタンスを使い、整数からバイトへの関数を任意の整数値に写します。

newtype Byte = Byte Int

instance Arbitrary Byte where
  arbitrary = map intToByte arbitrary
    where
    intToByte n | n >= 0 = Byte (n `mod` 256)
                | otherwise = intToByte (-n)

ここでは、0から255までの間の整数値であるような型Byteを定義しています。 Arbitraryインスタンスはmap演算子を使って、intToByte関数をarbitrary動作まで持ち上げています。 arbitrary動作内部の型はGen Intと推論されます。

この考え方を merge用のテストに使うこともできます。

quickCheck \xs ys ->
  eq (numbers $ mergePoly (sort xs) (sort ys)) (sort $ xs <> ys)

このテストでは、任意の配列xsysを生成しますが、mergeは整列済みの入力を期待しているので、これらを整列しておかなければなりません。 一方で、整列された配列を表すnewtypeを作成し、整列されたデータを生成するArbitraryインスタンスを書くこともできます。

newtype Sorted a = Sorted (Array a)

sorted :: forall a. Sorted a -> Array a
sorted (Sorted xs) = xs

instance (Arbitrary a, Ord a) => Arbitrary (Sorted a) where
  arbitrary = map (Sorted <<< sort) arbitrary

この型構築子を使うと、テストを次のように変更できます。

quickCheck \xs ys ->
  eq (ints $ mergePoly (sorted xs) (sorted ys)) (sort $ sorted xs <> sorted ys)

これは些細な変更に見えるかもしれませんが、xsysの型はただのArray IntからSorted Intへと変更されています。 これにより、mergePoly関数は整列済みの入力を取る、という意図をわかりやすく示すことができます。 理想的には、mergePoly関数自体の型がSorted型構築子を使うようにするといいでしょう。

より興味深い例として、 Treeモジュールでは枝の値で整列された二分木の型が定義されています。

data Tree a
  = Leaf
  | Branch (Tree a) a (Tree a)

Treeモジュールでは次のAPIが定義されています。

insert    :: forall a. Ord a => a -> Tree a -> Tree a
member    :: forall a. Ord a => a -> Tree a -> Boolean
fromArray :: forall a. Ord a => Array a -> Tree a
toArray   :: forall a. Tree a -> Array a

insert関数は新しい要素を整列済みの木に挿入し、member関数は特定の値について木に問い合わせます。 例えば次のようになります。

> import Tree

> member 2 $ insert 1 $ insert 2 Leaf
true

> member 1 Leaf
false

toArray関数とfromArray関数は、整列された木と配列を相互に変換できます。 fromArrayを使うと、木についてのArbitraryインスタンスを書けます。

instance (Arbitrary a, Ord a) => Arbitrary (Tree a) where
  arbitrary = map fromArray arbitrary

a用に使えるArbitaryインスタンスがあるなら、テストする性質の引数の型としてTree aを使えます。 例えば、memberによる木の確認については、値を挿入した後は常にtrueを返すことをテストできます。

quickCheck \t a ->
  member a $ insert a $ treeOfInt t

ここでは、引数 tTree Number型の無作為に生成された木です。 型引数は、同値関数 treeOfIntによって明確にされています。

演習

  1. (普通)a-zの範囲から無作為に選ばれた文字の集まりを生成する Arbitraryインスタンスを持つ、Stringのnewtypeを作ってください。 手掛かりTest.QuickCheck.Genモジュールから elementsarrayOf関数を使います。
  2. (難しい)木に挿入された値は、どれだけ沢山の挿入があった後でも、その木の構成要素であることを主張する性質を書いてください。

高階関数のテスト

Mergeモジュールはmerge関数の別の一般化も定義しています。 mergeWith関数は追加の関数を引数として取り、統合される要素の順序を判定します。 つまりmergeWithは高階関数です。

例えばlength関数を最初の引数として渡し、既に長さの昇順になっている2つの配列を統合できます。 その結果もまた長さの昇順になっているでしょう。

> import Data.String

> mergeWith length
    ["", "ab", "abcd"]
    ["x", "xyz"]

["","x","ab","xyz","abcd"]

このような関数をテストするにはどうしたらいいでしょうか。 理想的には、関数である最初の引数を含めた3つの引数全てについて、値を生成したいところです。

無作為に生成された関数を作れるようにする、2つ目の型クラスがあります。 Coarbitraryという名前で次のように定義されています。

class Coarbitrary t where
  coarbitrary :: forall r. t -> Gen r -> Gen r

coarbitrary関数は、型tの関数の引数と、型rの関数の結果の乱数生成器を取ります。 この関数引数を使って乱数生成器をかき乱します。 つまり、関数の引数を使って乱数生成器の無作為な出力を変更し、結果としているのです。

また、もし関数の定義域がCoarbitraryで値域がArbitraryなら、Arbitraryの関数を与える型クラスインスタンスが存在します。

instance (Coarbitrary a, Arbitrary b) => Arbitrary (a -> b)

実際のところ、引数として関数を取るような性質を記述できます。 mergeWith関数の場合では、新しい引数を考慮するようにテストを修正すると、最初の引数を無作為に生成できます。

結果が整列されていることは保証できません。 Ordインスタンスを持っているとさえ限らないのです。 しかし、引数として渡す関数fに従って結果が整列されていることは期待されます。 更に、2つの入力配列がfに従って整列されている必要がありますので、sortBy関数を使って関数fが適用されたあとの比較に基づいてxsysを整列します。

quickCheck \xs ys f ->
  let
    result =
      map f $
        mergeWith (intToBool f)
                  (sortBy (compare `on` f) xs)
                  (sortBy (compare `on` f) ys)
    expected =
      map f $
        sortBy (compare `on` f) $ xs <> ys
  in
    eq result expected

ここでは、関数 fの型を明確にするために、関数 intToBoolを使用しています。

intToBool :: (Int -> Boolean) -> Int -> Boolean
intToBool = id

関数は Arbitraryであるだけでなく Coarbitraryでもあります。

instance (Arbitrary a, Coarbitrary b) => Coarbitrary (a -> b)

つまり値や関数だけに制限されません。 高階関数や、引数が高階関数であるような関数やその他諸々もまた、無作為に生成できるのです。

Coarbitraryのインスタンスを書く

GenMonadApplicativeインスタンスを使って独自のデータ型に対して Arbitraryインスタンスを書くことができるのとちょうど同じように、独自の Coarbitraryインスタンスを書くこともできます。 これにより、無作為に生成される関数の定義域として、独自のデータ型を使うことができるようになります。

Tree型の Coarbitraryインスタンスを書いてみましょう。 枝に格納されている要素の型に Coarbitraryインスタンスが必要になります。

instance Coarbitrary a => Coarbitrary (Tree a) where

Tree aの値が与えられたときに、乱数発生器をかき乱す関数を記述する必要があります。 入力値がLeafであれば、そのままにしておく生成器を返します。

  coarbitrary Leaf = id

もし木が Branchなら、左の部分木、値、右の部分木を使って生成器をかき乱します。 関数合成を使って独自のかき乱し関数を作ります。

  coarbitrary (Branch l a r) =
    coarbitrary l <<<
    coarbitrary a <<<
    coarbitrary r

これで、木を引数にとるような関数を引数に含む性質を自由に書くことができるようになりました。 例えばTreeモジュールでは関数anywhereが定義されています。 これは述語が引数のどんな部分木についても満たされるかを調べます。

anywhere :: forall a. (Tree a -> Boolean) -> Tree a -> Boolean

今や述語関数を無作為に生成できます。 例えば、anywhere関数は選言の法則を満たすことが期待されます。

quickCheck \f g t ->
  anywhere (\s -> f s || g s) t ==
    anywhere f (treeOfInt t) || anywhere g t

ここで、 treeOfInt関数は木に含まれる値の型を型 Intに固定するために使われています。

treeOfInt :: Tree Int -> Tree Int
treeOfInt = id

副作用のないテスト

通常、テストの目的ではテストスイートのmain動作にquickCheck関数の呼び出しが含まれています。 しかしquickCheck関数には亜種があり、quickCheckPureという名前です。 副作用を使わない代わりに、入力として乱数の種を取ってテスト結果の配列を返す純粋な関数です。

PSCiを使用して quickCheckPureを試せます。 ここでは merge操作が結合法則を満たすことをテストします。

> import Prelude
> import Merge
> import Test.QuickCheck
> import Test.QuickCheck.LCG (mkSeed)

> :paste
… quickCheckPure (mkSeed 12345) 10 \xs ys zs ->
…   ((xs `merge` ys) `merge` zs) ==
…     (xs `merge` (ys `merge` zs))
… ^D

Success : Success : ...

quickCheckPureは乱数の種、生成するテスト項目数、テストする性質の3つの引数を取ります。 もし全てのテスト項目が成功したら、Successデータ構築子の配列がコンソールに出力されます。

quickCheckPureは、性能ベンチマークの入力データ生成や、webアプリケーションのフォームデータ例を無作為に生成するというような状況で便利かもしれません。

演習

  1. (簡単)ByteSorted型構築子についての Coarbitraryインスタンスを書いてください。

  2. (普通)任意の関数 fについて、 mergeWith f関数の結合性を主張する(高階)性質を書いてください。 quickCheckPureを使ってPSCiでその性質をテストしてください。

  3. (普通)次のデータ型のArbitraryCoarbitraryインスタンスを書いてください。

    data OneTwoThree a = One a | Two a a | Three a a a
    

    手掛かりTest.QuickCheck.Genで定義されたoneOf関数を使ってArbitraryインスタンスを定義してください。

  4. (普通)allを使ってquickCheckPure関数の結果を単純化してください。 この新しい関数は型List Result -> Booleanを持ち、全てのテストが通ればtrueを、そうでなければfalseを返します。

  5. (普通)quickCheckPureの結果を単純にする別の手法として、関数squashResults :: List Result -> Resultを書いてみてください。 Data.Maybe.FirstFirstモノイドと共にfoldMap関数を使うことで、失敗した場合の最初のエラーを保持することを検討してください。

まとめ

この章ではquickcheckパッケージに出会いました。 これを使うと生成的テスティングのパラダイムを使って、宣言的な方法でテストを書くことができました。具体的には以下です。

  • spago testを使ってQuickCheckのテストを自動化する方法を見ました。
  • 性質を関数として書く方法とエラー文言を改良する<?>演算子の使い方を説明しました。
  • ArbitraryCoarbitrary型クラスによって定型的なテストコードの自動生成を可能にする方法や、高階な性質のテストを可能にする方法を見ました。
  • 独自のデータ型に対して ArbitraryCoarbitraryインスタンスを実装する方法を見ました。