皆さんはプログラムが正しく動作することをどうやって調べていますか? いちいち実行して確認している人もいるかもしれませんが,多くの人はツールや自作のプログラムを使って何らかの形でテストを自動化しているでしょう。ここ10年くらいで「テストの自動化」はプログラマにとってなじみの深い概念になりました。今回はHaskellでのテストの自動化を取り上げます。

型検査を利用する

 実は,Haskellを使っていれば,すでにテストはある程度自動化されていると言えます。

 第12回第13回で説明したSTMでは,STMモナドという特別なモナドを使い内側にI/Oアクションを記述できなくすることによって,取り消し不可能なアクションが入り込むことを防いでいました。

Prelude Control.Concurrent.STM> atomically (writeFile "sample.txt" 12)

<interactive>:1:12:
    Couldn't match expected type `STM a' against inferred type `IO ()'
    In the first argument of `atomically', namely
        `(writeFile "sample.txt" 12)'
    In the expression: atomically (writeFile "sample.txt" 12)
    In the definition of `it':
        it = atomically (writeFile "sample.txt" 12)

 これはつまり,型によってテストの自動化を行っているということです。型がテスト自動化の道具であるという概念は,最初は理解しにくいかもしれません。しかし,Haskellのような強い静的型付けの言語に慣れるにつれて,こうした考え方を自然なものととらえられるようになります。

 もう一つ例を見てみましょう。HTMLの元になるテキストをHaskellを使って記述し,そこからHTMLファイルを生成するとします。このコードは以下のようになります。

class Render a where
    render :: a -> String

data Link = Link String URI
newtype URI = URI String

link str uri = Link str uri

instance Render Link where
~ 略 ~

 URIに対するリンクがテキストの中に記述されていることを明確にするために,データ構成子Linkの二つ目の型はStringではなく特別に定義したURI型にしています。

*TypeCheck> :t link "sample.txt" "http://sample.org/"

<interactive>:1:18:
    Couldn't match expected type `URI' against inferred type `[Char]'
    In the second argument of `link', namely `"http://sample.org/"'
*TypeCheck> :t link "sample.txt" (URI "http://sample.org/")
link "sample.txt" (URI "http://sample.org/") :: Link

 さて,リンクにアンカー・テキストだけではなく,リンク先のタイトルを付記する必要が後から出てきたとしましょう。どのようにコードを変更すればよいでしょうか?

 モジュールの中にlink関数を使用した定義がなければ,Link型の定義を書き換えてもこのモジュールは正常にロードされます。

data Link = Link String URI String

*TypeCheck> :r
[1 of 1] Compiling TypeCheck        ( TypeCheck.hs, interpreted )
Ok, modules loaded: TypeCheck.

 しかし,どこかのモジュールでlink関数が使われれば型エラーが発生します。つまり,link関数がモジュールの型検査をすり抜けてしまったのです。

 そこで,link関数がLink型を返すよう型シグネチャを与えてみましょう。以下のように「link関数の型とLink型の定義が一致しない」というエラーが表示されます。

link :: String -> URI -> Link

*TypeCheck> :r
[1 of 1] Compiling TypeCheck        ( TypeCheck.hs, interpreted )

TypeCheck.hs:8:15:
    Couldn't match expected type `Link'
           against inferred type `String -> Link'
    In the expression: Link str uri
    In the definition of `link': link str uri = Link str uri
Failed, modules loaded: none.

 リンク先のタイトルを付記できるようにしつつ,リンク先のタイトルを付記しないlink関数も使用できるようにするには,どうすればよいでしょうか?

 こんなときに役立つのが,第5回で紹介したMaybeです。Maybeを使えば,あってもなくてもよいオプションの値を表すことができます。Maybeを使ってLink型とlink関数を書き換え,リンク先のタイトルを付記する関数であるlinkWithTitleを追加してみましょう。

data Link = Link String URI (Maybe String)
newtype URI = URI String

link :: String -> URI -> Link
link str uri = Link str uri Nothing

linkWithTitle :: String -> URI -> String -> Link
linkWithTitle str uri title = Link str uri (Just title)

*TypeCheck> :r
[1 of 1] Compiling TypeCheck        ( TypeCheck.hs, interpreted )
Ok, modules loaded: TypeCheck.

 このように型検査をうまくテストの道具として利用することで,問題自体が生じるのを防いだり,問題により近い部分で対処できます。

 ただ型を変更すると,その定義を利用する多くのコードで型エラーが生じてしまいます。このため,慣れないうちは,型を中心に考えることをわずらわしく感じるかもしれません。この問題は,let式やwhere節,型クラス,モジュールなど,Haskellで名前のスコープ(有効範囲)を操作する機能を駆使することである程度緩和できます(参考リンク)。これらをうまく利用することを考えてみてください。

 型のわずらわしさを克服し,型での静的なテストに慣れて型検査のメリットを実感するにつれて,「型を考えること自体がプログラミングである」と理解し,「型によってバグを防ぐためのテスト・コード」を当たり前のように書けるようになります。こうした感覚を身に付ければ,一人前のHaskellerになったと言えるでしょう。

 なお,今回はlinkWithTitleの型シグネチャと定義を一度に書きましたが,型シグネチャを先に記述して改めて関数の定義を書くこともできます。ただし,型シグネチャだけを書いて関数の定義を書かないと,関数定義がないことを示すエラーが表示されます。

*TypeCheck> :r
[1 of 1] Compiling TypeCheck        ( TypeCheck.hs, interpreted )

TypeCheck.hs:10:0:
    Not in scope: `linkWithTitle'
Failed, modules loaded: none.
Prelude>

 このエラーが表示された場合,通常はすぐにlinkWithTitleの定義を書くべきです。ただ,関数を当面使用しないことがはっきりしている場合には,代わりに関数定義のダミーを用意することもできます。関数定義の代わりに⊥(undefined)を使用します。

linkWithTitle :: String -> URI -> String -> Link
linkWithTitle  = undefined

*TypeCheck> :r
[1 of 1] Compiling TypeCheck        ( TypeCheck.hs, interpreted )
Ok, modules loaded: TypeCheck.

 ただし,⊥を使用すると型検査を無条件で通過させてエラーの発生を実行時まで遅らせてしまいます。このため,プログラムに問題が存在するかどうかが見えにくくなってしまいます。

*TypeCheck> render $ linkWithTitle "sample.txt" (URI "http://sample.org/") "Sample"
*** Exception: Prelude.undefined

 関数定義のダミーとして⊥を使用する場合には,後述する「HUnitなどを使った動的なテストの自動化」と必ず組み合わせるようにしてください。