1. Overview

In this tutorial, we’ll explore the fundamentals of ZIO Test, a flexible testing framework that allows us to compare ZIO effects and literal values. We’ll walk through setting up the required dependencies and writing our first test.

Then, we’ll explore the various types of assertions ZIO Test offers and their differences. Finally, we’ll run through some of the useful pre-defined assertion functions offered as standard by the framework.

2. Setup

To use ZIO Test to unit test our applications, we need to include zio-test in our list of dependencies in build.sbt:

libraryDependencies ++= 
  Seq( "dev.zio" %% "zio-test" % "2.0.15" % Test )

Since the zio-test dependency is only required for testing, we need to make sure to add Test at the end to ensure it’s not included in the built JAR for our project.

3. Writing Our First Test

To serve as an example function for us to test, let’s define a function called returnString(), which wraps a given String in a ZIO effect:

def returnString(str: String): ZIO[Any, Nothing, String] =
  ZIO.succeed(str)

To create a test suite using ZIO Test, we should extend the ZIODefaultSpec abstract class. The convention for test classes in ZIO is to create a class ending in the word “Spec”.

Once we’ve created a Spec class that extends ZIODefaultSpec, all we have to do is override the abstract function spec() and set it to a list of test cases we want to execute:

object ExampleSpec extends ZIOSpecDefault {
  override def spec = suite("TestingApplicationsExamplesSpec")(
    test("returnString correctly returns string") {
      val testString = "Hello World!"
      for {
        output <- returnString(testString)
      } yield assertTrue(output == testString)
    }
  )
}

In this test suite, we’ve created an ExampleSpec class that extends ZIOSpecDefault. We’ve overridden the spec() function and initialized it using the test() function, providing the description of the test, along with the code block to execute, which returns a TestResult. 

Inside the test, we call returnString() within a for-comprehension and used assertTrue() to ensure that the effect returns the original String we provided to returnString().

4. Smart Assertions

Smart assertions are the preferred assertion type in ZIO Test. They allow us to assert both ZIO effects and literals. Smart assertions use assertTrue(), which takes a boolean expression where the resulting value is true when the test case passes. This can be seen in action in our previous example:

test("returnString correctly returns string") {
  val testString = "Hello World!"
  for {
    output <- returnString(testString)
  } yield assertTrue(output == testString)
}

Once we’ve got the value from our effect, we can compare it against our expected value testString using the standard boolean operators and assert the value is true using assertTrue().

5. Classic Assertions

The preferred way of asserting in ZIO Test is using assertTrue() as we mentioned in the previous section. However, there are some scenarios where smart assertions aren’t appropriate. In such scenarios, we have to use classic assertions.

5.1. Nested Assertions

Using classic assertions, we create nested assertions. These check the outer assertion, and if that’s true, it then checks the inner assertion:

assert(Some(1))(isSome(equalTo(1)))

Here, we’re checking the value of an Option[Int]. First, we check that the Option is a Some using the isSome() assertion. If that’s true, then we go on to check the value inside the Some using equalTo(1).

5.2. Logical Operations

We can also use Boolean operators to compose assertions together:

val testString = "Hello World!"
val andAssertion: Assertion[String] =
Assertion.startsWithString("Hello") && Assertion.endsWithString("World!")

for {
  output <- returnString(testString)
} yield assert(output)(andAssertion)

Here, we’re using a logical AND to compose two assertions together, so this assertion only passes when both are true. This can also be done for OR, where only one of the assertions must be true for it to pass.

6. Pre-Defined Assertions

ZIO Test provides some useful classic assertions for the standard Scala library. We’ll investigate a few in this section. Many more are available and can be found in the ZIO Test documentation.

6.1. String Assertions

There are many String assertions offered by ZIO Test. Let’s go through a few useful ones:

assert("Hello World!")(containsString("Hello")) &&
assert("Hello World!")(equalsIgnoreCase("HELLO WORLD!")) &&
assert("Hello World!")(hasSizeString(equalTo(12)))

As we can see, these are familiar to the functions available on a String. First, containsString() passes if the given String is a substring of the tested value. Then, equalsIgnoreCase() passes if the String matches, ignoring case. And finally, hasSizeString() passes if the length of the value being tested matches the length provided.

6.2. Numeric Assertions

Moving onto numeric assertions, these will work on any Numeric data type in Scala:

assert(0)(isZero) &&
assert(1)(isPositive) &&
assert(-1)(isNegative) &&
assert(102)(approximatelyEquals(100, 2))

As we can see, isZero asserts that the value is zero, while isPositive and isNegative assert that a value is either positive or negative, respectively. Next, approximatelyEquals() is a powerful assertion that allows you to assert a number is equal to the provided value within a certain tolerance.

As we can see in our example, we’ve provided a value of 100 and a tolerance of 2, so any value between 98 and 102 inclusive will pass this assertion.

6.3. Iterator Assertions

Let’s look at some of the pre-defined assertions available for all Iterable data structures:

assert(List(1, 2, 3))(contains(2)) &&
assert(List(1, 2, 3))(hasSize(equalTo((3)))) &&
assert(List(1, 2, 3))(hasNoneOf(List(5, 6)))

In the snippet above, contains() asserts that the List being tested contains the number 2. Then, hasSize() asserts the number of elements in the List. Lastly, hasNoneOf() ensures that the tested value contains none of the elements provided.

In our example, this passes as List(1, 2, 3) contains none of the elements in List(5, 6).

6.4. Either Assertions

The assertions for Either allow us to assert that an Either is one of the two types it can be, Right or Left:

assert(Right(1))(isRight) &&
assert(Left("Oh no"))(isLeft)

6.5. Boolean Assertions

Similarly to the assertions for Either, the assertions available for Boolean allow us to check the two possible states for Boolean — whether it’s true or false:

assert(true)(isTrue) &&
assert(false)(isFalse)

6.6. Option Assertions

The last set of assertions we’ll explore is for Option types:

assert(None)(isNone) &&
assert(Some(1))(isSome) &&
assert(Some(1))(isSome(equalTo(1)))

These assertions allow us to check whether the value is a Some or None, using isSome() or isNone, respectively. Using a nested assertion, we can then check the value of a Some, by nesting an equalTo() assertion within the isSome() assertion.

7. Conclusion

In this article, we’ve learned how to add ZIO Test as a dependency to our Scala projects and how to create a class to hold our test suites. We’ve discussed the differences between smart assertions and classic assertions. Then, we explored some of the pre-defined assertions available for testing standard Scala types, giving us the skills to be able to start writing tests for ZIO Scala applications.

As always, the sample code used in this article is available over on GitHub.

Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.