zio / zio-mock

https://zio.dev/zio-mock
Apache License 2.0
27 stars 33 forks source link
mock scala testing zio zio-test

ZIO Mock

Development CI Badge Sonatype Releases Sonatype Snapshots javadoc ZIO Mock

Installation

In order to use this library, we need to add the following line in our build.sbt file:

libraryDependencies += "dev.zio" %% "zio-mock" % "1.0.0-RC12"

The Problem

Whenever possible, we should strive to make our functions pure, which makes testing such function easy. So we 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 an HTTP server, the first layers of indirection are so-called routes, whose job is to match the request and delegate the processing to downstream layers. Below this layer, there is often a second layer of indirection, so-called controllers, which comprises 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, etc.

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

Let's say we have a Userservice defined as follows:

import zio._

trait UserService {
  def register(username: String, age: Int, email: String): IO[String, Unit]
}

object UserService {
  def register(username: String, age: Int, email: String): ZIO[UserService, String, Unit] =
    ZIO.serviceWithZIO(_.register(username, age, email))
}

The live implementation of the UserService has two collaborators, EmailService and UserRepository:

trait EmailService {
  def send(to: String, body: String): IO[String, Unit]
}

case class User(username: String, age: Int, email: String)

trait UserRepository {
  def save(user: User): IO[String, Unit]
}

Following is how the live version of UserService is implemented:

case class UserServiceLive(emailService: EmailService, userRepository: UserRepository) extends UserService {
  override def register(username: String, age: Int, email: String): IO[String, Unit] =
    if (age < 18) {
      emailService.send(email, "You are not eligible to register!")
    } else if (username == "admin") {
      ZIO.fail("The admin user is already registered!")
    } else {
      for {
        _ <- userRepository.save(User(username, age, email))
        _ <- emailService.send(email, "Congratulation, you are registered!")
      } yield ()
    }
}

object UserServiceLive {
  val layer: URLayer[EmailService with UserRepository, UserService] =
    ZLayer.fromFunction(UserServiceLive.apply _)
}

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:

The signature of register method (String, Int, String) => IO[String, Unit] hints us we're dealing with a command. It returns Unit (well, wrapped in the IO, but it does not matter here). We 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][link-sls-6.26.1] of the Scala Language Specification), which may shadow the fact that the final value produced (and discarded) was not the one we expected.

Inside the IO there may be a description of any side effects. It may open a file, print to the console, or connect to databases. So the problem is "How is it possible to test a service along with its collaborators"?

In this example, the register method has a service call to its collaborators, UserRepository and EmailService. So, how can we test the live version of UserService.register while it has some side effects in communicating with its collaborators?

Mockists would probably claim that testing how collaborators are called during the test process allows us to test the UserService. Let's move on to the next section and see the mockists' solution in greater detail.

The Solution

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][link-test-doubles]:

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

So to test the register function, we can mock the behavior of its two collaborators. So instead of using production objects, we use pre-programmed mock versions of these two collaborators with some expectations. In this way, in each test case, we expect these collaborators will be called with expected inputs.

In this example, we can define these three test cases:

  1. If we register a user with an age of less than 18, we expect that the save method of UserRepository shouldn't be called. Additionally, we expect that the send method of EmailService will be called with the following content: "You are not eligible to register."
  2. If we register a user with a username of "admin", we expect that both UserRepository and EmailService should not be called. Instead, we expect that the register call will be failed with a proper failure value: "The admin user is already registered!"
  3. Otherwise, we expect that the save method of UserRepository will be called with the corresponding User object, and the send method of EmailService will be called with this content: "Congratulation, you are registered!".

ZIO Test provides a framework for mocking our modules. In the next section, we are going to test UserService by mocking its collaborators.

Mocking Collaborators

In the previous section, we learned we can test the UserService by mocking its collaborators. Let's see how we can mock the EmailService and also the UserRepository.

We should create a mock object by extending Mock[EmailService] (zio.mock.Mock). Then we need to define the following objects:

  1. Capability tags — They are value objects which extend one of the Capability[R, I, E, A] data types, such as Effect, Method, Sink, or Stream. For each of the service capabilities, we need to create an object extending one of these data types. They encode the type of environments, arguments (inputs), the error channel, and also the success channel of each capability of the service.

For example, to encode the send capability of EmailService we need to extend the Effect capability as bellow:

  object Send extends Effect[(String, String), String, Unit]
  1. Compose layer — In this step, we need to provide a layer in which used to construct the mocked object. In order to do that, we should obtain the Proxy data type from the environment and then implement the service interface (i.e. EmailService) by wrapping all capability tags with proxy.

Let's see how we can mock the EmailService:

// Test Sources
import zio._
import zio.mock._

object MockEmailService extends Mock[EmailService] {
  object Send extends Effect[(String, String), String, Unit]

  val compose: URLayer[Proxy, EmailService] =
    ZLayer {
      for {
         proxy <- ZIO.service[Proxy]
      } yield new EmailService {
        override def send(to: String, body: String): IO[String, Unit] =
          proxy(Send, to, body)
      }
    }
}

And, here is the mock version of the UserRepository:

object MockUserRepository extends Mock[UserRepository] {
  object Save extends Effect[User, String, Unit]

  val compose: URLayer[Proxy, UserRepository] =
    ZLayer {
      for {
        proxy <- ZIO.service[Proxy]
      } yield new UserRepository {
        override def save(user: User): IO[String, Unit] =
          proxy(Save, user)
      }
    }
}

Testing the Service

After writing the mock version of collaborators, now we can use their capability tags to convert them to the Expectation, and finally create the mock layer of the service.

For example, we can create an expectation from the Send capability tag of the MockEmailService:

import zio.test._

val sendEmailExpectation: Expectation[EmailService] =
  MockEmailService.Send(
    assertion = Assertion.equalTo(("john@doe", "You are not eligible to register!")),
    result = Expectation.unit
  )

The sendEmailExpectation is an expectation, which requires a call to send method with ("john@doe", "You are not eligible to register!") arguments. If this service will be called, the returned value would be unit.

There is an extension method called Expectation#toLayer which implicitly converts an expectation to the ZLayer environment:

import zio.test._

val mockEmailService: ULayer[EmailService] =
  MockEmailService.Send(
    assertion = Assertion.equalTo(("john@doe", "You are not eligible to register!")),
    result = Expectation.unit
  ).toLayer

So we do not require to convert them to ZLayer explicitly. It will convert them whenever required.

  1. Now let's test the first scenario discussed in the solution section:

If we register a user with an age of less than 18, we expect that the save method of UserRepository shouldn't be called. Additionally, we expect that the send method of EmailService will be called with the following content: "You are not eligible to register."


test("non-adult registration") {
  val sut              = UserService.register("john", 15, "john@doe")
  val liveUserService  = UserServiceLive.layer
  val mockUserRepo     = MockUserRepository.empty
  val mockEmailService = MockEmailService.Send(
    assertion = Assertion.equalTo(("john@doe", "You are not eligible to register!")),
    result = Expectation.unit
  )

  for {
    _ <- sut.provide(liveUserService, mockUserRepo, mockEmailService)
  } yield assertTrue(true)
}

We used MockUserRepository.empty since we expect no call to the UserRepository service.

  1. The second scenario is:

If we register a user with a username of "admin", we expect that both UserRepository and EmailService should not be called. Instead, we expect that the register call will be failed with a proper failure value: "The admin user is already registered!"


test("user cannot register pre-defined admin user") {
  val sut = UserService.register("admin", 30, "admin@doe")

  for {
    result <- sut.provide(
      UserServiceLive.layer,
      MockEmailService.empty,
      MockUserRepository.empty
    ).exit
  } yield assertTrue(
    result match {
      case Exit.Failure(cause)
        if cause.contains(
          Cause.fail("The admin user is already registered!")
        ) => true
      case _ => false
    }
  )
}
  1. Finally, we have to check the happy path scenario:

We expect that the save method of UserRepository will be called with the corresponding User object, and the send method of EmailService will be called with this content: "Congratulation, you are registered!".


test("a valid user can register to the user service") {
  val sut              = UserService.register("jane", 25, "jane@doe")
  val liveUserService  = UserServiceLive.layer
  val mockUserRepo     = MockUserRepository.Save(
    Assertion.equalTo(User("jane", 25, "jane@doe")),
    Expectation.unit
  )
  val mockEmailService = MockEmailService.Send(
    assertion = Assertion.equalTo(("jane@doe", "Congratulation, you are registered!")),
    result = Expectation.unit
  )

  for {
    _ <- sut.provide(liveUserService, mockUserRepo, mockEmailService)
  } yield assertTrue(true)
}

Documentation

Learn more on the ZIO Mock homepage!

Contributing

For the general guidelines, see ZIO contributor's guide.

Code of Conduct

See the Code of Conduct

Support

Come chat with us on Badge-Discord.

License

License