Freeモナドベースのドメインコードのテスト試案

Free モナドの利点として、プログラムの「記述と実行[^execution]の分離」がよく言われる。つまりモナドの選択を遅らせることで、テスト時と本番で異なるモナドを使うことができる。

とはいえ、実際どんな感じになるのか示したコード例をあまり見たことがない。というわけで[前の記事](https://qiita.com/yasuabe1362/items/4202dd51469e5e56ddd1)の続きとして試しに書いて見た。

(Scala Worksheet を[ここ](https://gist.github.com/yasuabe/50bcbfc7dacd8f243f245d7ea5bb5a51)に置いた)

[^execution]: あるいは compile, または interpret

## テスト対象
テスト対象のコードは、cats 公式ドキュメントの [Composing Free monads ADTs のコードサンプル](https://typelevel.org/cats/datatypes/freemonad.html#composing-free-monads-adts) を少し改変した、以下のように、ADT、リフト、組み立てられた全体の3部分からなる。

### ADT - 振る舞いの代数的表現
`ConsoleOp` 型はユーザとのやりとりを `Ask` と `Tell` で表現したもので、`MqOp` 型はメッセージキューへの送信を `Publish`で表している。この二つを、[前の記事](https://qiita.com/yasuabe2613/items/4202dd51469e5e56ddd1)で紹介した [iota](https://github.com/frees-io/iota) を使って一個の`CopK`に合成して、`CatsApp` とした。

```scala
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` への関数にリフト[^lift]している。次に示すように、これらの関数を使ってビジネスロジックを組み立てる。[^freestyle]

```scala
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]
}
```
[^freestyle]: かなりのボイラープレートなのでなんとかしたい。できれば Freestyle とかで。
[^lift]: `CopK` なので API的には `lift` ではなく `inject` になるが

### ドメインロジック − 振る舞いの全体像
以下のような一連の振る舞いを考える。

1. まずユーザに猫の名前を聞いて、
2. 与えられたそれを MQに送ってから、
3. 同じものをユーザにエコーする

```scala
  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` のテストに適用したコードの、二つに分けて説明する。

### テストフレームワーク
まず以下のような型を考える。

```scala
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` モナドを与えて実行する。

```scala
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` の振る舞いごとに、残りの引数をシナリオとして与る。

```scala
val echoCatsRunner = scenarioRunner(CatsService.echoCats) _
```

以下が `echoCats` にシナリオを与えたもの。

```scala
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`が返されている。

２番目と３番目のものは、デモのためにあえて誤りのあるテストシナリオとした。実行結果として、「"shiro"のはずなのに"kuro"を publishしていた」とか、「シナリオが足りなかった」などの状況が`Left`としてレポートされる。[^compromise]

[^compromise]: シナリオのステップが多すぎた場合は残ったステップとともに `Right` が帰ってくるが、正直面倒になって妥協した。

##結論
Free モナドの合成として表現されたドメイン層のビジネスロジックのテストは、State と Eitherを組み合わせたモナドを使ってシナリオと照合するやり方ができそう。