Search code examples
scalaunit-testingscala-cats

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?


Solution

  • 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
        }
      }
    }