[ZIO] zio.test.mock.Mockでサービスのモックをセットアップする

ZIOのドキュメントを参考にzio.testのモック機能を試してみました。
2021.04.29

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

前回のZIOレイヤーの記事の続きです。今回はzio-testで提供されているMockを使ってテストを記述します。なお、今回の内容を含む詳細なドキュメントは「How to Mock Services ?」 で提供されています。

おさらい: FizzBuzzGame

前回例として使ったFizzBuzzGameは以下のようにFizzBuzzSeedGenに依存していました。

def fizzbuzz: ZIO[FizzBuzzSeedGen, Nothing, FizzBuzzAnswer] = for {
    r <- ZIO.accessM[FizzBuzzSeedGen](_.get.randomOne)
    ans = r.num match {
      case _ if r.num % 3 == 0 && r.num % 5 == 0 => FizzBuzz(r.num)
      case _ if r.num % 3 == 0 => Fizz(r.num)
      case _ if r.num % 5 == 0 => Buzz(r.num)
      case _ => Just(r.num)
    }
  } yield ans

素朴なモック

さて現在のFIzzBuzzSeedGenの実装は乱数を用いるものです。テストでは任意の値を入力としてfizzbuzzのロジックをテストしたいので次のようなFizzBuzzSeedGenの素朴なモックを定義してみます。fizzbuzzのようにサービスのメソッドが1つだけの場合これで十分です。

test("fizzbuzz with simple mock") {
    //素朴なモックを使ってfizzbuzzを実行する
    def fizzbuzz(num: Long): FizzBuzzAnswer =
      zio.Runtime.default.unsafeRun(
        FizzBuzzGame.fizzbuzz.provideLayer(
          ZLayer.succeed(
            new FizzBuzzSeedGen.Service {
              override def randomOne: UIO[FizzBuzzInput] = UIO(FizzBuzzInput(num))
            })
        ))

    assertEquals(fizzbuzz(1), Just(1))
    assertEquals(fizzbuzz(2), Just(2))
    assertEquals(fizzbuzz(3), Fizz(3))
    assertEquals(fizzbuzz(4), Just(4))
    assertEquals(fizzbuzz(5), Buzz(5))
    assertEquals(fizzbuzz(15), FizzBuzz(15))
  }

しかしモック対象のAPIがもっと複雑になったり、モックを必要とするテストが増えた場合に毎回これを実装するのは骨が折れます。

zio.test.mockを使う

そこでzio.testのモック機能を使います。先に例を示します。

test("fizzbuzz with zio.test.mock.Mock") {
    //インストラクション可能なモック(zio.test.mock.Mock)
    object MockFizzBuzzSeedGen extends Mock[FizzBuzzSeedGen] {
      val compose: URLayer[Has[Proxy], FizzBuzzSeedGen] = ZLayer.fromService { proxy =>
        new FizzBuzzSeedGen.Service {
          override def randomOne: UIO[FizzBuzzInput] = proxy(RandomOne)
        }
      }

      object RandomOne extends Effect[Unit, Nothing, FizzBuzzInput]
    }

    def fizzbuzz(num: Long): FizzBuzzAnswer = zio.Runtime.default.unsafeRun(
      FizzBuzzGame.fizzbuzz.provideLayer(MockFizzBuzzSeedGen.RandomOne(value(FizzBuzzInput(num)))
      ))

    assertEquals(fizzbuzz(1), Just(1))
    assertEquals(fizzbuzz(2), Just(2))
    assertEquals(fizzbuzz(3), Fizz(3))
    assertEquals(fizzbuzz(4), Just(4))
    assertEquals(fizzbuzz(5), Buzz(5))
    assertEquals(fizzbuzz(15), FizzBuzz(15))
  }

モックを作成するにはMock traitの型パラメータにモックしたいサービスを指定して、composeをオーバーライドします。composeはProxyを入力にしてモック対象を返すレイヤーです。レイヤーが返すモック実装では各メソッドをproxyに移譲します。ここでproxyに渡しているオブジェクトは各メソッドのケイパビリティ(※ドキュメントではCapabilityと記載されているのですが、ここではメソッドのシグネチャと考えてもいいかと思います)をエンコードしたものです (zio.test.mock.Capability[R <: Has[_], I, E, A])。Capabilityの各型パラメータは以下の通りで、例にあるEffectはCapability[[R, I, E, A](notion://www.notion.so/cmkazup0n/self)]のエイリアスです。

R is the type of environment the method belongs to
I is the type of methods input arguments
E is the type of error it can fail with
A is the type of return value it can produce

モックへのインストラクションはモックオブジェクト内に定義された各APIのケイパビリティに対して行います。上記の例だとMockFizzBuzzSeedGen.RandomOne(value(FizzBuzzInput(num)) がそれにあたります。今回の例では引数を取らないメソッドでしたが、引数のアサーションが必要な場合は指定できます。複数のモックへのインストラクションや、複数回のインタラクションをセットアップすることもできます。以下の例ではメソッド呼び出しごとに異なる値を順に返すようにセットアップしています。これ以外にもいくつかの呼び出しパターンをセットアップできるAPIが提供されています

//メソッド呼び出しごとに1 -> 2の順で結果を返す
MockFizzBuzzSeedGen.RandomOne(value(FizzBuzzInput(1)) ++
MockFizzBuzzSeedGen.RandomOne(value(FizzBuzzInput(2))

セットアップしたモック(Expectation)はtoLayerでレイヤーに変更して使用できます。また以下のimplicit メソッドが定義されているので暗黙的に変換できます。

implicit def toLayer[R <: Has[_]: Tag](trunk: Expectation[R]): ULayer[R] =
    ZLayer.fromManagedMany(
      for {
        state <- Managed.make(MockState.make(trunk))(MockState.checkUnmetExpectations)
        env   <- (ProxyFactory.mockProxy(state) >>> trunk.mock.compose).build
      } yield env
    )

@mockableマクロ

ここまで紹介してきたMock traitによるモックは@mockable(zio.test.mock.mockable)マクロアノテーションによって生成できます。@accessibleアノテーションと同様にボイラープレートを大幅に削減できるので実際にZIOを使う場合には使用するのがいいと思います。

@mockable(FizBuzzSeedGen.Service)
object MockFizzBuzzSeedGen

まとめ

ZIOのドキュメントを参考にzio.testのモック機能を試してみました。ZIOはエフェクトコンテナだけでなくDIやテストの仕組みを提供していてcats EffectやMonixのようなライブラリとの相互運用もできるのでいろいろな適用例を試してみたいです。