テストという存在の型について考えてみる

いわゆるユニットテストフレームワークに現れる各種型について考えてみる。

成果は考えない。

このエントリは考えがまとまっていない段階で書かれたものであるため、間違っている可能性があります。

成果物(の途中経過)

途中結果を素っ飛ばした作成中のものは下記に公開している。

https://github.com/pocketberserker/dog/tree/d0ad112e00c72500a2ac5df764b7109249e684de

ドッグフーディングしたいのでこの名前になっただけである*1

この記事では Scala with Scalaz と 一部 FSharp(というか Persimmon )を使って考察する。

テストケースは Kleisli

テストケースは入力値、実行事前条件、期待値、実行事後条件から構成されるはずだ。 このうち入力値、実行事前条件、実行事後条件は型の内部に隠せるものなので、たぶんよくあるテストケースはこんな感じになる。

trait TestCase {
  def run(): TestResult = {
    ???
  }
}

しかし、テスト結果を合成したい場合にはこれは圧倒的に情報が足りないので、テスト結果に値を持てるようにしたほうがよさそうである。

trait TestCase[A] {
  def run(): TestResult[A] = {
    ???
  }
}
// 型コンストラクタはおいおい
trait TestResult[A]

さて、これでTestCase#run は 引数なしで実行できTestResult[A]を返す操作が行える存在になった。

ここでちょっと考えると、引数なしというのは Unit を引数にとる、と考えられなくもない。 そうすると都合の良いことに、Kleisliの構造に一致する。

import scalaz._

type TestCase[A] = Kleisli[TestResult, Unit, A]

// ちょっと不恰好だが、以下のように実行できる
// testcase.run(())

テスト結果はMonadにできなくもない

テスト結果は多くの場合、成功か失敗かで表されるはずだ。

case class Success[A](value: A) extends TestResult[A]
case class Failure[A](reason: String) extends TestResult[A]

しかし、これは色々と大雑把な構造になっている。

  1. エラーが考慮されていない
  2. 失敗時の情報が蓄積できない
  3. "失敗"なのか"テストをスキップ(無視)した"のか判断がつかない

問題1を解決するためにエラーを持てるようにする。

case class Done[A](result: String \/ A) extends TestResult[A]
case class Error[A](e: Throwable) extends TestResult[A]

テスト実行完了か、エラーが発生したことが確認できるようになった。 この時点では明らかにMonadである。

問題2について解決を試みる。

case class Done[A](results: ValidationNel[String, A]) extends TestResult[A]
case class Error[A](e: Throwable) extends TestResult[A]

成功時に保持すべき値は最終結果ひとつで良いのに対し、失敗情報は多数保持すべきなのでValidationNelがちょうどよさそうだ。 ただし、実装次第ではMonad則を満たさなくなりそうである。

問題3が残っている。 これはValidationNelだけでは解決できそうにないので、別の型を導入する。

trait Cause
case class Violated(reason: String) extends Cause
case class Skipped(reason: String) extends Cause
trait AssertionResult[A]
case class Passed[A](value: A) extends AssertionResult[A]
case class NotPassed[A](cause: Cause) extends AssertionResult[A]
type AssertionNel[A] = NonEmptyList[AssertionResult[A]]
case class Done[A](results: AssertionNel[A]) extends TestResult[A]
case class Error[A](e: Throwable) extends TestResult[A]

Done#results に入る値を特定のものに絞り(ありえない状態を除外する、といったほうが正しい)、それ以外の入力は受け付けないようにすればTestResult[A]Monad則を満たす。 しかしその実装はMonadと呼べるのかというのは、疑問が尽きない。

Persimmon の TestResult は Monad ではない

今回はエラーだった場合はそれ以降何もしないことにしたので、Monadになる可能性があった。 しかし、実行できるアサーションはすべて実行してしまえ、という方針にするとMonad則を満たせなくなる。

Persimmon はそういう方針なので、おそらくMonad則を満たさない。

AssertionNel ~> TestResultは実装できるか

これに関しては考え方次第である。

  • テストケース名や入力値をテスト結果に持たせたいなら、無理
  • 変換時は空のデータを持たせ、Runnerで後から差し替えるならできそう

検証が足りていないのでもう少し考慮すべき項目があるかもしれない。

Persimmonに関していえば、変換できなくもない。 テスト名が空な場合にはリフレクションで変数(関数)名前を取得して入れ替える機能が備わっているためだ。

Assertion[A]は必要か

以下のような型を定義できる。

type Assertion[A] = Kleisli[AssertionResult, Unit, A]

しかし必要性を感じない。 基本的にさっさと実行されてしまうため、機能過多に思える。 これが何かに使えるか考えるなら、AssertionResult[A]の機能拡充を考えたほうがよさそうである。

TestResult[A] は Applicative であるべきか

現時点のTestResult[A] は入力値を制御しているためMonad則を満たすが、Applicativeにとどめておくべきかどうかは悩ましい問題だ。

Scalaのfor式は F# のコンピュテーション式に比べて表現に不安を感じるし、テスト失敗情報を蓄積させるならMonadにすべきではないのはその通りだ。 しかし、ScalaだとApplicativeで合成するのはそれはそれで面倒くさい作業にしかならない気もしている。

書きやすいDSLを実装する、という点ではScalaではマクロを使うことも考慮すべきなのだろうが…さてはて。

まとまらない

  • FreeやFreeApを使って何かできないか考えていたらいつのまにかこういうものに行き着いた、なぜ
  • Operational monad などの利用を考えると、やはりHaskellのライブラリを移植したほうが早いのだろうか…
  • とはいえ、Scalazの各種型で色々定義できそうということがわかっただけでも面白かったので良しとする

*1:猫の双対でco猫とかにしてみたかったけどどう見ても名前負け思想だったのでやめた