Get started with ZIO Test

Wiem Zine
5 min readDec 14, 2019

--

ZIO Test is a zero dependency Scala library for testing. It works very well using ZIO because ZIO Test provides the ability to use IOs which will be interpreted by its internal runtime implementation. However you can test your programs even if you are not using ZIO.

Step 1: Add ZIO Test to your dependencies

Checkout the last version of ZIO and add zio-test to your build.sbt

libraryDependencies ++= Seq("dev.zio" %% "zio-test" % ZIOVersion % "test",
"dev.zio" %% "zio-test-sbt" % ZIOVersion % "test"
)
testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework"))

Step 2: Write your test

1. Without ZIO

We can think about a program to test..

The program statement: A given iterator contains couples of people who are living at the same country. Every single person belong to a distinct country. Compute the countries and the population for each country.

The code is here.

We want to test this function:

def getCountries(personIt: Iterator[(Person, Person)]): Vector[Country]

We can test if we have an empty input. The expected result is an empty Vector[Country].

import zio.test.Assertion.isEmpty
import zio.test.{ DefaultRunnableSpec, assert, suite }

object PopulationTest extends DefaultRunnableSpec {
def spec = suite("Population Exercise")(
test("Empty input returns an empty output") {
val countries = Population
.getCountries(Iterator.empty)
assert(countries, isEmpty)
})
}

PopulationTest uses ZIO Test and:

  1. It extends DefaultRunnableSpec
  2. We should define the spec method (from DefaultRunnableSpec) that contains the test suites ( suite) which contains other test suites and/or test cases ( test )
  3. We check the result using assert(value, `assertionFor`(expectedValue)) . In zio.test.Assertion you can find the operations that you can use to check the expected value, in the implementation above we assert that countries isEmpty
  4. We run the test using sbt: test or test:run or testOnly PopulationTest

Let’s implement another test case:

import zio.test.Assertion._
import zio.test.{ assert, suite, test, DefaultRunnableSpec }

object PopulationTest extends DefaultRunnableSpec {
def spec =
suite("Population Exercise")(
test("Empty input returns an empty output") {
val countries = Population
.getCountries(Iterator.empty)
assert(countries, isEmpty)
},
test("All people living at the same country") {
val countries = Population
.getCountries(Iterator(Person("A") -> Person("B"), Person("C") -> Person("D"), Person("D") -> Person("A")))
assert(countries, hasSameElements(Vector(Country(1, Set(Person("A"), Person("B"), Person("C"), Person("D"))))))
assert(countries, hasSize(equalTo(1)))
})
}

Here we used other assertions - hasSize , equalTo and hasSameElements

If you would like to execute only the new test case you can execute:

testOnly PopulationTest -- -t "All people living at the same country"
  • You can also test if your program throws an exception:
suite("TestExceptions")(
test("Division by zero") {
assert(1/0, throws(isSubtype[ArithmeticException](anything)))
}
)

Using throws you assert that the value throws an exception which is a subtype of ArithmeticException. We cannot specify the specific value of that exception so we can use anything .

There are many different assertion functions that you can use, it depends to your types. For example withEither you can use isLeft and isRight and many other functions that you can check in Assertion

  • Test compile errors!!
suite("Test compile error")(
testM("Correct") {
assertM(typeCheck("val a: Int = 1"), isRight(anything))
},
testM("Wrong") {
assertM(typeCheck("val a: Int = Some(1)"),
isLeft("type mismatch;\nfound : Some[Int]\nrequired: Int\nval a: Int = Some(1)"))
}
)

Here we’re using testM because the assertion using typeCheck will return a functional effect that we can control (and not throw an exception in case of error). ZIO Test uses the effectful type built on ZIO (which is: ZIO[R, E, A]). If you don’t know about it, there is nothing to do in your side - you only have to use assertM which enables ZIO Test to check the value inside that functional effect. The value returned by typeCheck is of type: Either[String, Unit]. If your code succeeds it returns Right of Unit and if it fails it returns Left of the compiler error message.

Let’s see how could we use ZIO Test with ZIO type:

2. Using ZIO

ZIO[R, E, A] is a data type that describes an effectful program which:

  • requires an environment of type R (if not R =:= Any )
  • fails with an error of type E (if not E =:= Nothing )
  • produces a value of type A (if not A =:= Nothing )

To simplify our programs we can use the type aliases of ZIO :

type RIO[-R, +A]  = ZIO[R, Throwable, A]
type URIO[-R, +A] = ZIO[R, Nothing, A]
type IO[+E, +A] = ZIO[Any, E, A]
type UIO[+A] = ZIO[Any, Nothing, A]
type Task[+A] = ZIO[Any, Throwable, A]

Let’s see how to test ZIO using simple examples:

def divide(a: Int, b: Int): Task[Int] = Task(a/b)

In a single operation we can describe this effect using Task(a/b) , which produces a value of type Int and might fail with Throwable .

  • Successful test case:
testM("successful divide")(assertM(divide(4, 2), equalTo(2)))
  • Failed test case:
testM("failed divide")(assertM(divide(4, 0).run, fails(isSubtype[ArithmeticException](anything))))

You can also ensure if the Task fails with a specified error message:

testM("Wrong") {
assertM(Task(1/0).run, fails(hasMessage("/ by zero")))
}

hasMessage works with effects that fails with Throwable .

Note: io.run returns an effect that produces a value of type Exit[E, A] :

→ If the io succeeds with a value a, the result will be: Exit.success(a) that you can test using assertM(io.run, succeeds(a))

→ If the io fails with error , the result will be: Exit.fail(error) that you can test using: assertM(io.run, fails(error))

→ If the io dies with exception , the result will be: Exit.die(exception) that you can test using assertM(io.run, dies(throwable))

  • Unexpected behavior:
testM("surprise")(assertM(divide(4, 2).flatMap(_ => throw new Exception("💣!")).run, dies(isSubtype[Exception](anything))))
  • Test computations that time out using timeout function that is available at zio.test.TestAspect package.
testM("Long running computation should be timed out")(ZIO.never)         
@@ timeout(500.millis)

We expect that test to fail with TestTimeoutException so we can add:

testM("Long running computation should be timed out")(ZIO.never)         
@@ timeout(500.millis)
@@ failure(diesWithSubtypeOf[TestTimeoutException]),

Using ZIO Test you can test many other cases, you can even check if there is a flakiness in your test using nonFlaky which is also available at zio.test.TestAspect package. It ensures that your test is stable and more other features, you can also use generated values in your test zio.test.Gen .

Example:

testM("successful divide") {
checkM(Gen.anyInt.zip(Gen.int(1, 100))) { case (a, b) =>
assertM(Task(a / b).run, succeeds(anything))
}

There are more features in ZIO Test, you can check it out and play with it.

Thanks to Adam Fraser for this nice feature! 🤩

Note: If you are using ZIO version: 1.0.0-RC17 or older version consider to define your tests as following:

object ExampleTest extends DefaultRunnableSpec(
suite("Example")(test(assertion1), test(assertion2), ...)
https://github.com/zio/zio

--

--