読者です 読者をやめる 読者になる 読者になる

HaskellのDocTestでTDDしてみる+α

(注意)この記事はお試しでやってみたよ的な記事です。

Haskell界隈で面白そうな話をみた。

『プログラミング Haskell』『すごい Haskell 楽しく学ぼう!』の次に何を読む? - Togetterまとめ

こういう話を見ていると自分でも試したくなりますよね。

というわけで、例によってFizzBuzzをお題にしてやってみました。まぁ、Haskellはじめて間もないのでこれくらいのお題がちょうどよさそうですし・・・コードひどかったりしたらご指摘ください。

環境準備

どうせだからディレクトリ構成からきちんと考えたいですよね、ということでGItHubに置かれているHaskellライブラリのディレクトリ構成を参考に作ってみました。

fizzbuzz
|- Setup.hs
|- fizzbuzz.cabal
|- src
   |- FizzBuzz.hs
|- dist
   |- ビルドやテストでの生成物置き場
|- test
   |- doctests.hs
   |- Spec.hs
   |- Tests.hs

fizzbuzz.cabalはcabal initコマンドで基本情報を埋めたあと、以下のように書き加えました。

name:              fizzbuzz
-- (省略)
build-type:        Custom

library
  hs-source-dirs:  src
  ghc-options:     -Wall
  build-depends:   base
  exposed-modules: FizzBuzz

test-suite doctests
  type:            exitcode-stdio-1.0
  hs-source-dirs:  test
  main-is:         doctests.hs
  ghc-options:     -Wall -threaded
  build-depends:   base,
                   doctest >= 0.7

test-suite spec
  type:            exitcode-stdio-1.0
  hs-source-dirs:  test, src
  main-is:         Spec.hs
  ghc-options:     -Wall
  build-depends:   base,
                   hspec,
                   HUnit

test-suite tests
  type:            exitcode-stdio-1.0
  hs-source-dirs:  test, src
  main-is:         Tests.hs
  ghc-options:     -Wall
  build-depends:   base,
                   test-framework,
                   test-framework-th,
                   test-framework-quickcheck2,
                   test-framework-hunit,
                   HUnit,
                   QuickCheck >= 2.4.0.1

各ライブラリのバージョン指定はもう少ししっかり書くべきかもしれません・・・。

doctests.hsの作成

sol/doctest-haskell · GitHub

こちらを参考にしました。

module Main where

import Test.DocTest

main :: IO ()
main = doctest ["--optghc=-isrc", "src/FizzBuzz.hs"]

[2012/7/25 追記]
srcディレクトリ下の他のソースファイルもサーチしてほしい場合もあるので、"--optghc=-isrc"を追加
(-isrcというオプションではなく、-iオプションにsrcを指定している)

TDDしてみる

FIzzBuzz.hsを作成し、テストを書きます。今回はhaddockとdoctestで試してみました。

まず3の倍数は"Fizz"を返す、からいきます。

module FizzBuzz (fizzbuzz) where

{-| function 'fizzbuzz'.

>>> fizzbuzz 3
"Fizz"
-}
fizzbuzz x = undefined

今回は後から型を書くことにしました。

出来上がったのでビルドとテストを実行します。

cabal configure --enable-test
cabal build
cabal test doctests

もちろん失敗します。失敗したので実装しましょう(これ以降、実装時は関数のみ書きます)。

fizzbuzz x = "Fizz"

仮実装でとりあえずテストに通りました。が、これはひどいので三角測量で更にテストを追加します。

module FizzBuzz (fizzbuzz) where

{-| function 'fizzbuzz'.

>>> fizzbuzz 3
"Fizz"

>>> fizzbuzz 6
"Fizz"
-}
fizzbuzz x = "Fizz"

テストは通らなくなります。まじめに実装しましょう。

fizzbuzz x
  | x `mod` 3 == 0 = "Fizz"

大丈夫そうですね。

次は5の倍数について・・・となるのですが、長いのでこの記事では省略します。

途中経過

さて、続けていった結果以下の状態になりました。

module FizzBuzz (fizzbuzz) where

{-| function 'fizzbuzz'.
'fizzbuzz'は与えられた整数に対応する文字列を返す。

例:

3の倍数
>>> fizzbuzz 3
"Fizz"

5の倍数
>>> fizzbuzz 5
"Buzz"

3かつ5の倍数
>>> fizzbuzz 15
"FizzBuzz"

それ以外
>>> fizzbuzz 1
"1"
-}
fizzbuzz x
  | multiples3 && multples5 = "FizzBuzz"
  | multiples3 = "Fizz"
  | multiples5 = "Buzz"
  | otherwise = show x
  where multiples3 = x `mod` 3 == 0
        multiples5 = x `mod` 5 == 0

私の環境だと日本語を認識できたのでとりあえず日本語で書いてます。あと、三角測量に使用したテスト(6なら"Fizz"とか)は、ずっと必要なテストではないと考えたので途中で削りました。ドキュメントなので最小構成でいいかなと思ったというのもあります。

doctestを使った感覚としては「楽でいいなー」といったところでしょうか。
IO系は厳しいものの、副作用のないものであればこれで十分な感じもありますね。とはいっても、ドキュメントとしては書きたくないけどテストとして残しておきたいコードはどうするのという話もありますが・・・と思っていたらHaskellとテストとBDD - あどけない話に色々と書かれていました。非常に参考になります。

番外編

ひと段落してからQuickCheckのコードも作ってみました。

{-# LANGUAGE TemplateHaskell #-}
module Main where

import Test.Framework.TH
import Test.Framework.Providers.QuickCheck2
import Test.QuickCheck2
import Control.Monad (liftM)

import FizzBuzz

main :: IO ()
main = $(defaultMainGenerator)

prop_fizz :: Int -> Property
prop_fizz x =
  (pred_mod3 x) && (x > 0) ==> fizzbuzz x == "Fizz"
prop_buzz :: Property
prop_buzz =
  forAll (liftM abs arbitrary) $
    \x -> (pred_mod5 x) ==> fizzbuzz x == "Buzz"
prop_fizzbuzz :: Property
  forAll (liftM ((*15) . abs) arbitrary) $
    \x -> (pred_both x) ==> fizzbuzz x == "FizzBuzz"
prop_non :: Int -> Property
prop_non x =
  (pred_non x) && (x > 0) ==> fizzbuzz x == (show x)

pred_non :: Int -> Bool
pred_non x = (mod3 x > 0) && (mod5 x > 0)
pred_mod3 :: Int -> Bool
pred_mod3 x = (mod3 x == 0) && (mod5 x > 0)
pred_mod5 :: Int -> Bool
pred_mod5 x = (mod3 x > 0) && (mod5 x == 0)
pred_both :: Int -> Bool
pred_both x = (mod3 x == 0) && (mod5 x == 0)

mod3 :: Int -> Int
mod3 x = x `mod` 3
mod5 :: Int -> Int
mod5 x = x `mod` 5

負の数を対象にしないように(x > 0)や(liftM abs arbitrary)してます。また、(liftM ((*15) . abs) arbitrary)で15の倍数にマッチするテストデータを増やしてます。

慣れていないのでそこそこ時間がかかりました。

cabal test tests

このコマンドで実行させました。.cabalに記述したtest-suiteの名前を指定することで実行したいtest-suiteを選べるようですね。(何も書かないとtestsが走る?用調査)

    1. 番外編その2

Hspecでも書いてみました。ただ、doctestの後だとどうも重い感じがしますね。

module Main where

import Test.Hspec.Monadic
import Test.Hspec.HUnit ()
import Test.HUnit

import FizzBuzz

main :: IO ()
main = hspecX spec

spec :: Specs
spec = do
  describe "fizzbuzz" $ do
    it "3の倍数ならFizz" $ do fizzbuzz 3 @?= "Fizz"
    it "5の倍数ならBuzz" $ do fizzbuzz 5 @?= "Buzz"
    it "3かつ5の倍数ならFizzBuzz" $ do fizzbuzz 15 @?= "FizzBuzz"
    it "その他数値は文字列化" $ do fizzbuzz 1 @?= "1"

いつもの(?)Specですね。

cabal test spec

これでテスト実行しました。

番外編3

最初はtest-framework-doctestを使おうとしていたのですが、deprecatedという単語が見えたので引き返しました。