Skip to main content
Version: 1.0.18

How to Mock Services?

How to test interactions between services?

Whenever possible, we should strive to make our functions pure, which makes testing such function easy - you just need to assert on the return value. However in larger applications there is a need for intermediate layers that delegate the work to specialized services.

For example, in a HTTP server the first layer of indirection are so called routes, whose job is to match the request and delegate the processing to downstream layers. Often below there is a second layer of indirection, so called controllers, which consist of several business logic units grouped by their domain. In a RESTful API that would be all operations on a certain model. The controller to perform its job might call on further specialized services for communicating with the database, sending email, logging, et cetera.

If the job of the capability is to call on another capability, how should we test it?

Hidden outputs

A pure function is such a function which operates only on its inputs and produces only its output. Command-like methods, by definition are impure, as their job is to change state of the collaborating object (performing a side effect). For example:

import scala.concurrent.Future

def processEvent(event: Event): Future[Unit] = Future(println(s"Got $event"))

The signature of this method Event => Future[Unit] hints us we're dealing with a command. It returns Unit (well, wrapped in future, but it does not matter here), you can't do anything useful with Unit and it does not contain any information. It is the equivalent of returning nothing. It is also an unreliable return type, as when Scala expects the return type to be Unit it will discard whatever value it had (for details see Section 6.26.1 of the Scala Language Specification), which may shadow the fact that the final value produced (and discarded) was not the one you expected.

Inside the future there may be happening any side effects. It may open a file, print to console, connect to databases. We simply don't know. Let's have a look how this problem would be solved using ZIO's effect system:

import zio._
import zio.console.Console

def processEvent(event: Event): URIO[Console, Unit] =
console.putStrLn(s"Got $event").orDie

With ZIO, we've regained to ability to reason about the effects called. We know that processEvent can only call on capabilities of Console, so even though we still have Unit as the result, we have narrowed the possible effects space to a few.

Note: this is true assuming the programmer disciplines themselves to only perform effects expressed in the type signature. There is no way (at the moment) to enforce this by the compiler. There is some research done in this space, perhaps future programming languages will enable us to further constrain side effects.

However, the same method could be implemented as:

def processEvent2(event: Event): URIO[Console, Unit] =
ZIO.unit

How can we test it did exactly what we expected it to do?

Mocking

In this sort of situations we need mock implementations of our collaborator service. As Martin Fowler puts it in his excellent article Mocks Aren't Stubs:

Mocks are (...) objects pre-programmed with expectations which form a specification of the calls they are expected to receive.

ZIO Test provides a framework for mocking your modules.

Creating a mock service

We'll be assuming you've read about modules and layers in the contextual types guide. In the main sources we define the service, a module alias and capability accessors. In test sources we're defining the mock object which extends zio.test.mock.Mock which holds capability tags and compose layer.

// main sources

import zio.stream.{ ZSink, ZStream }
import zio.test.mock._

type Example = Has[Example.Service]

object Example {
trait Service {
val static : UIO[String]
def zeroArgs : UIO[Int]
def zeroArgsWithParens() : UIO[Long]
def singleArg(arg1: Int) : UIO[String]
def multiArgs(arg1: Int, arg2: Long) : UIO[String]
def multiParamLists(arg1: Int)(arg2: Long) : UIO[String]
def command(arg1: Int) : UIO[Unit]
def overloaded(arg1: Int) : UIO[String]
def overloaded(arg1: Long) : UIO[String]
def function(arg1: Int) : String
def sink(a: Int) : ZSink[Any, String, Int, Int, List[Int]]
def stream(a: Int) : ZStream[Any, String, Int]
}
}
// test sources

object ExampleMock extends Mock[Example] {
object Static extends Effect[Unit, Nothing, String]
object ZeroArgs extends Effect[Unit, Nothing, Int]
object ZeroArgsWithParens extends Effect[Unit, Nothing, Long]
object SingleArg extends Effect[Int, Nothing, String]
object MultiArgs extends Effect[(Int, Long), Nothing, String]
object MultiParamLists extends Effect[(Int, Long), Nothing, String]
object Command extends Effect[Int, Nothing, Unit]
object Overloaded {
object _0 extends Effect[Int, Nothing, String]
object _1 extends Effect[Long, Nothing, String]
}
object Function extends Method[Int, Throwable, String]
object Sink extends Sink[Any, String, Int, Int, List[Int]]
object Stream extends Stream[Any, String, Int]

val compose: URLayer[Has[Proxy], Example] = ???
}

A capability tag is just a value which extends the zio.test.mock.Capability[R <: Has[_], I, E, A] type constructor, where:

  • R is the type of environment the method belongs to
  • I is the type of methods input arguments
  • E is the type of error it can fail with
  • A is the type of return value it can produce

The Capability type is not publicly available, instead you have to extend Mock dependent types Effect, Method, Sink or Stream.

We model input arguments according to following scheme:

  • for zero arguments the type is Unit
  • for one or more arguments, regardless in how many parameter lists, the type is a TupleN where N is the size of arguments list

Note: we're using tuples to represent multiple argument methods, which follows with a limit to max 22 arguments, as is Scala itself limited.

For overloaded methods we nest a list of numbered objects, each representing subsequent overloads.

Finally we need to define a compose layer that can create our environment from a Proxy. A Proxy holds the mock state and serves predefined responses to calls.

import ExampleMock._

val compose: URLayer[Has[Proxy], Example] =
ZLayer.fromServiceM { proxy =>
withRuntime.map { rts =>
new Example.Service {
val static = proxy(Static)
def zeroArgs = proxy(ZeroArgs)
def zeroArgsWithParens() = proxy(ZeroArgsWithParens)
def singleArg(arg1: Int) = proxy(SingleArg, arg1)
def multiArgs(arg1: Int, arg2: Long) = proxy(MultiArgs, arg1, arg2)
def multiParamLists(arg1: Int)(arg2: Long) = proxy(MultiParamLists, arg1, arg2)
def command(arg1: Int) = proxy(Command, arg1)
def overloaded(arg1: Int) = proxy(Overloaded._0, arg1)
def overloaded(arg1: Long) = proxy(Overloaded._1, arg1)
def function(arg1: Int) = rts.unsafeRunTask(proxy(Function, arg1))
def sink(a: Int) = rts.unsafeRun(proxy(Sink, a).catchAll(error => UIO(ZSink.fail[String, Int](error))))
def stream(a: Int) = rts.unsafeRun(proxy(Stream, a))
}
}
}

Note: The withRuntime helper is defined in Mock. It accesses the Runtime via ZIO.runtime and if you're on JS platform, it will replace the executor to an unyielding one.

A reference to this layer is passed to capability tags so it can be used to automatically build environment for composed expectations on multiple services.

Note: for non-effectful capabilities you need to unsafely run the final effect to satisfy the required interface. For ZSink you also need to map the error into a failed sink as demonstrated above.

Complete example

// main sources

import zio._
import zio.console.Console
import zio.test.mock._

type AccountObserver = Has[AccountObserver.Service]

object AccountObserver {
trait Service {
def processEvent(event: AccountEvent): UIO[Unit]
def runCommand(): UIO[Unit]
}

def processEvent(event: AccountEvent) =
ZIO.accessM[AccountObserver](_.get.processEvent(event))

def runCommand() =
ZIO.accessM[AccountObserver](_.get.runCommand)

val live: ZLayer[Console, Nothing, AccountObserver] =
ZLayer.fromService[Console.Service, Service] { console =>
new Service {
def processEvent(event: AccountEvent): UIO[Unit] =
for {
_ <- console.putStrLn(s"Got $event").orDie
line <- console.getStrLn.orDie
_ <- console.putStrLn(s"You entered: $line").orDie
} yield ()

def runCommand(): UIO[Unit] =
console.putStrLn("Done!").orDie
}
}
}
// test sources

object AccountObserverMock extends Mock[AccountObserver] {

object ProcessEvent extends Effect[AccountEvent, Nothing, Unit]
object RunCommand extends Effect[Unit, Nothing, Unit]

val compose: URLayer[Has[Proxy], AccountObserver] =
ZLayer.fromService { proxy =>
new AccountObserver.Service {
def processEvent(event: AccountEvent) = proxy(ProcessEvent, event)
def runCommand(): UIO[Unit] = proxy(RunCommand)
}
}
}

Note: ZIO provides some useful macros to help you generate repetitive code, see Scrapping the boilerplate with macros.

Provided ZIO services

For each built-in ZIO service you will find their mockable counterparts in zio.test.mock package:

  • MockClock for zio.clock.Clock
  • MockConsole for zio.console.Console
  • MockRandom for zio.random.Random
  • MockSystem for zio.system.System

Setting up expectations

To create expectations we use the previously defined capability tags:

import zio.test.Assertion._
import zio.test.mock.Expectation._
import zio.test.mock.MockSystem

val exp01 = ExampleMock.SingleArg( // capability to build an expectation for
equalTo(42), // assertion of the expected input argument
value("bar") // result, that will be returned
)

For methods that take input, the first argument will be an assertion on input, and the second the predefined result.

In the most robust example, the result can be either a successful value or a failure. To construct either we must use one of following combinators from zio.test.mock.Expectation companion object:

  • failure[E](failure: E) Expectation result failing with E
  • failureF[I, E](f: I => E) Maps the input arguments I to expectation result failing with E.
  • failureM[I, E](f: I => IO[E, Nothing]) Effectfully maps the input arguments I to expectation result failing with E.
  • never Expectation result computing forever.
  • unit Expectation result succeeding with Unit.
  • value[A](value: A) Expectation result succeeding with A.
  • valueF[I, A](f: I => A) Maps the input arguments I to expectation result succeeding with A.
  • valueM[I, A](f: I => IO[Nothing, A]) Effectfully maps the input arguments I expectation result succeeding with A.

For methods that take no input, we only define the expected output.

val exp02 = ExampleMock.ZeroArgs(value(42))

For methods that may return Unit, we may skip the predefined result (it will default to successful value) or use unit helper.

import zio.test.mock.MockConsole

val exp03 = MockConsole.PutStrLn(equalTo("Welcome to ZIO!"))
val exp04 = MockConsole.PutStrLn(equalTo("Welcome to ZIO!"), unit)

For methods that may return Unit and take no input we can skip both:

val exp05 = AccountObserverMock.RunCommand()

Finally we're all set and can create ad-hoc mock environments with our services.

import zio.test._

val event = new AccountEvent {}
val app: URIO[AccountObserver, Unit] = AccountObserver.processEvent(event)
val mockEnv: ULayer[Console] = (
MockConsole.PutStrLn(equalTo(s"Got $event"), unit) ++
MockConsole.GetStrLn(value("42")) ++
MockConsole.PutStrLn(equalTo("You entered: 42"))
)

We can combine our expectation to build complex scenarios using combinators defined in zio.test.mock.Expectation:

  • andThen (alias ++) Compose two expectations, producing a new expectation to satisfy both sequentially.
  • and (alias &&) Compose two expectations, producing a new expectation to satisfy both in any order.
  • or (alias ||) Compose two expectations, producing a new expectation to satisfy only one of them.
  • repeated Repeat expectation within given bounds, produces a new expectation to satisfy itself sequentially given number of times.
  • atLeast Lower-bounded variant of repeated, produces a new expectation to satisfy itself sequentially at least given number of times.
  • atMost Upper-bounded variant of repeated, produces a new expectation to satisfy itself sequentially at most given number of times.
  • optional Alias for atMost(1), produces a new expectation to satisfy itself at most once.

Providing mocked environment

object AccountObserverSpec extends DefaultRunnableSpec {
def spec = suite("processEvent")(
testM("calls putStrLn > getStrLn > putStrLn and returns unit") {
val result = app.provideLayer(mockEnv >>> AccountObserver.live)
assertM(result)(isUnit)
}
)
}

Mocking unused collaborators

Often the dependency on a collaborator is only in some branches of the code. To test the correct behaviour of branches without depedencies, we still have to provide it to the environment, but we would like to assert it was never called. With the Mock.empty method you can obtain a ZLayer with an empty service (no calls expected).

object MaybeConsoleSpec extends DefaultRunnableSpec {
def spec = suite("processEvent")(
testM("expect no call") {
def maybeConsole(invokeConsole: Boolean) =
ZIO.when(invokeConsole)(console.putStrLn("foo"))

val maybeTest1 = maybeConsole(false).provideLayer(MockConsole.empty)
val maybeTest2 = maybeConsole(true).provideLayer(MockConsole.PutStrLn(equalTo("foo")))
assertM(maybeTest1)(isUnit) *> assertM(maybeTest2)(isUnit)
}
)
}

Mocking multiple collaborators

In some cases we have more than one collaborating service being called. You can create mocks for rich environments and as you enrich the environment by using capability tags from another service, the underlaying mocked layer will be updated.

import zio.console.Console
import zio.random.Random
import zio.test.mock.MockRandom

val combinedEnv: ULayer[Console with Random] = (
MockConsole.PutStrLn(equalTo("What is your name?")) ++
MockConsole.GetStrLn(value("Mike")) ++
MockRandom.NextInt(value(42)) ++
MockConsole.PutStrLn(equalTo("Mike, your lucky number today is 42!"))
)

val combinedApp =
for {
_ <- console.putStrLn("What is your name?")
name <- console.getStrLn.orDie
num <- random.nextInt
_ <- console.putStrLn(s"$name, your lucky number today is $num!")
} yield ()

val result = combinedApp.provideLayer(combinedEnv)
assertM(result)(isUnit)

Polymorphic capabilities

Mocking polymorphic methods is also supported, but the interface must require zio.Tag implicit evidence for each type parameter.

// main sources
type PolyExample = Has[PolyExample.Service]

object PolyExample {
trait Service {
def polyInput[I: Tag](input: I): Task[String]
def polyError[E: Tag](input: Int): IO[E, String]
def polyOutput[A: Tag](input: Int): Task[A]
def polyAll[I: Tag, E: Tag, A: Tag](input: I): IO[E, A]
}
}

In the test sources we construct partially applied capability tags by extending Method.Poly family. The unknown types must be provided at call site. To produce a final monomorphic Method tag we must use the of combinator and pass the missing types.

// test sources
object PolyExampleMock extends Mock[PolyExample] {

object PolyInput extends Poly.Effect.Input[Throwable, String]
object PolyError extends Poly.Effect.Error[Int, String]
object PolyOutput extends Poly.Effect.Output[Int, Throwable]
object PolyAll extends Poly.Effect.InputErrorOutput

val compose: URLayer[Has[Proxy], PolyExample] =
ZLayer.fromServiceM { proxy =>
withRuntime.map { rts =>
new PolyExample.Service {
def polyInput[I: Tag](input: I) = proxy(PolyInput.of[I], input)
def polyError[E: Tag](input: Int) = proxy(PolyError.of[E], input)
def polyOutput[A: Tag](input: Int) = proxy(PolyOutput.of[A], input)
def polyAll[I: Tag, E: Tag, A: Tag](input: I) = proxy(PolyAll.of[I, E, A], input)
}
}
}
}

Similarly, we use the same of combinator to refer to concrete monomorphic call in our test suite when building expectations:

import PolyExampleMock._

val exp06 = PolyInput.of[String](equalTo("foo"), value("bar"))
val exp07 = PolyInput.of[Int](equalTo(42), failure(new Exception))
val exp08 = PolyInput.of[Long](equalTo(42L), value("baz"))

val exp09 = PolyAll.of[Int, Throwable, String](equalTo(42), value("foo"))
val exp10 = PolyAll.of[Int, Throwable, String](equalTo(42), failure(new Exception))

More examples

You can find more examples in the examples and test-tests subproject: