Free モナドの利点として、プログラムの「記述と実行1の分離」がよく言われる。つまりモナドの選択を遅らせることで、テスト時と本番で異なるモナドを使うことができる。
とはいえ、実際どんな感じになるのか示したコード例をあまり見たことがない。というわけで前の記事の続きとして試しに書いて見た。
(Scala Worksheet をここに置いた)
テスト対象
テスト対象のコードは、cats 公式ドキュメントの Composing Free monads ADTs のコードサンプル を少し改変した、以下のように、ADT、リフト、組み立てられた全体の3部分からなる。
ADT - 振る舞いの代数的表現
ConsoleOp
型はユーザとのやりとりを Ask
と Tell
で表現したもので、MqOp
型はメッセージキューへの送信を Publish
で表している。この二つを、前の記事で紹介した iota を使って一個のCopK
に合成して、CatsApp
とした。
sealed trait ConsoleOp[+A] // ユーザインタラクション
case class Ask(prompt: String) extends ConsoleOp[String]
case class Tell(msg: String) extends ConsoleOp[Unit]
sealed trait MqOp[+A] // MQ 操作
case class Publish(message: String) extends MqOp[Unit]
type CatsApp[A] = CopK[ConsoleOp ::: MqOp ::: TNilK, A]
リフト - 代数から Free への変換
下のようにして ADT から Free
への関数にリフト2している。次に示すように、これらの関数を使ってビジネスロジックを組み立てる。3
class Console[F[_] <: iota.CopK[_, _]](implicit I: CopK.Inject[ConsoleOp, F]) {
def ask(prompt: String): Free[F, String] = inject[ConsoleOp, F](Ask(prompt))
def tell(msg: String): Free[F, Unit] = inject[ConsoleOp, F](Tell(msg))
}
object Console {
implicit def interacts[F[_] <: iota.CopK[_, _]](implicit I: CopK.Inject[ConsoleOp, F]): Console[F] =
new Console[F]
}
class MQOperations[F[_] <: iota.CopK[_, _]](implicit I: CopK.Inject[MqOp, F]) {
def publish(message: String): Free[F, Unit] = inject[MqOp, F](Publish(message))
}
object MQOperations {
implicit def mqOperation[F[_] <: iota.CopK[_, _]](implicit I: CopK.Inject[MqOp, F]): MQOperations[F] =
new MQOperations[F]
}
ドメインロジック − 振る舞いの全体像
以下のような一連の振る舞いを考える。
- まずユーザに猫の名前を聞いて、
- 与えられたそれを MQに送ってから、
- 同じものをユーザにエコーする
def echoCats[A](implicit I: Console[CatsApp], M: MQOperations[CatsApp])
: Free[CatsApp, Unit] = for {
cat <- I.ask("kitty's name?")
_ <- M.publish(cat)
_ <- I.tell(cat)
} yield ()
echoCats
は、これを Free
として組み立てて関数の実行結果として返す。テストコードでは、この振る舞いにから派生するシナリオを検証する。
テストコード
共通的に使うことを想定した小さなテストフレームワークと、それを CatsService.echoCats
のテストに適用したコードの、二つに分けて説明する。
テストフレームワーク
まず以下のような型を考える。
type Step = (CatsApp[Any], Any) // テストシナリオのステップ
type Scenario = List[Step] // ステップから構成されるテストシナリオ
type Result[A] = Either[String, A] // ステップごとの結果
type Operations[T] = StateT[Result, Scenario, T] // テストの進行状況
Step
は、ある時点でテスト対象が評価することを期待する ADTと、その結果としてテストシナリオ側から模擬的に与える関数出力のペアになる。テストシナリオは一連のStep
を並べたもので、Scenario
型のデータとして表現される。
Result
は、テストが失敗した時の文字列と、成功したときの最終計算結果をEither
で表したもので、これを StateT
に合成して Operations
とする。
実環境で echoCats
を動作させるときは、モナドとして Future
か何かを指定することになるが、ここではテスト環境用のモナドとして StateT
を利用してみた。プログラムにシナリオを与えながら、実行状態を蓄積して記録している。
以下のようなシナリオ実行コードで、Scenario
で表現された振る舞いに Operations
モナドを与えて実行する。
def validate[A](e: CatsApp[Any])(a: CatsApp[A])(r: Any): Operations[A] = lift(
Either.cond(e == a, r.asInstanceOf[A], s"expected ${e.value}, but got ${a.value}"))
def proceed[A](actual: CatsApp[A]): Scenario => Operations[A] = {
case Nil => lift(Left("no more steps"))
case (expected, result) :: rest => for {
e <- validate(expected)(actual)(result)
_ <- set[Result, Scenario](rest)
} yield e
}
val scenarioInterpreter = new (CatsApp ~> Operations) {
def apply[A](fa: CatsApp[A]) = for {
steps <- get[Result, Scenario]
next <- proceed[A](fa)(steps)
} yield next
}
// シナリオを実行する関数
def scenarioRunner(dsl: Free[CatsApp, Unit])(steps: List[(Product, Any)]) = {
def inject[A[_]](l: A[Any], r: Any)(implicit i: CopK.Inject[A, CatsApp]) =
(CopK.Inject[A, CatsApp].inj(l), r):(CatsApp[Any], Any)
val scenario = steps.map {
case (l, r) => l match {
case l1: ConsoleOp[_] => inject[ConsoleOp](l1, r)
case l2: MqOp[_] => inject[MqOp] (l2, r)
}
}
dsl.foldMap(scenarioInterpreter).run(scenario)
}
アサーションの部分は省略しているが、ふつうに既存のテスティングフレームワークと併用すれば良い。
もっと抽象度をあげて汎用的にできなくもないが、簡単のために CatsApp
型のみに対応したコードに留めた。また、ここでは完全一致でのみの比較としたが、マッチャー的なものを使うと便利かもしれない。
シナリオと実行
上記の scenarioRunner
に echoCats
だけを部分適用し、テスト対象である echoCats
の振る舞いごとに、残りの引数をシナリオとして与る。
val echoCatsRunner = scenarioRunner(CatsService.echoCats) _
以下が echoCats
にシナリオを与えたもの。
echoCatsRunner(List(
Ask("kitty's name?") -> "kuro",
Publish("kuro") -> (),
Tell("kuro") -> ()
))
// res0: Result[(Scenario, Unit)] = Right((List(),()))
echoCatsRunner(List(
Ask("kitty's name?") -> "kuro",
Publish("shiro") -> (),
Tell("shiro") -> ()
))
// res1: Result[(Scenario, Unit)] = Left(expected Publish(shiro), but got Publish(kuro))
echoCatsRunner(List(
Ask("kitty's name?") -> "kuro",
Publish("kuro") -> ()
))
// res2: Result[(Scenario, Unit)] = Left(no more steps)
最初のシナリオが成功したテストで、Either
のRight
が返されている。
2番目と3番目のものは、デモのためにあえて誤りのあるテストシナリオとした。実行結果として、「"shiro"のはずなのに"kuro"を publishしていた」とか、「シナリオが足りなかった」などの状況がLeft
としてレポートされる。4
##結論
Free モナドの合成として表現されたドメイン層のビジネスロジックのテストは、State と Eitherを組み合わせたモナドを使ってシナリオと照合するやり方ができそう。