How to use the same Test for different Implementations with ZIO

Pascal Mengelt
5 min readDec 22, 2019

In my last Blog I tried to decouple the Program from its Implementation — see here.

This is the second follow up to the question: How to provide general Tests to test any Implementation.
See here for the first follow up:
How to dynamically inject …

A basic understanding of ZIO and ZIO Test is expected. See for example Get started with ZIO Test by Wiem Zine

Rigi Switzerland

Scenario

Remember our Scenario:

We defined a Program in core and provided two different Implementations that did the same thing:

Load different Components and then render them to the Console.

Now we want to do the same for Testing:

Write a Test and then run it for each Implementation.

Adjust the Program

That we can test something we need to adjust our Program a bit. In essence we extract the ‘flow’ and let it return the created Components. This is the function we actually gonna test:

The Program looks now like this:

def program: ZIO[AppEnv, Nothing, Int] =
flow
.map(_ => 0)
.catchAll { x =>
console.putStrLn(s"Exception: $x") *>
UIO.effectTotal(1)
}

Remember a ZIO App needs an Int and all Exceptions must be handled (ZIO[AppEnv, Nothing, Int]).

Provide the Test

As we want to use the Test in different Implementations (Modules), we create a separate Module tests in Mill’s build.sc.

object tests extends MyModule {
override def moduleDeps = Seq(core)

override def ivyDeps = {
Agg(
libs.zio,
libs.zioTest
)
}
}

A Test in ZIO Test is always composed of Suites and Tests, where the Tests are the leafs of this tree. Again for a nice introduction check Get started with ZIO Test.

In our case one suite with one test is enough.

  • As you maybe have spotted, it is testM. That is because our test will return a functional effect (ZIO[R, E, TestResult]).
  • As we have different Environments, we have different Service Implementations, which we provide to the program (CompApp.flow.provide(env(service)).
  • Next line we Pattern Match the Result to the three Components we have created in the Program.
  • Then we use the assert functions provided by ZIO Test to validate the Components. As you can see the asserts can be concatenated with &&.

Using the Test

In our Test configuration (Mill’s build.sc), we add now the tests module as a dependency:

trait MyModuleWithTests extends MyModule {

object test extends Tests {
override def moduleDeps = super.moduleDeps :+ tests
...

In our HoconSuites we run now the test with the Service Implementation:

  • We extend DefaultRunnableSpec from ZIO Test:

A default runnable spec that provides testable versions of all of the modules in ZIO (Clock, Random, etc).

  • The general Suite from the ComponentsTests we wrap with a custom Suite. So we have an according name and the possibility to add additional Suites that are specific to our Hocon Implementation.
  • Then we run it with our HoconComps service.

This creates this tree-like output:

That’s it, no assertions needed, everything is done in the general Test.

Can we do even better

We have only tested the load function. What about render?

Well render creates only Console outputs, so normally there is no way, right?

Rigi Switzerland

With ZIO actually there is a way: we can test the Console Output!

First each environment provided by ZIO, has an equivalent test-environment. So for example:

  • Console > TestConsole; Clock > TestClock; Random > TestRandom and so on.

The TestConsole provides a default Service we can use (TestConsole.Test). So we replace in our environment Console.Live with TestConsole.

  • The TestConsole does not write to the console but to Ref[TestConsole.Data]. See ZIO Datatype Ref.

Ref[A] models a mutable reference to a value of type A. The two basic operations are set, which fills the Ref with a new value, and get, which retrieves its current content.

Let’s adjust our test with this Ref[TestConsole.Data].

for {
consoleData <- Ref.make[TestConsole.Data](Data())
r <- CompApp.flow.provide(env(service, consoleData))
..
  • In our for-comprehension we create the Ref with empty Data.
  • And provide it to the Program.

Handle the Result

In the end we get the content of the data and filter the ones that start with “RENDER”.

for {
..
content <- consoleData.get
consoleOut = content.output.filter(_.startsWith(renderOutputPrefix))
..

Testing the result is just adding more Asserts. To keep the function look sane we extract the different components and test them separately.

private def testDbConn(conn: DbConnection, output: String) = {
assert(conn, equalTo(dbConnection)) &&
assert(output,
containsString(dbConnection.name) &&
containsString(dbConnection.url) &&
containsString(dbConnection.user) &&
containsString(dbConnection.password.value)
)
}

Also cool, you can concatenate whole Asserts or only the Assertions with &&.

So the final test function is now:

By the way no changes needed in our implementation Suites!

When a Test fails

When there is an error, ZIO tells you exactly which Assertions had the problem:

And this in nice colors 😏.

Conclusion

Rigi Switzerland

The more I get involved, the more I see the benefits of ZIO and of Functional Programming in general.

I think ZIO gives me (as a simple Programmer without Ph.D.😏) the chance to get to my next level of Functional Programming.

I am really impressed of what ZIO has become in this short amount of time.

The only two downsides so far for me:

  • There is no integration in IntelliJ. You can run the tests but the output is only in the console.
  • There is no integration for Pretty diffs for case classes. I spent quite some time figuring out where the difference actually was.

References

The Project to this Blog: zio-comps-module

ZIO: https://zio.dev

ZIO Test: https://zio.dev/docs/usecases/usecases_testing

Wiem Zine’s Blog to ZIO Test: medium.com/@wiemzin/get-started…

Mill: http://www.lihaoyi.com/mill/

PureConfig: https://pureconfig.github.io

circe-yaml: https://github.com/circe/circe-yaml

YAML: https://yaml.org

HOCON: https://github.com/lightbend/config/blob/master/HOCON.md

Let me know if you have questions or stuff that can be done better!

--

--

Pascal Mengelt

Working for finnova.com in the Banking business. Prefer to work with Scala / ScalaJS.