実例によるPureScript

ウェブのための関数型プログラミング

Phil Freeman, "PureScript by Example - Functional Programming for the Web"

目次に戻る

第13章 テストの自動生成

13.1 この章の目標

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

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

13.2 プロジェクトの準備

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

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

13.3 プロパティの書き込み

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

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

mergeは2つのソートされた数の配列をとって、その要素を統合し、ソートされた結果を返します。例えば次のようになります。

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

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

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

purescript-quickcheckでは、無作為なテストケースを生成することで、直接これらの性質をテストすることができます。コードが持つべき性質を、次のような関数として述べるだけです。

main = do
  quickCheck \xs ys ->
    isSorted $ merge (sort xs) (sort ys)
  quickCheck \xs ys ->
    xs `isSubarrayOf` merge xs ys

ここで、 isSortedisSubarrayOfは次のような型を持つ補助関数として実装されています。

isSorted :: forall a. Ord a => Array a -> Boolean
isSubarrayOf :: forall a. Eq a => Array a -> Array a -> Boolean

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

$ pulp test

* Build successful. Running tests...

100/100 test(s) passed.
100/100 test(s) passed.

* Tests OK.

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

Error: Test 1 failed:
Test returned false

このエラーメッセージではあまり役に立ちませんが、これから見ていくように、少しの作業で改良することができます。

13.4 エラーメッセージの改善

テストケースが失敗した時に同時にエラーメッセージを提供するには、 purescript-quickcheck演算子を使います。次のように性質の定義に続けて で区切ってエラーメッセージを書くだけです。

quickCheck \xs ys ->
  let
    result = merge (sort xs) (sort ys)
  in
    xs `isSubarrayOf` result <?> show xs <> " not a subarray of " <> show result

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

Error: Test 6 failed:
[79168] not a subarray of [-752832,686016]

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

演習

  1. (簡単) 空の配列を持つ配列を統合しても元の配列は変更されない、と主張する性質を書いてください。

  2. (簡単) mergeの残りの性質に対して、適切なエラーメッセージを追加してください。

13.5 多相的なコードのテスト

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.

このエラーメッセージは、配列に持たせたい要素の型が何なのかわからないので、コンパイラが無作為なテストケースを生成できなかったということを示しています。このような場合、補助関数を使と、コンパイラが特定の型を推論すること強制できます。例えば、恒等関数の同義語として intsという関数を定義します。

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

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

quickCheck \xs ys ->
  isSorted $ ints $ mergePoly (sort xs) (sort ys)
quickCheck \xs ys ->
  ints xs `isSubarrayOf` mergePoly xs ys

ここで、 numbers関数が不明な型を解消するために使われるので、 xsysはどちらも型 Array Intを持っています。

演習

  1. (簡単)xsysの型を Array Booleanに強制する関数 boolsを書き、 mergePolyをその型でテストする性質を追加してください。

  2. (やや難しい) 標準関数から(例えば purescript-arraysパッケージから)ひとつ関数を選び、適切なエラーメッセージを含めてQuickCheckの性質を書いてください。その性質は、補助関数を使って多相型引数を IntBooleanのどちらかに固定しなければいけません。

13.6 任意のデータの生成

purescript-quickcheckライブラリを使って性質についてのテストケースを無作為に生成する方法について説明します。

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

class Arbitrary t where
  arbitrary :: Gen t

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

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

例えば、 purescript-quickcheckライブラリで提供されている Int型の Arbitraryインスタンスは、関数を整数から任意の整数値のバイトまでマップするための Functorインスタンスを Genに使用することで、バイト値の分布した値を生成します。

newtype Byte = Byte Int

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

ここでは、0から255までの間の整数値であるような型 Byteを定義しています。 Arbitraryインスタンスの <$>演算子を使って、 uniformToByte関数を arbitraryアクションまで持ち上げています。この型の arbitraryアクションの型は Gen Numberだと推論されますが、これは0から1の間に均一に分布する数を生成することを意味しています。

この考え方を mergeに対しての既ソート性テストを改良するのに使うこともできます。

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

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

newtype Sorted a = Sorted (Array a)

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

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

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

quickCheck \xs ys ->
  isSorted $ ints $ mergePoly (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 arbTree :: (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. (難しい) 木に挿入された値は、任意に多くの挿入があった後も、その木の構成要素であることを主張する性質を書いてください。

13.7 高階関数のテスト

Mergeモジュールは merge関数についての他の生成も定義します。 mergeAith関数は、統合される要素の順序を決定するのに使われる、追加の関数を引数としてとります。つまり mergeWithは高階関数です。

例えば、すでに長さの昇順になっている2つの配列を統合するのに、 length関数を最初の引数として渡します。このとき、結果も長さの昇順になっていなければなりません。

> import Data.String

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

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

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

関数を無作為に生成せきるようにする、もうひとつの型クラスがあります。この型クラスは Coarbitraryと呼ばれており、次のように定義されています。

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

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

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

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

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

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

quickCheck \xs ys f ->
  isSorted $
    map f $
      mergeWith (intToBool f)
                (sortBy (compare `on` f) xs)
                (sortBy (compare `on` f) ys)

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

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

部分配列性については、単に関数の名前を mergeWithに変えるだけです。引き続き入力配列は結果の部分配列になっていると期待できます。

quickCheck \xs ys f ->
  xs `isSubarrayOf` mergeWith (numberToBool f) xs ys

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

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

これは値の生成が単純な関数だけに限定されるものではないことを意味しています。つまり、高階関数や、引数が高階関数であるような関数すら無作為に生成することができるのです。

13.8 Coarbitraryのインスタンスを書く

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

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

instance coarbTree :: 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を生成することができるようになりました。例えば、 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

13.9 副作用のないテスト

テストの目的では通常、テストスイートの mainアクションには quickCheck関数の呼び出しが含まれています。しかし、副作用を使わない quickCheckPureと呼ばれる 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は、性能ベンチマークの入力データ生成や、ウェブアプリケーションのフォームデータ例を無作為に生成するというような状況で便利かもしれません。

演習

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

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

  3. (やや難しい)次のデータ型の Coarbitraryインスタンスを書いてください。

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

    ヒントTest.QuickCheck.Genで定義された oneOf関数を使って Arbitraryインスタンスを定義します。

  4. (やや難しい)all関数を使って quickCheckPure関数の結果を単純化してください。その関数はもしどんなテストもパスするなら trueを返し、そうでなければ falseを返さなくてはいけません。 purescript-monoidsで定義されている Firstモノイドを、失敗時の最初のエラーを保存するために foldMap関数と一緒に使ってみてください。

まとめ

この章では、生成的テスティングのパラダイムを使って宣言的な方法でテストを書くための、 purescript-quickcheckパッケージを導入しました。

目次に戻る