Testing cats-effect IO with munit-cats-effect

Testing cats-effect IO with munit-cats-effect

Testing cats-effect applications requires a test framework that understands IO. Running IO in tests naively—calling .unsafeRunSync()—works but loses meaningful error messages, leaks resources, and breaks in concurrent scenarios. munit-cats-effect provides native IO support for MUnit, making tests composable and resource-safe.

This guide covers munit-cats-effect setup, testing IO computations, Resource lifecycle testing, fiber behavior, and dependency injection patterns for the Typelevel ecosystem.

Setup

// build.sbt
libraryDependencies ++= Seq(
  "org.typelevel" %% "munit-cats-effect" % "2.0.0" % Test,
  "org.scalameta" %% "munit"             % "1.0.0" % Test
)

CatsEffectSuite

Extend CatsEffectSuite instead of munit.FunSuite. Every test can return IO[Unit] or IO[Assertion]:

import cats.effect.IO
import munit.CatsEffectSuite

class UserServiceSuite extends CatsEffectSuite {
  test("creates user successfully") {
    for {
      service <- UserService.make[IO]
      user    <- service.create("alice@example.com", "password123")
    } yield {
      assertEquals(user.email, "alice@example.com")
      assert(user.id > 0)
    }
  }

  test("returns error for duplicate email") {
    for {
      service <- UserService.make[IO]
      _       <- service.create("alice@example.com", "pass")
      result  <- service.create("alice@example.com", "pass2").attempt
    } yield {
      assert(result.isLeft)
      assert(result.left.exists(_.getMessage.contains("already exists")))
    }
  }
}

Tests returning IO are automatically executed by the framework. No .unsafeRunSync() needed.

Asserting on Effects

// Assert IO produces a value
assertIO(service.getUser(42), expectedUser)

// Assert IO raises an error
interceptIO[UserNotFoundException] {
  service.getUser(99999)
}

// Assert IO result satisfies predicate
assertIOBoolean(service.exists("alice@example.com"))

// Check with message
assertIO(
  counter.get,
  100,
  "counter should have incremented 100 times"
)

interceptIO is the IO-aware equivalent of intercept:

test("throws on invalid input") {
  interceptIO[IllegalArgumentException] {
    EmailValidator.validate[IO]("not-an-email")
  }
}

Resource Fixtures

Resource management is central to cats-effect. munit-cats-effect provides ResourceFixture for test resources that initialize before each test and clean up after:

import munit.CatsEffectSuite
import cats.effect.Resource

class DatabaseSuite extends CatsEffectSuite {
  val dbFixture: SuiteMixin { val db: TestDatabase } =
    ResourceFixture(TestDatabase.resource[IO])

  dbFixture.test("inserts and retrieves records") { db =>
    for {
      _      <- db.insert(Record(1, "test"))
      record <- db.find(1)
    } yield assertEquals(record, Some(Record(1, "test")))
  }

  dbFixture.test("returns None for missing records") { db =>
    db.find(99999).map(result => assertEquals(result, None))
  }
}

The Resource acquired before each test is guaranteed to be released — even if the test fails or throws.

Suite-Level Fixtures

For expensive resources (database connections, test containers), share across all tests in a suite:

class IntegrationSuite extends CatsEffectSuite {
  // Acquired once, shared across all tests
  val sharedDb = ResourceSuiteLocalFixture(
    "shared-db",
    PostgresTestContainer.resource[IO]
  )

  override def munitFixtures = List(sharedDb)

  test("test 1") {
    val db = sharedDb()
    // use db
  }

  test("test 2") {
    val db = sharedDb()
    // same db instance
  }
}

Testing Concurrent IO

Parallel Execution

test("runs operations in parallel") {
  val start = System.currentTimeMillis()

  val slowOp = IO.sleep(100.milliseconds) *> IO.pure(42)

  for {
    results <- List.fill(10)(slowOp).parSequence
    elapsed = System.currentTimeMillis() - start
  } yield {
    assertEquals(results, List.fill(10)(42))
    assert(elapsed < 500, s"Expected parallel execution, took ${elapsed}ms")
  }
}

Testing with TestControl

cats-effect 3 includes TestControl for deterministic concurrency testing without wall-clock time:

import cats.effect.testkit.TestControl

test("fiber scheduler is deterministic") {
  val program = for {
    ref    <- IO.ref(0)
    fiber  <- (IO.sleep(1.second) *> ref.update(_ + 1)).start
    _      <- IO.sleep(2.seconds)
    _      <- fiber.join
    result <- ref.get
  } yield result

  TestControl.executeEmbed(program).map { result =>
    assertEquals(result, 1)
  }
}

TestControl.executeEmbed runs the program with a simulated clock — time advances only when the program yields, making concurrent tests deterministic.

Racing Effects

test("race returns the faster result") {
  val fast = IO.pure("fast")
  val slow = IO.sleep(1.hour) *> IO.pure("slow")

  for {
    result <- IO.race(fast, slow)
  } yield assertEquals(result, Left("fast"))
}

Ref and State Testing

Ref is the cats-effect equivalent of AtomicReference. Test concurrent state:

test("counter is thread-safe under parallel updates") {
  for {
    counter <- IO.ref(0)
    _       <- List.fill(1000)(counter.update(_ + 1)).parSequence_
    final   <- counter.get
  } yield assertEquals(final, 1000)
}

test("Deferred resolves when completed") {
  for {
    deferred <- IO.deferred[String]
    fiber    <- deferred.get.start
    _        <- deferred.complete("result")
    result   <- fiber.joinWithNever
  } yield assertEquals(result, "result")
}

Dependency Injection Patterns

Constructor Injection

The simplest pattern — pass dependencies as constructor arguments:

class OrderServiceSuite extends CatsEffectSuite {
  val inventoryFixture = ResourceFixture(
    Resource.eval(IO.ref(Map("item-1" -> 10)))
      .map(ref => new InMemoryInventory[IO](ref))
  )

  inventoryFixture.test("completes order when in stock") { inventory =>
    val service = new OrderService[IO](inventory, new NoOpNotifier[IO])
    for {
      order <- service.placeOrder(Order("item-1", quantity = 2))
    } yield assert(order.confirmed)
  }
}

Using cats.effect.kernel.Ref for Test State

class EmailSuite extends CatsEffectSuite {
  test("sends welcome email on registration") {
    for {
      sent    <- IO.ref(List.empty[Email])
      mailer   = new TestMailer[IO](sent)
      service  = new UserService[IO](InMemoryUserRepo.empty, mailer)
      _       <- service.register("alice@example.com", "pass")
      emails  <- sent.get
    } yield {
      assertEquals(emails.size, 1)
      assertEquals(emails.head.to, "alice@example.com")
      assert(emails.head.subject.contains("Welcome"))
    }
  }
}

Testing Streams (fs2)

For services using fs2 streams:

import fs2.Stream

test("stream emits all elements") {
  val stream = Stream.range(1, 6).covary[IO]

  stream.compile.toList.map { result =>
    assertEquals(result, List(1, 2, 3, 4, 5))
  }
}

test("stream processes in batches of 10") {
  val stream = Stream.range(1, 101).covary[IO]

  stream
    .chunkN(10)
    .evalMap(batch => IO.pure(batch.toList))
    .compile
    .toList
    .map { batches =>
      assertEquals(batches.size, 10)
      assert(batches.forall(_.size == 10))
    }
}

Timeout and Retry

import scala.concurrent.duration._

test("completes within timeout") {
  val program = service.slowOperation()

  // munit timeout
  override val munitTimeout = 5.seconds

  program.map { result =>
    assertEquals(result, expectedValue)
  }
}

test("retries on transient failure") {
  import cats.syntax.all._
  import retry._

  val policy = RetryPolicies.limitRetries[IO](3)

  retryingOnAllErrors[String](
    policy = policy,
    onError = (_, _) => IO.unit
  )(flakyService.call()).map { result =>
    assertEquals(result, "success")
  }
}

Property-Based Testing with ScalaCheck

Combine munit-cats-effect with ScalaCheck for property tests over IO:

import org.scalacheck.Prop
import munit.ScalaCheckSuite

class UserPropertySuite extends CatsEffectSuite with ScalaCheckSuite {
  property("email normalization is idempotent") {
    Prop.forAll { (email: String) =>
      // Note: property tests are synchronous; use IO.unsafeRunSync for simple cases
      val normalized = EmailNormalizer.normalize(email)
      EmailNormalizer.normalize(normalized) == normalized
    }
  }
}

For IO-returning properties, use a helper:

def propIO(name: String)(io: IO[Boolean]): Unit =
  test(name) { io.map(assert(_)) }

Continuous Production Testing

munit-cats-effect ensures your IO programs work correctly in development and CI. For production assurance, HelpMeTest monitors your live application continuously — verifying that your Typelevel services remain available and correct under real load.

Summary

munit-cats-effect makes testing cats-effect applications natural: tests return IO, resources are managed safely via ResourceFixture, and concurrent behavior is testable with TestControl. The CatsEffectSuite base class handles the runtime, error reporting, and resource lifecycle — letting you focus on behavior.

Use ResourceFixture for test-scoped resources, ResourceSuiteLocalFixture for expensive shared resources, IO.ref for test state, and TestControl for deterministic concurrency testing. These patterns scale from simple unit tests to complex integration scenarios.

Read more

Start now free