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.test
とzio.test.assert
の代わりにzio.test.testM
とzio.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.fails
、zio.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: fails
とdies
の違いは 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)))
}
)