実例による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を使って簡単なライブラリをテストし、Gruntでテストスイートを自動化されたビルドに統合する方法を見ていきます。

13.2 プロジェクトの準備

この章のプロジェクトにはBower依存関係としてpurescript-quickcheckが追加されます。実際には、purescript-quickcheckbower.jsondevDependenciesセクションに追加されます。

  "devDependencies": {
    "purescript-quickcheck": "~0.1.3"
  }

これは purescript-quickcheckは開発時のみ必要であることを示しています。製品ビルドのときは QuickCheck ライブラリコードを出力に含むのを避けるため、 bowerコマンドで--productionフラグを使ってください。

$ bower update --production

$ grunt build

13.3 テストの自動化

このプロジェクトの Gruntfile.jsファイルは、テストスイートをサポートするために少し変更されています。

まず、pscタスクに新しいセクションが追加されています。これはソースコードをテストスイートのコードと一緒にビルドし、副次的なJavaScriptファイルを出力するようにします。

psc: {
  lib: {
    src: ["<%=srcFiles%>"],
    dest: "dist/Main.js"
  },
  tests: {
    options: {
      module: ["Main"],
      main: true
    },
    src: ["tests/Main.purs", "<%=srcFiles%>"],
    dest: "dist/tests.js"
  }
}

psc:testsタスクはテストスイートを実行するためにの追加のdist/tests.jsファイルを生成するようになっています。次の手順は、grunt-executeパッケージを使って、このプロセスを自動化することです。

execute: {
  tests: {
    src: "dist/tests.js"
  }
}

grunt-executeパッケージもNPMの​依存関係として追加されます。最後に、Gruntタスクリストにタスクとしてこれを追加する必要があります。

grunt.loadNpmTasks("grunt-execute");

grunt.registerTask("build", 
  ["psc:lib", "dotPsci"]);
grunt.registerTask("test", 
  ["build", "psc:tests", "execute:tests"]);

これで、ライブラリのソースコードだけをビルドするbuild、ライブラリとテストスイートをビルドしテストも実行するtestの、2つのタスクが新しく利用できるようになります。

13.4 プロパティの書き込み

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

merge :: [Number] -> [Number] -> [Number]

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

> :i 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) => [a] -> Boolean
isSubarrayOf :: forall a. (Eq a) => [a] -> [a] -> Boolean

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

$ grunt

Running "execute:tests" (execute) task
-> executing dist/tests.js
100/100 test(s) passed.
100/100 test(s) passed.
-> completed dist/tests.js

>> 1 file and 0 calls executed

Done, without errors.

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

Error: Test 1 failed: 
Test returned false

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

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

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

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

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

Error: Test 6 failed: 
[0.85] not a subarray of [0.89,0.82,0.44,0.01]

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

演習

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

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

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

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

mergePoly :: forall a. (Ord a) => [a] -> [a] -> [a]

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

Error in declaration main
No instance found for Testable ([u1] -> [u1] -> Boolean)

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

numbers :: [Number] -> [Number]
numbers = id

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

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

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

演習

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

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

13.7 任意のデータの生成

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

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

class Arbitrary t where
  arbitrary :: Gen t

Gen型構築子は決定的無作為データ生成の副作用を表しています。 決定的無作為データ生成は、擬似乱数生成器を使って、シード値から決定的無作為関数の引数を生成します。GenはモナドでもApplicative関手でもあるので、Arbitary型クラスの新しいインスタンスを作成するのに、いつも使っているようなコンビネータを自由に使うことができます。

例えば、purescript-quickcheckライブラリで提供されているNumber型のArbitraryインスタンスは、0と1の間に均一に分布した値を生成します。もし異なる分布を持った数を生成するArbitaryインスタンスを持った型を定義したい場合は、Applicativeインスタンスを使うと、関数適用によって、均一な無作為な変数を、次のような無作為な値へと変換することができます。

newtype Byte = Byte Number

instance arbitraryByte :: Arbitary Byte where
  arbitrary = uniformToByte <$> arbitrary
    where
    uniformToByte n = Math.floor (n * 256)

ここでは、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 [a]

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

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

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

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

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

より興味深い例として、Tree.pursファイルでは枝の値でソートされた二分木の型が定義されています。

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

toArray :: forall a. Tree a -> [a]
fromArray :: forall a. (Ord a) => [a] -> Tree a

insert関数は新しい要素をソート済みの二分木に挿入するのに使われ、member関数は特定の値の有無を木に問い合わせるのに使われます。例えば次のようになります。

> :i 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 = fromArray <<< sorted <$> arbitrary

fromArrayへの入力がソートされた配列であることを保証するために、ここでsort関数を使っていることに注意してください。

aについての有効なArbitaryインスタンスが存在していれば、テストする性質の引数の型としてTree aを使うことができます。例えば、memberテストは値を挿入した後は常にtrueを返すことをテストできます。

quickCheck $ \t a -> 
  member a $ insert a (t :: Tree Number) 

ここでは、引数tTree Number型の無作為に生成された木です。

演習

  1. (やや難しい) a-zの範囲から無作為に選ばれた文字の集まりを生成するArbitraryインスタンスを持った、Stringのnewtypeを作ってください。ヒント[Number]Arbitraryインスタンスと、 型[Number] -> Stringを持つ関数を使ってください。

  2. (難しい) 木に挿入された値は、任意に多くの挿入があった後も、その木の構成要素であることを主張する性質を書いてください。

13.8 高階関数のテスト

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

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

> :i 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 (numberToBool f) 
                (sortBy (compare `on` f) xs) 
                (sortBy (compare `on` f) ys)

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

numberToBool :: (Number -> Boolean) -> Number -> Boolean
numberToBool = 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.9 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 (treeOfNumber t) || anywhere g t

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

treeOfNumber :: Tree Number -> Tree Number
treeOfNumber = id

13.10 副作用のないテスト

テストの目的では通常、テストスイートの mainアクションにはquickCheck関数の呼び出しが含まれています。しかし、副作用を使わないquickCheckPureと呼ばれるquickCheck関数の亜種もあります。quickCheckPureは、入力として乱数の種をとり、テスト結果の配列を返す純粋な関数です。

psciを使用してquickCheckPureを使ってみましょう。ここでは merge操作が結合法則を満たすことをテストしてみます。

> :i Test.QuickCheck
> :i Merge

> quickCheckPure 12345 10 $ \xs ys zs -> 
    ((xs `merge` ys) `merge` zs) == 
      (xs `merge` (ys `merge` zs))
  
[Success, 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
  4. (やや難しい) all関数を使ってquickCheckPure関数の結果を単純化してください。その関数はもしどんなテストもパスするならtrueを返し、そうでなければfalseを返さなくてはいけません。purescript-monoidsで定義されているFirstモノイドを 、失敗時の最初のエラーを保存するためにfoldMap関数と一緒に使ってみてください。

13.11 まとめ

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