Scala
TDD
DDD
FunctionalProgramming
関数型プログラミング

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

Free モナドの嬉しいところとしてよく言われるのが、ビジネスロジックの「記述」と「実行(あるいは compile, interpret)」が分離できるという点で、例えばモナドの選択を遅らせることでテスト時と本番で別々のモナドを使うことができることなどがよく言われます。

ただ実際どんな感じになるのか示したコード例をあまり見たことがなかったので、前の記事の続きとして試しに自分で書いて見ました。

テスト対象

テスト対象のコードは以下のようなもので、cats のドキュメントのにあるFreeコード例を少しいじったものです。(※ 簡単のために一部省略してますが、動くコードとして IntelliJ の Worksheet を gistに 載せておきました。)

ADT

ConsoleOp型はユーザとのやりとりをAskTellで表現したもので、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]
case class Publish(message: String) extends MqOp[Unit]

type CatsApp[A] = CopK[ConsoleOp ::: MqOp ::: TNilK, A]

リフト

上述の ADTを Freeを返す関数にリフトするところです(CopKなので API的には liftじゃなくて injectですが)。呼び出し側ではこれらの関数を使って、ビジネスロジックを組み立てます。

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とかか。)

ドメインサービス

object CatsService {
  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 ()
}
  1. まずユーザに猫の名前を聞いて、
  2. 与えられたそれを MQに送ってから、
  3. 同じものをユーザにエコーする

という一連の振る舞いが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としています。

実環境でCatsService.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型のみに対応したコードに留めてます。また、ここでは完全一致でのみの比較ですが、マッチャー的なものを使うと便利かもしれません。

シナリオと実行

上記のscenarioRunnerCatsService.echoCatsだけを部分適用し、テストしたいCatsService.echoCatsの振る舞いごとに、残りの引数をシナリオとして与えます。

val echoCatsRunner = scenarioRunner(CatsService.echoCats) _

以下が CatsService.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)

最初のシナリオが成功したテストで、EitherRightが返されています。

2番目と3番目のものは、デモのためにあえて誤りのあるテストシナリオとしました。実行結果として、「"shiro"のはずなのに"kuro"を publishしていた」とか、「シナリオが足りなかった」などの状況がLeftとしてレポートされています。(シナリオのステップが多すぎた場合は残ったステップとともにRightが帰ってきてしまいますが、正直面倒になって妥協しました。)

結論

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