LoginSignup
4
3

More than 3 years have passed since last update.

ZIO Test

Posted at

ZIO TestはZIOをベースにしたテストライブラリです。この記事ではZIOテストの書き方を見ていきます。

この記事のソースコードはこちらです。

テスト・コードの書き方・基本編

テストを書くためにはzio.test.DefaultRunnableSpecクラスを継承します。

zio.test.DefaultRunnableSpecはコンストラクタでZSpecを受け取ります。ZSpecはテスト仕様を表現する型です。ZSpecの値を生成するにはzio.test.test関数を使用します。zio.test.test関数はラベルとテスト・コードを引数にとります。テスト・コード中でテスト対象のロジックを実行し結果をチェックします。

import zio.test.{DefaultRunnableSpec, _}

object SingleSpec extends DefaultRunnableSpec(
  test("test single") {
    ??? // テスト・コード。テスト対象のロジックを実行して結果をassertする
  }
)

複数のテストケースをひとまとめにするにはzio.test.suite関数を使用します。zio.test.suite関数は表示用のラベルとテストのリストを受け取ります。zio.test.suiteでは第2引数のカッコが丸かっこ(であることに注意しましょう。波かっこ{を使用するとBlock式になってリストが渡せなくなります。

import zio.test.{DefaultRunnableSpec, _}

object MultipleTestSpec extends DefaultRunnableSpec(
  suite("multiple test cases")(
    test("test case 1")(???),
    test("test case 2")(???)
  )
)

副作用のないコードのテスト

ZIO Testではテスト対象のコードが副作用のないコードか、副作用のあるコード(戻り値の型がZIOモナド)かでコードの書き方が少し変わります。前述のzio.test.test関数は副作用のないテストコードを生成するための関数です。

zio.test.testのテストコード内では副作用のないコードの実行結果をzio.test.assert関数でチェックします。zio.test.assert関数は第1引数にチェックしたい対象の値を第2引数に値に対する表明zio.test.Assertionを取ります。

import zio.test._
import zio.test.Assertion._

object SpecWithoutEffect extends DefaultRunnableSpec(
  test("42 is the answer to ultimate question of life") {
    val answerToUltimateQuestion = 42
    assert(answerToUltimateQuestion, equalTo(42))
  }
)

副作用のあるコードのテスト

副作用のあるコードをテストするにはzio.test.testzio.test.assertの代わりにzio.test.testMzio.test.assertM(*1) を使用します。

import zio.UIO
import zio.test._
import zio.test.Assertion._

object SpecWithSideEffect extends DefaultRunnableSpec(
  testM("waiting 7.5 million years for the answer"){
    val answerToUltimateQuestion: UIO[Int] = UIO(42)
    assertM(answerToUltimateQuestion, equalTo(42))
  }
)

成功時の値ではなく失敗に関するテストを書くためにはZIOモナドのrunメソッドでエラー情報にアクセスし
zio.test.Assertion.failszio.test.Assertion.dies関数で表明を行います。(*2)

import zio.{Exit, UIO, ZIO}
import zio.test.Assertion._
import zio.test.{DefaultRunnableSpec, _}

object FailureSpec extends DefaultRunnableSpec(
  suite("Testing effects")(
    testM("testing failure"){
      // runメソッドで失敗`Exception`情報にアクセスする。
      val result: UIO[Exit[Exception, Nothing]] = ZIO.fail(new Exception("failure")).run
      assertM(result, fails(anything))
    },
    testM("testing cause")(
      assertM(ZIO.die(new Exception("die")).run, dies(anything))
    )
  )
)

テスト・コードの書き方・実践編

ここまでZIO Testを使用したテストの基本的な書き方(副作用のない場合&ある場合)を見てきました。これ以降では実際のテストを書くときに役立ちそうな情報をいくつか紹介します。

表明Assertionについて

値に対する表明は40種類以上用意されています。ここでは数種類の表明をコードで紹介します。

import zio.ZIO
import zio.test.Assertion._
import zio.test.{DefaultRunnableSpec, _}

object AssertionsSpec extends DefaultRunnableSpec(
  suite("Assertions")(
    test("testing equality") {
      assert(42, equalTo(42))
    },
    test("testing approximation") {
      assert(42.0, approximatelyEquals(42.1, 0.1))
    },
    test("string starts with") {
      assert("abc", startsWith("ab"))
    },
    test("string ends with") {
      assert("abc", endsWith("bc"))
    },
    test("string comparison with case insensitive manner") {
      assert("ABC", equalsIgnoreCase("abc"))
    },
    test("test to throw an exception") {
      assert(throw new Exception("exception"), throws(anything))
    },
    testM("testing type of failure") {
      object MyError extends Exception("MyError")
      assertM(ZIO.fail(MyError).run, fails(isSubtype[Exception](anything)))
    }
  )
)

ここで上げた表明以外にも、コレクション型・Option型に対する表明など便利なものが多数用意されています。

テスト結果(assert) & 表明(Assertion)の合成

テスト結果(assert)や値に対する表明Assertionは合成できます。

テスト結果は、否定(negation)、論理和(logical disjunction)、論理積(logical conjunction)、論理包含(implication)で合成可能です。

import zio.ZIO
import zio.test.Assertion._
import zio.test.{DefaultRunnableSpec, _}

object CombinationOfTestResultSpec extends DefaultRunnableSpec(
  suite("Combination of TestResult")(
    test("negating a test result") {
      !assert(42, equalTo("forty two"))
    },
    test("logical conjunction of 2 test results") {
      assert(42, equalTo(42)) && assert("forty two", equalTo("forty two"))
    },
    test("logical disjunction of 2 test results") {
      val x = 42
      assert(x, equalTo(42)) || assert(x, equalTo(43))
    },
    test("logical implication of test results") {
      val x = 42

      def isEven(z: Int) = z % 2 == 0

      assert(x, equalTo(42)) ==> assert(isEven(x), isTrue)
    },
    test("logical implication of test results take2") {
      val x = 41

      def isEven(z: Int) = z % 2 == 0

      assert(x, equalTo(42)) ==> assert(isEven(x), isTrue)
    }
  )
)

表明は否定(negation)、論理和(logical disjunction)、論理積(logical conjunction)で合成可能です。

import zio.ZIO
import zio.test.Assertion._
import zio.test.{DefaultRunnableSpec, _}

object CombinationOfAssertionSpec extends DefaultRunnableSpec(
  suite("Combination of Assertion")(
    test("negating an assertion") {
      assert(42, not(equalTo(43)))
    },
    test("logical conjunction of 2 assertions") {
      assert(
        "ZIO Test is a zero dependency testing library",
        startsWith("ZIO") && endsWith("library")
      )
    },
    test("logical disjunction of 2 assertions") {
      assert(42, equalTo(42) || equalTo(43))
    }
  )
)

テスト時の実行環境について

ZIOモナドの実行に実行環境が必要です。ZIO Testではテストコードはzio.test.environment.TestEnvironment環境で実行されます。TestEnvironmentは通常のZIOの実行環境(Clockなど)がテスト版の実装TestClockに置き換えられています。そのため実行環境に依存したコードをテストする場合は注意が必要です。

以下、Clockに依存したテストケースです。テスト用TestClockは自身で時刻を進めないため、このテストは終了しません。

import zio.ZIO
import zio.clock.Clock
import zio.duration._
import zio.test.Assertion._
import zio.test._

object BadSpecWithEnvironment extends DefaultRunnableSpec(
  suite("Spec with Environment")(
    testM("this test never finishes") {

      val logicUnderTest: ZIO[Clock, Nothing, Int] =  for {
        _ <- zio.clock.sleep(200.milliseconds)
      } yield 42

      assertM(logicUnderTest, equalTo(42))
    }
  )
)

このテストを終了させるためにはTestClockの時刻を手動で調整する、または、通常のZIOの実行環境を使用する必要があります。手動で時刻を調整するためにはTestClock.adjustメソッドを、通常のZIOの実行環境を利用するにはzio.test.environment.Live.live関数を使用します。

import zio.ZIO
import zio.clock.Clock
import zio.duration._
import zio.test.Assertion._
import zio.test._

object GoodSpecWithEnvironment extends DefaultRunnableSpec(
  suite("Spec with Environment")(
    testM("test with clock manually adjust time")(
      assertM(for {
        fiber <- zio.clock.sleep(200.milliseconds).fork
        _ <- TestClock.adjust(200.milliseconds)
        _ <- fiber.join
      } yield 42, equalTo(42))
    ),
    testM("use Live version of clock") {
      assertM(
        for {
          _ <- zio.test.environment.Live.live(zio.clock.sleep(200.milliseconds))
        } yield 42,
        equalTo(42)
      )
    }
  )
)

アプリケーション固有の環境

ZIOの実行環境のほかにアプリケーション固有の環境でテストを実行できます。

以下DBアクセスが必要なアプリケーションのテストの模擬コードです。アプリケーション固有のDatabaseAccessにZIO Testのテスト環境TestEnvironmentをmixinした環境をテストへ提供します。こうすることでテストコードがDatabaseAccessを使用できます。

import CustomEnvironmentSpecUtil.DatabaseAccess
import zio.{Managed, RIO, Task, UIO, ZIO}
import zio.test.Assertion._
import zio.test.environment.TestEnvironment
import zio.test.{DefaultRunnableSpec, _}

object CustomEnvironmentSpecUtil {

  trait DatabaseAccess {
    val databaseAccess: DatabaseAccess.Service
  }

  object DatabaseAccess {

    trait Service {
      def query: Task[Int]
    }

    val make: Managed[Nothing, DatabaseAccess] = Managed.make(UIO(
      new DatabaseAccess {
        override val databaseAccess: Service = new Service {
          override def query: Task[Int] = UIO(42)
        }
      }
    ))(_ => UIO.unit)
  }

  // create a Environment
  val databaseAccessEnvironment: Managed[Nothing, DatabaseAccess with TestEnvironment] = for {
    testEnvironment <- TestEnvironment.Value
    databaseAccessService <- DatabaseAccess.make
  } yield new TestEnvironment(
    blocking = testEnvironment.blocking,
    clock = testEnvironment.clock,
    console = testEnvironment.console,
    live = testEnvironment.live,
    random = testEnvironment.random,
    sized = testEnvironment.sized,
    system = testEnvironment.system
  ) with DatabaseAccess {
    override val databaseAccess: DatabaseAccess.Service = databaseAccessService.databaseAccess
  }
}

object CustomEnvironmentSpec extends DefaultRunnableSpec(
  testM("testing a logic with DatabaseAccess") {
    val logicWithDatabaseAccess: RIO[DatabaseAccess, Boolean] = for {
      q <- ZIO.accessM[DatabaseAccess] {
        _.databaseAccess.query
      }
    } yield q == 42

    assertM(logicWithDatabaseAccess, isTrue)
  }.provideManaged(CustomEnvironmentSpecUtil.databaseAccessEnvironment)
)

Test Aspectによるテスト制御

特定のテストをテスト対象から外す、テストをタイムアウトさせるなど、Test Aspectを利用するとテストの挙動を制御できます。

import zio.test.Assertion._
import zio.test._
import zio.duration._

object SpecWithAspect extends DefaultRunnableSpec(
  suite("Spec with aspect")(
    test("ignore test case which always fails due to bug"){
      assert(42, equalTo(43))
    }@@TestAspect.ignore,
    testM("timeout test if it takes too long"){
      zio.clock.sleep(60.minutes).map(r => assert(r, isUnit))
    }@@TestAspect.timeout(3.second)
  )
)

最後に

この記事ではZIO Testを利用したテストについて以下のことを紹介しました。

  • 副作用のないコードのテスト
  • 副作用のあるコードのテスト
  • 表明Assertionについて
  • テスト結果と表明の合成
  • テスト時の実行環境について
  • アプリケーション固有の環境
  • Test Aspectによるテスト制御

ZIO TestはZIOで作られたアプリケーションをテストするための最良の選択肢の1つです。皆さんもぜひ試してください。

参考URL

Footnote

*1: zio.test.assertMはZIOモナドの値に表明をmapするための糖衣構文でzio.test.assertでも副作用のあるコードに対して表明可能です。
*2: failsdiesの違いは ZIOのエラー・モデルとエラー処理を参照してください。

object SpecWithSideEffectForFootNote extends DefaultRunnableSpec(
  testM("waiting 7.5 million years for the answer") {
    val answerToUltimateQuestion: UIO[Int] = UIO(42)
    answerToUltimateQuestion.map(v => assert(v, equalTo(42)))
  }
)
4
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
4
3