1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-12-07

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

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

(Scala Worksheet をここに置いた)

テスト対象

テスト対象のコードは、cats 公式ドキュメントの Composing Free monads ADTs のコードサンプル を少し改変した、以下のように、ADT、リフト、組み立てられた全体の3部分からなる。

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]      // 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]
}

ドメインロジック − 振る舞いの全体像

以下のような一連の振る舞いを考える。

  1. まずユーザに猫の名前を聞いて、
  2. 与えられたそれを MQに送ってから、
  3. 同じものをユーザにエコーする
  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 型のみに対応したコードに留めた。また、ここでは完全一致でのみの比較としたが、マッチャー的なものを使うと便利かもしれない。

シナリオと実行

上記の scenarioRunnerechoCats だけを部分適用し、テスト対象である 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)

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

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

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

  1. あるいは compile, または interpret

  2. CopK なので API的には lift ではなく inject になるが

  3. かなりのボイラープレートなのでなんとかしたい。できれば Freestyle とかで。

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

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?