A unit test is one of the most basic tests when we create our application. It is to test each component or function as a unit - given an input, insert input to the function, and assert the output. However, we encounter much complexity within a unit test that has to do with complex IO or side effects.
A good rule of thumb is to create a mock of that side effect that returns the value that we want. However, by doing so, we need to extract out a single component to a function to mock that operation. It can be cumbersome.
In this blog, I would like to share about how you can test asynchronous code with a single few tweaks in your function - abstracting it over type constructor.
Let’s dive into an example to illustrate what I mean.
Note: We use category type classes and cats
library later in the code if you are not familiar with category type classes here is a brief description of what it is.
Problem
Imagine we have a DBClient
, and we want to have a DBService
to do some operation that has an interaction with Database, using DBClient
.
Here is DBClient
implementation:
trait DBClient {
def get(url:String):Future[Int]
}
Here is the DBService
implementation:
class DBService(dbClient:DBClient) {
def sumAllPrice(urls:List[String]): Future[Int] = Future.traverse(urls)(dbClient.get).map(_.sum)
}
Now, if we want to test sumAllPrice
we can create a stub of DBClient
.
class TestDBClient extends DBClient {
override def get(url:String): Future[Int] = Future.successful{1}
}
How can we test sumAllPrice
in the unit test?
Since it is asynchronous, we need to either have a test case that can receive the asynchronous result, however, if there is also a way to mitigate this problem by making the DBClient
more generic and abstract it out to a type constructor.
Action
We can solve this in a couple of ways. The first one is to refactor the code into a type constructor. In the second one, I want to change the code to a type class pattern.
Abstracting Over Type Constructor
We make DBClient
to receive a type constructor F[_]
type.
trait DBClient[F[_]] {
def get(url:String):F[Int]
}
Note: You need to import Higher Kinded Types in your application.
It means that get(url:String)
returns any constructor type. It can be Future[Int]
or a List[Int]
.
We use Cats
library to generate an asynchronous code for the production version and synchronous code for the test version.
Cats
library has a Monad type id
which allow types to wraps into a type constructor without changing their meaning:
package cats
type Id[A] = A
We have TestDBClient
trait which uses for unit testing, and ProdDBClient
trait which uses for the main code:
import cats.Id
trait TestDBClient extends DBClient[Id]
trait ProdDBClient extends DBClient[Future]
Then, we abstract the DBService
over type constructor too.
import cats.implicits._
class DBService[F[_]:Applicative](dbClient:DBClient[F]) {
def sumAllPrice(urls:List[String]): F[Int] = urls.traverse(dbClient.get).map(_.sum)
}
F[_]: Applicative
is syntactic sugar, context-bound, for having an implicit
value of ap: Applicative[F]
.
The above function is the same as class DBService[F[_]](dbClient:DBClient[F])(implicit ap:Applicative[F])
Here we make the type constructor be an Applicative because traverse
only works on a sequence of values that has an Applicative
. In the context of Future
it has an Applicative
, and it results in List[Future[Int]]
. However, by abstracting over type constructor, List[F[Int]]
, we need to prove to the compiler that the value has an Applicative
when passing into the function.
In this case, we make the type constructor in DBClient
to not bound to any specific context so that it can easily use in other services. However, we restrict the context of DBService
because it needs to have Applicative
to do traverse
operation.
The second method is abstracting over type constructor but using type class pattern.
Using Type Class
There are 3 things that we need to do to define a Type class:
- Type Class
- Type Instances
- Interface Syntax, Interface Object
We will define DBClient
as a type class:
trait DBClient[F[_]] {
def get(url:String): F[Int]
}
Then we will defined the instances. The instance object is where we put the ProdDBClient
and TestDBClient
.
object DBClientInstances {
implicit val getFutureInstance: DBClient[Future] = new DBClient[Future] {
override def get(url: String): Future[Int] = ???
}
implicit val getIdInstance:DBClient[Id] = new DBClient[Id] {
override def get(url: String): Id[Int] = ???
}
}
Lastly, we create interface object, DBService
and inject our instances in the sumAllPrice
:
object DBService {
def sumAllPrice[F[_]:Applicative](urls:List[String])(implicit dbClient:DBClient[F]): F[Int] = urls.traverse(dbClient.get).map(_.sum)
}
We also restrict our type constructor to have an Applicative
to use traverse
.
Why didn’t we use Monad
over here and Applicative
instead?
It is because Monad
is more restricted, the subtype of Applicative
, in the type class hierarchy, and for the current function Applicative
can do the job. We don’t need to restrict the incoming element to Monad
as we can have a broader range of behavior with Applicative
, and fewer laws to obey (no flatMap
). Therefore, the caller can make a broader range of behavior when implementing DBService
.
Takeaway
- We can test asynchronous code by abstracting our application with a type constructor.
- When abstracting your application with type constructor, it is an excellent practice to make the type constructor have a minimal restriction for behavior that needed for the current implementation. For instance,
DBClient
is not restricted to having any context, whereasDBService
is restricted to haveApplicative
because we want to be able to usetraverse
in the type constructor.
All the source code in this tutorial are here.