Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Unit-testing with cats-effect's IO monad

The Scenario

In an application I am currently writing I am using cats-effect's IO monad in an IOApp.

If started with a command line argument 'debug', I am delegeting my program flow into a debug loop that waits for user input and executes all kinds of debugging-relevant methods. As soon as the developer presses enter without any input, the application will exit the debug loop and exit the main method, thus closing the application.

The main method of this application looks roughly like this:

import scala.concurrent.{ExecutionContext, ExecutionContextExecutor}
import cats.effect.{ExitCode, IO, IOApp}
import cats.implicits._

object Main extends IOApp {

    val BlockingFileIO: ExecutionContextExecutor = ExecutionContext.fromExecutor(blockingIOCachedThreadPool)

    def run(args: List[String]): IO[ExitCode] = for {
        _ <- IO { println ("Running with args: " + args.mkString(","))}
        debug = args.contains("debug")
        // do all kinds of other stuff like initializing a webserver, file IO etc.
        // ...
        _ <- if(debug) debugLoop else IO.unit
    } yield ExitCode.Success

    def debugLoop: IO[Unit] = for {
      _     <- IO(println("Debug mode: exit application be pressing ENTER."))
      _     <- IO.shift(BlockingFileIO) // readLine might block for a long time so we shift to another thread
      input <- IO(StdIn.readLine())     // let it run until user presses return
      _     <- IO.shift(ExecutionContext.global) // shift back to main thread
      _     <- if(input == "b") {
                  // do some debug relevant stuff
                  IO(Unit) >> debugLoop
               } else {
                  shutDown()
               }
    } yield Unit

    // shuts down everything
    def shutDown(): IO[Unit] = ??? 
}

Now, I want to test if e.g. my run method behaves like expected in my ScalaTests:

import org.scalatest.FlatSpec

class MainSpec extends FlatSpec{

  "Main" should "enter the debug loop if args contain 'debug'" in {
    val program: IO[ExitCode] = Main.run("debug" :: Nil)
    // is there some way I can 'search through the IO monad' and determine if my program contains the statements from the debug loop?
  }
}

My Question

Can I somehow 'search/iterate through the IO monad' and determine if my program contains the statements from the debug loop? Do I have to call program.unsafeRunSync() on it to check that?

like image 770
Florian Baierl Avatar asked Oct 17 '22 06:10

Florian Baierl


2 Answers

You could implement the logic of run inside your own method, and test that instead, where you aren't restricted in the return type and forward run to your own implementation. Since run forces your hand to IO[ExitCode], there's not much you can express from the return value. In general, there's no way to "search" an IO value as it just a value that describes a computation that has a side effect. If you want to inspect it's underlying value, you do so by running it in the end of the world (your main method), or for your tests, you unsafeRunSync it.

For example:

sealed trait RunResult extends Product with Serializable
case object Run extends RunResult
case object Debug extends RunResult

def run(args: List[String]): IO[ExitCode] = {
  run0(args) >> IO.pure(ExitCode.Success)
}

def run0(args: List[String]): IO[RunResult] = {
  for {
    _ <- IO { println("Running with args: " + args.mkString(",")) }
    debug = args.contains("debug")
    runResult <- if (debug) debugLoop else IO.pure(Run)
  } yield runResult
}

def debugLoop: IO[Debug.type] =
  for {
    _ <- IO(println("Debug mode: exit application be pressing ENTER."))
    _ <- IO.shift(BlockingFileIO) // readLine might block for a long time so we shift to another thread
    input <- IO(StdIn.readLine()) // let it run until user presses return
    _ <- IO.shift(ExecutionContext.global) // shift back to main thread
    _ <- if (input == "b") {
      // do some debug relevant stuff
      IO(Unit) >> debugLoop
    } else {
      shutDown()
    }
  } yield Debug

  // shuts down everything
  def shutDown(): IO[Unit] = ???
}

And then in your test:

import org.scalatest.FlatSpec

class MainSpec extends FlatSpec {

  "Main" should "enter the debug loop if args contain 'debug'" in {
    val program: IO[RunResult] = Main.run0("debug" :: Nil)
    program.unsafeRunSync() match {
      case Debug => // do stuff
      case Run => // other stuff
    }
  }
}
like image 89
Yuval Itzchakov Avatar answered Oct 19 '22 07:10

Yuval Itzchakov


To search through some monad expression, it would have to be values, not statements, aka reified. That is the core idea behind the (in)famous Free monad. If you were to go through the hassle of expressing your code in some "algebra" as they call (think DSL) it and lift it into monad expression nesting via Free, then yes you would be able to search through it. There are plenty of resources that explain Free monads better than I could google is your friend here.

My general suggestion would be that the general principles of good testing apply everywhere. Isolate the side-effecting part and inject it into the main piece of logic, so that you can inject a fake implementation in testing to allow all sorts of assertions.

like image 20
V-Lamp Avatar answered Oct 19 '22 07:10

V-Lamp