Skip to content

Feature request: Integration with cats-effect-testkit #439

@morgen-peschke

Description

@morgen-peschke

Code using cats-effect-testkit has a rough transition to the CatsEffectAssertions, as either a bunch of extra wrapping needs added around the value passed to .assertEquals, or the returned Option[Outcome[F, E, A]] needs unwrapped before in can be checked.

Example:

import cats.effect.IO
import cats.effect.kernel.Outcome
import cats.effect.testkit.TestControl
import cats.syntax.all._
import munit.CatsEffectSuite

import scala.concurrent.duration.DurationInt

class FooSpec extends CatsEffectSuite {
  test("wrapping") {
    val test =
      for {
        start <- IO.realTimeInstant
        _ <- IO.sleep(5.seconds)
        _ <- IO.realTimeInstant.assertEquals(start.plusSeconds(5))
      } yield 5

    TestControl.execute(test)
      .flatMap { control =>
        control.tickFor(6.seconds) *> control.results
      }
      // Unfortunately, the type annotations do appear to be needed
      .assertEquals(Outcome.Succeeded[cats.Id, Throwable, Int](5).some)
  }

  test("un-wrapping") {
    val test =
      for {
        start <- IO.realTimeInstant
        _ <- IO.sleep(5.seconds)
        _ <- IO.realTimeInstant.assertEquals(start.plusSeconds(5))
      } yield 5

    TestControl.execute(test)
      .flatMap { control =>
        control.tickFor(6.seconds) *> control.results
      }
      .flatMap {
        case Some(Outcome.Succeeded(value)) => value.pure[IO]
        case other => fail("Outcome was not a success", clues(other))
      }
      .assertEquals(5)
  }
}

Something along these lines might be a useful addition (with MUnitCatsAssertionsForIOOutcomeOps):

def outcomeIntercept[T <: Throwable](outcome: Option[Outcome[?, Throwable, ?]],
                                     clues: Clues = new Clues(Nil)
                                    )(implicit T: ClassTag[T], loc: Location): IO[Throwable] =
  outcome match {
    case None => IO[Throwable](fail("Outcome did not contain a value", clues))
    case Some(Outcome.Canceled()) => IO[Throwable](fail("Outcome was cancellation instead of error", clues))
    case Some(Outcome.Succeeded(fa)) =>
      IO[Throwable](fail(
        "Outcome was success instead of error",
        // This should be OK because `F` will usually be cats.Id
        new Clues(new Clue("returned", fa, fa.getClass.getTypeName) :: clues.values)
      ))
    case Some(Outcome.Errored(e)) =>
      e match {
        case e: munit.FailExceptionLike[_] if !T.runtimeClass.isAssignableFrom(e.getClass) =>
          IO.raiseError(e)
        case e if T.runtimeClass.isAssignableFrom(e.getClass) => e.pure[IO]
        case e =>
          val obtained = e.getClass.getName
          val expected = T.runtimeClass.getName
          IO[Throwable](fail(
            s"Outcome error '$obtained' is not a subtype of '$expected'",
            new Clues(new Clue("thrown", e, e.getClass.getTypeName) :: clues.values)
          ))
      }
  }

def assertCanceledOutcome[F[_], A](outcome: Option[Outcome[F, Throwable, A]],
                                   clues: Clues = new Clues(Nil)
                                  )(implicit loc: Location): IO[Unit] =
  outcome match {
    case None => IO[Unit](fail("Outcome did not contain a value", clues))
    case Some(Outcome.Canceled()) => IO.unit
    case Some(Outcome.Succeeded(fa)) =>
      IO[Unit](fail(
        "Outcome was success instead of cancellation",
        // This should be OK because `F` will usually be cats.Id
        new Clues(new Clue("returned", fa, fa.getClass.getTypeName) :: clues.values)
      ))
    case Some(Outcome.Errored(e)) =>
      e match {
        case _: munit.FailExceptionLike[_] => IO.raiseError(e)
        case _ =>
          IO[Unit](fail(
            "Outcome was error instead of cancellation",
            new Clues(new Clue("thrown", e, e.getClass.getTypeName) :: clues.values)
          ))
      }
  }

def assertSucceededOutcome[F[_], A](outcome: Option[Outcome[F, Throwable, A]],
                                    clues: Clues = new Clues(Nil)
                                   )(implicit loc: Location): IO[F[A]] =
  outcome match {
    case None => IO[F[A]](fail("Outcome did not contain a value", clues))
    case Some(Outcome.Succeeded(fa)) => fa.pure[IO]
    case Some(Outcome.Errored(e)) =>
      e match {
        case _: munit.FailExceptionLike[_] => IO.raiseError(e)
        case _ =>
          IO[F[A]](fail(
            "Outcome was error instead of success",
            new Clues(new Clue("thrown", e, e.getClass.getTypeName) :: clues.values)
          ))
      }
    case Some(Outcome.Canceled()) => IO[F[A]](fail("Outcome was cancellation instead of success", clues))
  }

The examples would then simplify to this:

test("helpers") {
  val test =
    for {
      start <- IO.realTimeInstant
      _ <- IO.sleep(5.seconds)
      _ <- IO.realTimeInstant.assertEquals(start.plusSeconds(5))
    } yield 5

  TestControl.execute(test)
    .flatMap { control =>
      control.tickFor(6.seconds) *> control.results
    }
    .assertSucceededOutcome
    .assertEquals(5)
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions