I've been trying to grasp IO monad for a while now and it makes sense. If I'm not mistaken, the goal is to split the description of the side effect and the actual execution. As in the example below, Scala has a way to get an environment variable which is not referentially transparent. Two questions arose.
Question 1: Is this one referentially transparent
Question 2: How to properly (unit/property-based) test this? It's not possible to check for equality because it will check for memory reference and it's not possible to check the inner function because function comparison is not possible if I'm not mistaken. But, I don't want to run the actual side effect in my unit test. Also, is this a design mistake or a misuse of the IO monad?
case class EnvironmentVariableNotFoundException(message: String) extends Exception(message)
object Env {
def get(envKey: String): IO[Try[String]] = IO.unit.flatMap((_) => IO.pure(tryGetEnv(envKey)))
private[this] def tryGetEnv(envKey: String): Try[String] =
Try(System.getenv(envKey))
.flatMap(
(x) =>
if (x == null) Failure(EnvironmentVariableNotFoundException(s"$envKey environment variable does not exist"))
else Success(x)
)
}
It's good to use IO
to wrap up values in your program that have come from an impure source, just like the System call in your example. This will give you back an IO[A]
which reads as "I am able to obtain an A
via impure means". Thereafter you can use pure / referentially transparent functions that act on that A
, via map
, flatMap
etc.
This leads to two answers. I'd ask, what property of this are you trying to test?
Looking at that code I notice the flatMap
in tryGetEnv
as something that may be complicated enough to warrant testing. You could do that by extracting this logic into a pure function. You might (for example) re-write this so there's a function that returns an IO[String]
and then write a (tested) function that converts this into the type you want.
IO does exactly what you say, but this explicitly does not include making code referentially transparent! If you want to test the actual side-effects here you might consider passing System as an argument and mocking it for a test, just like you would in a program that doesn't use IO
.
So in summary, I'd consider creating a minimal function that does the call to System
to create an IO[A]
(in this case, an IO[Try[String]]
). You might choose to test this minimal function via mocking, but only if you feel you're adding value by doing so. Around this, you can write functions that take an A
and test those by passing pure values to those functions. Remember, the signature for map
on IO
is the following, and the f
here is a pure (testable) function!
sealed abstract class IO[+A] {
def map[B](f: A => B): IO[B]
^ f is a pure function!
test it by passing A values and verifying the Bs
Taken to its extreme, this pattern encourages you to create IO
values only at the very edge of your program (e.g. your main
function). The rest of your program can then be created from pure functions that act on the values that will come from the IO types when the program runs.