osiire’s blog

ふしぎなそふとやさん

第一級モジュールで単体テスト可能なコードを保つ

コードを単体テスト可能なよう保つためには色々なやり方がある訳ですが、一番いいのは副作用を分離して局所に押し込めておく手法でしょう。でも、処々の事情によりそんなに綺麗に分離する事もできないので、次善の策としては副作用部分をパラメーター化して別のモックに置き換え可能にしておくというやつがある訳です。そしてOCamlではそのパラメーター化の方法にも幾つかやり方があって、次の4通りの方法が考えられます。

  1. 副作用を行う部分を関数化して引数として渡す。
  2. 副作用を伴う部分が複数ある時はレコードにして渡す。
  3. レコードの代わりにファンクター経由で渡す。
  4. ファンクターの代わりに第一級モジュールで渡す。


例えばこういうコードを単体テスト可能にしたいとします。(単純化のため省略した関係で殆どなんのロジックもない関数になっていますが、実際にはもう少し条件分岐が入ったコードだと思ってください。)

(* 単体テスト可能にしたい関数。このままでは副作用があるのでできない。 *)
let take_candles paramter =
  with_db (fun db ->   (* DBハンドラを受け取るローンパターン *)
    let hist_candles = 
      load_candles paramter  (* データベースから情報をロード *)
    in
    match hist_candles with
      [] -> load_realtime_candle db paramter  (* 条件によっては別の情報をロード *)
    | _ -> hist_candles)

もちろん、この関数はデータベースがないと動きません。このままでは回帰単体テストできません。まず1.の方法を採用するとすると、widh_db, load_candles, load_realtime_candleの3個の関数をtake_candles関数の引数に渡す事になります。3個も引数増えるのかよ!面倒ですね。レコードにしましょう。すると2.の方法になる訳ですが、実際のDBアクセスをテスト用のモックに切り替えようとするとちょっと困った問題が出ます。

type db_access = {  (* レコード化したDBアクセス *)
   with_db : 'a. (db_connection -> 'a) -> 'a;
   load_candles : db_connection -> parameter -> candle list;
   load_realtime_candle : db_connection -> parameter -> candle list;
}

db_connection型はPostgreSQLなどDBへのハンドラを想定しています。これが邪魔で、単体テスト用のモックを作りたいのに本物のDBハンドラを用意しないければインスタンス化できないという事態になります。困った困った。じゃぁ、db_connectionをレコードの中に隠すとどうでしょう。

type db_access = {  (* DBへのハンドラを隠した版 *)
   load_candles : parameter -> candle list;
   load_realtime_candle : parameter -> candle list;
   close : unit -> unit
}

db_connection型を隠した影響で、ローンパターンのwith_db関数は書けなくなりました。しかもwith_db関数で制御していたハンドラのクローズも出来なくなったので、明示的にハンドラを閉じるclose関数を追加する必要も出てきました。これは正直嬉しくない。そこでファンクターの登場です。ファンクターなら型も一緒に抽象化できるので、db_connection型も一緒に抽象化してしまいましょう。

module type DBAccess = sig
  type db_connection (* DBアクセスを想定した抽象型 *)
  val with_db : (db_connection -> 'a) -> 'a
  val load_candles : db_connection -> parameter -> candle list
  val load_realtime_candle : db_connection -> parameter -> candle list
end

module Make( D:DBAccess ) = struct
  let take_candles paramter =
    D.with_db (fun db ->   (* DBハンドラを受け取るローンパターン *)
      let hist_candles = 
        D.load_candles paramter  (* データベースから情報をロード *)
      in
      match hist_candles with
        [] -> D.load_realtime_candle db paramter  (* 条件によっては別の情報をロード *)
      | _ -> hist_candles)
end

これでめでたしな感じがしますが、実用的にはちょっと問題が。実際にはtake_candles関数も単体で存在している訳じゃなくて、他の複数の関数と一緒にどこかのモジュールの一部な訳です。そんな一つの関数の都合のためにモジュール全体がファンクターとなったのでは、take_candles関数を含むモジュールの関数を利用している個所全部でファンクター適用しないといけなくなり、単体テストのためのコード変更が余計な複雑さを生みます。そこで最後に登場するのが第一級モジュールです。第一級モジュールならこう書けます。

module type DBAccess = sig
  type db_connection (* DBアクセスを想定した抽象型 *)
  val with_db : (db_connection -> 'a) -> 'a
  val load_candles : db_connection -> parameter -> candle list
  val load_realtime_candle : db_connection -> parameter -> candle list
end

let take_candles loader paramter =
  let module D = 
    (val loader : DBAccess)  (* 第一級モジュールを受け取る! *)
  in 
  D.with_db (fun db ->   (* DBハンドラを受け取るローンパターン *)
    let hist_candles = 
      D.load_candles paramter  (* データベースから情報をロード *)
    in
    match hist_candles with
      [] -> D.load_realtime_candle db paramter  (* 条件によっては別の情報をロード *)
    | _ -> hist_candles)

let test1 paramter = (* take_canldes関数をテストする関数 *)
  let module DBMoc = struct
    type db_connection = unit  (* どうせ使わないのでunit型 *)
    let with_db f = f ()
    let load_candles = [] (* なにかテストデータを作って入れる *)
    let load_realtime_candle = []
  end in
  let result = 
    take_candles (module DBMoc:DBAccess) parameter
  in
  assert ( result = ... )  (* テスト判定 *)

モックを作る時はdb_connectionを適当な型にできる上に、take_candles関数の引数を一つ増やすだけで対応できています。
まとめです。

  1. 次善の策として副作用をパラメーター化しよう。
  2. パラメーター化するには高階関数かレコード。
  3. 型が要るならファンクターか第一級モジュール。


ちなみに、db_connection型を型パラメーター化する別解もあります。

type 'connection db_access = {  (* db_connectionを型パラメーター化 *)
   with_db : 'a. ('connection -> 'a) -> 'a;
   load_candles : 'connection -> parameter -> candle list;
   load_realtime_candle : 'connection -> parameter -> candle list;
}

今回はこれでもいいと思います。でも、http://www.math.nagoya-u.ac.jp/~garrigue/papers/ocamlum2010.pdfの「型安全なプラグイン」にあるように、異なる型のdb_accessをリストで扱いたくなると、型パラメーター版では対応できなくなります。加えて、外部に公開する必要のない具体的な型が外に露出してしまうので、private関数をpublic関数にしている的な気持ち悪さが残ります。