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..
- Let’s take the program of the previous blog post as an example.
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:
- It extends
DefaultRunnableSpec
- We should define the
spec
method (fromDefaultRunnableSpec
) that contains the test suites (suite
) which contains other test suites and/or test cases (test
) - We check the result using
assert(value, `assertionFor`(expectedValue))
. Inzio.test.Assertion
you can find the operations that you can use to check the expected value, in the implementation above we assert thatcountries
isEmpty
- We run the test using sbt:
test
ortest:run
ortestOnly 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 notR =:= Any
) - fails with an error of type
E
(if notE =:= Nothing
) - produces a value of type
A
(if notA =:= 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 atzio.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), ...)