5

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?

Svend
  • 6,352
  • 1
  • 25
  • 38
Florian Baierl
  • 2,378
  • 3
  • 25
  • 50

2 Answers2

2

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
    }
  }
}
Yuval Itzchakov
  • 146,575
  • 32
  • 257
  • 321
  • Thank you very much; it seems I asked the wrong question, but your answer solves the problem I had. :) – Florian Baierl Feb 20 '19 at 09:04
  • If your question was about testing it, then I think this is not really possible because running it will block on readLine. To test it you need to somehow mock out IO interactions – V-Lamp Feb 20 '19 at 12:04
1

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.

V-Lamp
  • 1,630
  • 10
  • 18
  • wouldn't you have an issue with `Free` because of the `flatMap` case of `A => F[B]` requiring actual evaluation for further introspection? I think this is only possible in applicative languages? – Dominic Egger Feb 19 '19 at 13:52
  • Yes, the whole idea of flatMap is to chain some second *thing* based on the outcome of the first, so can't inspect as is. But you can interpret the expression with some dummy interpreter, that can e.g. yield a list of statements of the DSL that would be executed, instead of doing the real side effects. That would indeed involve using some canned values for any side-effecting input (reified) statement, like readLine. You still need to "run" it, but the essence of Free (or Tagless Final for that matter) is that it doesn't dictate on *what* you run it against. – V-Lamp Feb 19 '19 at 17:19
  • 2
    Thank you very much for your answer. Looking back at my question I know realize that it was a case of an XY problem. While your answer seems to (I am still researching it) be the correct answer to my question, @Yuval 's answer is an answer to the underlying problem I had. That is why I accepted his answer. But again, thank you very much for the valuable information you provided. – Florian Baierl Feb 20 '19 at 09:03
  • 1
    Hi Florian, no worries :) @Yuval's answer is actually somewhat aligned with my suggestion: "Reify" the actions you care about into values (Plain run vs debug run), which is what `RunResult` is really. As I commented above, to properly test it you would also need to reify (or otherwise mock) the readline, hope it helps – V-Lamp Feb 20 '19 at 12:04