ペンギン村 Tech Blog

技術をこよなく愛するエンジニア集団が在住するペンギン村から、世界へ役立つ(かもしれない)技術情報を発信する技術系ブログです。某アラレちゃんが済む村とは一切関係ありません。んちゃ!

Haskell で doctest(unittest)と QuickCheck(Property-based Testing)

どうも、年末年始に飲むワインはバッチリ買い揃えた @tobi462 です。

なんだか、ひょんなことからすごく久しぶりに Haskell を書いたのですが、そもそも doctest のインストール方法とかもすぐに分からなかったのでメモも兼ねて。

Tl;Dr

-- | 文字がすべて異なるか判定する。
--
-- >>> isUnique ""
-- True
-- >>> isUnique "a"
-- True
-- ...
--
-- ユニークな文字列は、ソート・グループした文字数の長さと、元の文字列の長さが一致する
-- prop> isUnique s == ((length $ group $ sort s) == length s)
-- 
isUnique :: String -> Bool
isUnique []     = True
isUnique [x]    = True
isUnique (x:xs) = not (x `elem` xs) && (isUnique xs)
$ stack exec doctest main.hs
Examples: 50  Tried: 50  Errors: 0  Failures: 0

doctest

doctest は Hackage 1 に公開されている、ドキュメンテーションコメント内に「ユニットテスト」を書いて実行できるツールです。

hackage.haskell.org

Python における doctest と似たような感じですね。(どちらが先なんでしょうね?)

インストール

昔は cabal 2 を使ってインストールするのが主流だったようですが、パッケージ依存関係の管理が複雑になるという問題があり、最近では stack というものが主流なようなので、そちらを使ってインストールしてみます。

stack install {パッケージ名} でインストールできるようです。

$ stack install doctest
ghc-paths-0.1.0.9: using precompiled package
code-page-0.1.3: download
code-page-0.1.3: configure
code-page-0.1.3: build
code-page-0.1.3: copy/register
doctest-0.13.0: download
doctest-0.13.0: configure
doctest-0.13.0: build
doctest-0.13.0: copy/register
Completed 3 action(s).
Copying from /Users/yusuke/.stack/snapshots/x86_64-osx/lts-10.2/8.2.2/bin/doctest to /Users/yusuke/.local/bin/doctest

Copied executables to /Users/yusuke/.local/bin:
- doctest

ちなみに .local/bin にパスが通ってないケースもあるので、その場合はパスを通しておく必要があります。

doctest を書く

例として、足し算を行う add 関数に対して doctest を書いてみます。(あまり Haskell らしくない例題ですね;)

-- | 足し算
-- 
-- >>> add 0 1
-- 1
-- >>> add 1 1
-- 2
-- 
add :: (Integral a) => a -> a -> a
add x y = x + y

このように >>> で始まる行に「式」を記述し、その直後の行に「期待値(expectation)」を記述していきます。ちなみに、最初の行の | からはじまる「関数の説明」を書かないと認識されないので注意しましょう。

実行は stack exec doctest {ファイル名.hs} です。

$ stack exec doctest add.hs
Examples: 2  Tried: 2  Errors: 0  Failures: 0

2つのテストを実行し、エラーも失敗もなかったことがわかります。

失敗した場合

試しに2つ目のテストの「期待値」を正しくない「3」に変更して実行すると、次のような結果が得られます。

$ stack exec doctest add.hs
### Failure in add.hs:7: expression `add 1 1'
expected: 3
 but got: 2
Examples: 2  Tried: 2  Errors: 0  Failures: 1

add 1 1 の「期待値(expected)」として 3 を期待したが、実際には 2 が得られた、という結果が報告されているのがわかります。

期待値がリストの場合のテストの注意

なお、期待値がリストの場合(に限った話でもないのですが)については、少し注意です。

与えられた2つの数値をリストにして返すtoList関数の doctest を考えてみます。

-- | 2つの数値をリストにして返す
-- 
-- >>> toList 0 1
-- [0, 1]
-- 
toList :: (Integral a) => a -> a -> [a]
toList x y = [x, y]

一見すると正しく動きそうですが、実際に doctest を実行すると失敗します。

### Failure in add.hs:15: expression `toList 0 1'
expected: [0, 1]
 but got: [0,1]

期待値は[0, 1]というリストで正しいようですが、,の直後のスペース有無により、結果と不一致とみなされています。

きちんとドキュメントを読んだわけではないのですが、どうやら結果に対して( Show インスタンスの) show 関数を呼び出し、その結果と期待値を比較する、という仕組みに doctest はなっているようです。

自分で定義した型を、(deriving を利用せず)自分で Showインスタンスにするとそれがわかります。

data MyBool = MyTrue | MyFalse

instance Show MyBool where
    show MyTrue  = "YES!"
    show MyFalse = "NO!"

-- | `MyTrue`を返す
-- 
-- >>> yes
-- YES!
-- 
yes = MyTrue

リストの show の結果では、, の前後にスペースが入らないので、それに合わせて記述する必要があるということですね。

Prelude> let xs = [0, 1]
Prelude> xs
[0,1]

これは Show インスタンスでない型(例えば「関数」)については、doctest でテストできないことを意味します。

QuickCheck

QuickCheck は Haskell において Property-based Testing を行うツールです。

hackage.haskell.org

Property-based Testing とは

Property-based Testing は、テスト対象の関数の「性質」を表す「式」を定義しておき、それに対して大量のランダムな値をツールが生成し、その式が成り立たないケースが存在しないかチェックするテスト手法です。

・・・と言っても、この説明だけで理解するのは難しいと思うので、Swift での話ですが以前発表したスライドを貼っておきます。

speakerdeck.com

インストール

QuickCheck は doctest をインストールしておけば、自動的に使えるようです。

QuickCheck でテストを書く

QuickCheck は doctest と併用して記述することができます。さきほどの足し算をおこなう add 関数に対して実装してみます。

-- | 足し算
-- 
-- >>> add 0 1
-- 1
-- >>> add 1 1
-- 2
-- 
-- prop> add x y == add y x
-- prop> add (add x 1) 1 == add x 2
-- 
add :: (Integral a) => a -> a -> a
add x y = x + y

このように prop> から始まる行に、その関数をあらわす「性質」の式を記述します。

実行すると doctest と一緒にテストの一部として動作します。

$ stack exec doctest add.hs
Examples: 4  Tried: 4  Errors: 0  Failures: 0

失敗した場合

ためしに、内部のロジックを本来の足し算である + ではなく * に変更してみるとどうなるか見てみます。QuickCheck の結果だけをみるために doctest のテストは無効化しています。

-- | 足し算
-- 
-- prop> add x y == add y x
-- prop> add (add x 1) 1 == add x 2
-- 
add :: (Integral a) => a -> a -> a
add x y = x * y -- `+` -> `*` に変更

実行結果の例 3 として、次のような出力が得られました。

$ stack exec doctest add.hs
### Failure in add.hs:6: expression `add (add x 1) 1 == add x 2'
*** Failed! Falsifiable (after 2 tests):
1
Examples: 2  Tried: 2  Errors: 0  Failures: 1

ここでは add (add x 1) 1 == add x 2 という式について、(x が) 1 のケースについて成り立たなかったと報告されています。

この式の表す性質は、以下について同じ結果が得られるはず、というものです。

  • ある変数 x に対して、1 を2回だけ足した結果
  • ある変数 x に対して、2 を1回だけ足した結果

具体的に言い換えると、5 + 1 + 15 + 2 は同じ結果が得られるはず、といった感じです。

間違った実装の式を展開していくと、最終的に 1 == 2 となり、最終的に不一致になることが確認できます。

add (add x 1) 1 == add x 2
add (add 1 1) 1 == add 1 2 -- 今回報告された失敗ケースである`x = 1`を展開
add (1 * 1) 1   == add 1 2 -- 左辺の () 内の`add`を展開
(1 * 1) * 1     == 1 * 2   -- 両辺の残りの`add`を展開

1 == 2 -- 不一致

このようにテストの漏れが無いかを網羅的にチェックしてくれるのが、QuickChcek の利点です。

実践編

そして最初の話に戻るのですが、なぜいきなり Haskell で doctest だの QuickCheck だの言い出したのかですが、友人宅にお邪魔したときに、友人が読んでいた次の本についての話になったのがきっかけです。

世界で闘うプログラミング力を鍛える本 ~コーディング面接189問とその解法~

世界で闘うプログラミング力を鍛える本 ~コーディング面接189問とその解法~

これに出題されている問題の例として、文字列がユニークである(重複する文字がない)かを判定するコードを実装せよ、という問題があり、それなら久しぶりに Haskell で書いてみるか、という流れでした。

その時は近くのワインバーで遅くまで飲んでいた結果、二日酔いで死にかけていた状況だったりして、我ながらそんな状況でなぜコードを書くのか理解不能でした。結果、もっと気分が悪くなったのは言うまでもありません。

で、せっかくなら本気でやろうと思い、次を全部やろうと思いました。

  • 標準関数も含めてほぼ自作する
  • doctest で unittest をきっちりやる
  • QuickCheck で Property-based Testing もやる(標準関数が正しく実装できているかの検証にも利用)

出来上がったコードは Gist に置いておきました。

mod 以外の標準関数は自分で実装し直しています。(group だけはうまい実装方法が思いつかなかったので、標準のソースコードを参考にして実装しています)

まとめ

  • doctest による unittest はいいぞ!
  • QuickCheck による Property-based Testing はいいぞ!
  • Haskell はいいぞ!

P.S.
Swift にも doctest ほしいなぁ。


  1. Haskell のパッケージ公開サイトです。

  2. Haskell におけるパッケージ管理ツールです。

  3. ランダムな値で実行されるため、必ずしも同じ結果が得られるとは限りません。