Testing ZIO Applications

Why Testing Matters in ZIO

One of ZIO's greatest strengths is testability. Traditional Scala code with side effects is notoriously difficult to test:

// How do you test this without hitting a real database?
def getUser(id: Int): User = {
  val connection = Database.connect()
  connection.query(s"SELECT * FROM users WHERE id = $id")
}

// How do you verify this actually sent an email?
def notifyUser(user: User): Unit = {
  EmailService.send(user.email, "Welcome!")
}

With ZIO, effects are just descriptions. You can:

  • Inspect effects without running them
  • Mock dependencies using ZLayer
  • Test error scenarios as easily as success
  • Verify concurrent behavior deterministically

The ZIO Test Framework

ZIO comes with a powerful testing framework built specifically for testing effects. No external libraries needed.

Setting Up Your First Test

Add to your build.sbt:

libraryDependencies += "dev.zio" %% "zio-test" % "2.0.19" % Test
libraryDependencies += "dev.zio" %% "zio-test-sbt" % "2.0.19" % Test

testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework")

Create your first test spec:

import zio._
import zio.test._

object MyFirstSpec extends ZIOSpecDefault {
  def spec = suite("MyFirstSpec")(
    test("1 + 1 equals 2") {
      assertTrue(1 + 1 == 2)
    }
  )
}

Run with: sbt test

Test Structure

object CalculatorSpec extends ZIOSpecDefault {
  def spec = suite("Calculator")(
    test("addition works") {
      assertTrue(add(2, 3) == 5)
    },
    test("subtraction works") {
      assertTrue(subtract(5, 3) == 2)
    },
    suite("multiplication")(
      test("positive numbers") {
        assertTrue(multiply(3, 4) == 12)
      },
      test("negative numbers") {
        assertTrue(multiply(-2, 3) == -6)
      }
    )
  )
}

Tests are organized in nested suites, making large test suites manageable.

Testing ZIO Effects

Testing Success Values

import zio._
import zio.test._

object EffectSpec extends ZIOSpecDefault {
  def spec = suite("Testing Effects")(
    test("succeed returns the value") {
      val effect = ZIO.succeed(42)
      for {
        result <- effect
      } yield assertTrue(result == 42)
    },

    test("map transforms the value") {
      val effect = ZIO.succeed(10).map(_ * 2)
      for {
        result <- effect
      } yield assertTrue(result == 20)
    }
  )
}

Notice how tests themselves are ZIO effects! You can use for-comprehensions to compose test logic.

Testing Failures

test("division by zero fails") {
  val effect = divide(10, 0)
  for {
    exit <- effect.exit
  } yield assertTrue(exit.isFailure)
}

test("failure contains expected error") {
  val effect: IO[String, Int] = ZIO.fail("Not found")
  assertZIO(effect.flip)(equalTo("Not found"))
}

The exit method captures both success and failure, while flip swaps the error and success channels.

Assertions

ZIO Test provides rich assertions:

test("various assertions") {
  for {
    value <- ZIO.succeed(42)
  } yield assertTrue(
    value == 42,                              // Basic equality
    value > 40,                               // Comparison
    value < 50,
    List(1, 2, 3).contains(2),               // Collection checks
    "hello".startsWith("he")                 // String checks
  )
}

Why use assertTrue instead of assert? It provides better error messages when tests fail.

AssertZIO for Effects

test("assertZIO works with effects") {
  val userService = new UserService()
  assertZIO(userService.getUser(123).map(_.name))(
    equalTo("John Doe")
  )
}

test("checking multiple conditions") {
  val effect = fetchData()
  for {
    data <- effect
  } yield assertTrue(
    data.size > 0,
    data.forall(_.isValid),
    data.count(_.isPremium) >= 10
  )
}

Testing with Dependencies

This is where ZIO shines. Remember ZLayer from Lesson 3?

Defining a Service

trait UserRepository {
  def findById(id: Int): IO[String, User]
  def save(user: User): IO[String, Unit]
}

object UserRepository {
  def findById(id: Int): ZIO[UserRepository, String, User] =
    ZIO.serviceWithZIO[UserRepository](_.findById(id))

  def save(user: User): ZIO[UserRepository, String, Unit] =
    ZIO.serviceWithZIO[UserRepository](_.save(user))
}

Creating a Test Implementation

case class TestUserRepository(users: Ref[Map[Int, User]]) extends UserRepository {
  def findById(id: Int): IO[String, User] =
    users.get.flatMap { map =>
      ZIO.fromOption(map.get(id))
        .orElseFail(s"User $id not found")
    }

  def save(user: User): IO[String, Unit] =
    users.update(_ + (user.id -> user))
}

object TestUserRepository {
  val layer: ZLayer[Any, Nothing, UserRepository] =
    ZLayer {
      for {
        ref <- Ref.make(Map.empty[Int, User])
      } yield TestUserRepository(ref)
    }
}

Testing with the Test Layer

object UserServiceSpec extends ZIOSpecDefault {
  def spec = suite("UserService")(
    test("can save and retrieve user") {
      val user = User(1, "Alice", "alice@example.com")
      for {
        _        <- UserRepository.save(user)
        retrieved <- UserRepository.findById(1)
      } yield assertTrue(retrieved.name == "Alice")
    }
  ).provide(TestUserRepository.layer)
}

The provide method injects the test layer. Your business logic doesn't know it's being tested!

Test Aspects

Test aspects modify how tests run. They're applied with the @@ operator:

Timeout

test("operation completes quickly") {
  slowOperation()
} @@ TestAspect.timeout(5.seconds)

If the test takes longer than 5 seconds, it fails.

Retrying Flaky Tests

test("flaky network call") {
  makeNetworkRequest()
} @@ TestAspect.flaky

Flaky tests are retried if they fail, useful for non-deterministic operations.

Ignoring Tests

test("work in progress") {
  ???
} @@ TestAspect.ignore

Running Tests Multiple Times

test("random generation works") {
  generateRandomNumber()
} @@ TestAspect.samples(100)

Runs the test 100 times with different random seeds.

Combining Aspects

test("critical operation") {
  criticalBusinessLogic()
} @@ TestAspect.timeout(10.seconds) @@ TestAspect.nonFlaky(10)

Property-Based Testing

Instead of testing specific examples, test properties that should always hold.

Basic Property Tests

import zio.test.Gen

test("reversing a list twice returns original") {
  check(Gen.listOf(Gen.int)) { list =>
    assertTrue(list.reverse.reverse == list)
  }
}

test("addition is commutative") {
  check(Gen.int, Gen.int) { (a, b) =>
    assertTrue(a + b == b + a)
  }
}

Gen.listOf generates random lists. check runs the test with many different inputs.

Custom Generators

case class User(name: String, age: Int, email: String)

val genUser: Gen[Any, User] = for {
  name  <- Gen.alphaNumericStringBounded(3, 20)
  age   <- Gen.int(18, 100)
  email <- Gen.alphaNumericStringBounded(5, 15).map(_ + "@example.com")
} yield User(name, age, email)

test("user validation") {
  check(genUser) { user =>
    assertTrue(
      user.name.nonEmpty,
      user.age >= 18,
      user.email.contains("@")
    )
  }
}

Shrinking

When a property test fails, ZIO Test automatically shrinks the failing case to the simplest example:

test("all numbers are positive") {
  check(Gen.int) { n =>
    assertTrue(n > 0)  // Will fail
  }
}
// Fails with n = 0 (simplest failing case)
// Not with n = -2847293 (some random negative)

Testing Concurrent Code

Testing Race Conditions

test("concurrent updates are safe") {
  for {
    ref    <- Ref.make(0)
    _      <- ZIO.foreachPar(1 to 1000)(_ => ref.update(_ + 1))
    result <- ref.get
  } yield assertTrue(result == 1000)
}

Testing Timeouts

test("operation times out") {
  val slowEffect = ZIO.sleep(10.seconds) *> ZIO.succeed("done")
  for {
    exit <- slowEffect.timeout(1.second).exit
  } yield assertTrue(exit.isFailure)
}

Testing Interruption

test("effect handles interruption") {
  for {
    promise <- Promise.make[Nothing, Unit]
    fiber   <- (ZIO.sleep(5.seconds) *> promise.succeed(())).fork
    _       <- fiber.interrupt
    result  <- promise.poll
  } yield assertTrue(result.isEmpty)
}

Mocking Complex Services

Service with Multiple Methods

trait EmailService {
  def send(to: String, subject: String, body: String): Task[Unit]
  def sendBatch(emails: List[Email]): Task[Int]
}

case class TestEmailService(sent: Ref[List[Email]]) extends EmailService {
  def send(to: String, subject: String, body: String): Task[Unit] =
    sent.update(_ :+ Email(to, subject, body))

  def sendBatch(emails: List[Email]): Task[Int] =
    sent.update(_ ++ emails).as(emails.size)
}

object TestEmailService {
  val layer: ZLayer[Any, Nothing, EmailService] =
    ZLayer {
      Ref.make(List.empty[Email]).map(TestEmailService(_))
    }
}

Verifying Service Calls

test("notification service sends emails") {
  for {
    _      <- NotificationService.notifyUser(User(1, "Alice", "alice@example.com"))
    emails <- ZIO.service[TestEmailService].flatMap(_.sent.get)
  } yield assertTrue(
    emails.size == 1,
    emails.head.to == "alice@example.com",
    emails.head.subject.contains("Welcome")
  )
}.provide(
  TestEmailService.layer,
  NotificationService.live
)

Testing Error Handling

Testing Error Recovery

test("service recovers from failures") {
  val service = new ResilientService()
  for {
    result <- service.fetchWithRetry()
  } yield assertTrue(result.isSuccess)
}

test("service fails after max retries") {
  val service = new ResilientService(maxRetries = 3)
  for {
    exit <- service.fetchWithRetry().exit
  } yield assertTrue(exit.isFailure)
}

Testing Custom Error Types

sealed trait AppError
case class NotFound(id: String) extends AppError
case class Unauthorized(user: String) extends AppError
case class ValidationError(field: String) extends AppError

test("returns NotFound error") {
  val effect: IO[AppError, User] = UserService.getUser(999)
  for {
    error <- effect.flip
  } yield assertTrue(error match {
    case NotFound("999") => true
    case _ => false
  })
}

Integration Testing

Testing Multiple Layers

object IntegrationSpec extends ZIOSpecDefault {

  val testLayers = 
    TestUserRepository.layer ++
    TestEmailService.layer ++
    UserService.live ++
    NotificationService.live

  def spec = suite("Integration Tests")(
    test("user registration flow") {
      for {
        user   <- UserService.register("alice@example.com", "password123")
        emails <- ZIO.service[TestEmailService].flatMap(_.sent.get)
      } yield assertTrue(
        user.email == "alice@example.com",
        emails.exists(_.to == "alice@example.com")
      )
    }
  ).provide(testLayers)
}

Shared Test Resources

object DatabaseSpec extends ZIOSpecDefault {

  val sharedDb = ZLayer.scoped {
    ZIO.acquireRelease(
      Database.connect()
    )(db => db.close().orDie)
  }

  def spec = suite("Database Tests")(
    test("insert user") {
      ???
    },
    test("query user") {
      ???
    }
  ).provideSomeShared[Scope](sharedDb) @@ TestAspect.sequential
}

The database connection is shared across tests, and sequential ensures tests don't run in parallel.

Best Practices

Test Organization

object UserServiceSpec extends ZIOSpecDefault {
  def spec = suite("UserService")(
    suite("registration")(
      test("succeeds with valid data") { ??? },
      test("fails with invalid email") { ??? },
      test("fails with weak password") { ??? }
    ),
    suite("authentication")(
      test("succeeds with correct credentials") { ??? },
      test("fails with wrong password") { ??? },
      test("locks account after failed attempts") { ??? }
    )
  )
}

Group related tests in nested suites for clarity.

Test Data Builders

object TestData {
  def user(
    id: Int = 1,
    name: String = "Test User",
    email: String = "test@example.com",
    role: Role = Role.User
  ): User = User(id, name, email, role)

  def adminUser(id: Int = 1): User = 
    user(id, role = Role.Admin)
}

test("admin can delete users") {
  val admin = TestData.adminUser()
  val user = TestData.user(id = 2)
  ???
}

Descriptive Test Names

// Good
test("returns NotFound when user doesn't exist") { ??? }
test("sends welcome email after successful registration") { ??? }

// Bad
test("test1") { ??? }
test("user service works") { ??? }

What information do you need when a test fails? Put that in the name.

Practical Example: Testing a REST API Service

trait UserApi {
  def getUser(id: Int): IO[ApiError, User]
  def createUser(req: CreateUserRequest): IO[ApiError, User]
  def deleteUser(id: Int): IO[ApiError, Unit]
}

object UserApiSpec extends ZIOSpecDefault {

  def spec = suite("UserApi")(
    suite("getUser")(
      test("returns user when exists") {
        for {
          _    <- UserRepository.save(User(1, "Alice", "alice@example.com"))
          user <- UserApi.getUser(1)
        } yield assertTrue(user.name == "Alice")
      },

      test("returns NotFound when user doesn't exist") {
        for {
          error <- UserApi.getUser(999).flip
        } yield assertTrue(error == ApiError.NotFound("User 999"))
      }
    ),

    suite("createUser")(
      test("creates user with valid data") {
        val req = CreateUserRequest("Bob", "bob@example.com")
        for {
          user  <- UserApi.createUser(req)
          saved <- UserRepository.findById(user.id)
        } yield assertTrue(
          user.name == "Bob",
          saved.email == "bob@example.com"
        )
      },

      test("rejects invalid email") {
        val req = CreateUserRequest("Invalid", "not-an-email")
        for {
          error <- UserApi.createUser(req).flip
        } yield assertTrue(error == ApiError.ValidationError("email"))
      },

      test("sends welcome email") {
        val req = CreateUserRequest("Charlie", "charlie@example.com")
        for {
          _      <- UserApi.createUser(req)
          emails <- ZIO.service[TestEmailService].flatMap(_.sent.get)
        } yield assertTrue(
          emails.exists(e => 
            e.to == "charlie@example.com" && 
            e.subject.contains("Welcome")
          )
        )
      }
    ),

    suite("deleteUser")(
      test("removes user from database") {
        for {
          _      <- UserRepository.save(User(1, "Dave", "dave@example.com"))
          _      <- UserApi.deleteUser(1)
          result <- UserRepository.findById(1).either
        } yield assertTrue(result.isLeft)
      }
    )
  ).provide(
    TestUserRepository.layer,
    TestEmailService.layer,
    UserApi.live
  )
}

Testing Performance

Benchmark Tests

test("operation completes within budget") {
  val operation = expensiveComputation()

  for {
    start  <- Clock.nanoTime
    _      <- operation
    end    <- Clock.nanoTime
    millis = (end - start) / 1_000_000
  } yield assertTrue(millis < 100)
} @@ TestAspect.samples(50)

Memory Efficiency

test("streaming doesn't load entire file in memory") {
  val largeFile = "large-data.txt"
  for {
    before <- Runtime.totalMemory
    _      <- StreamProcessor.processLargeFile(largeFile)
    after  <- Runtime.totalMemory
    growth = after - before
  } yield assertTrue(growth < 10_000_000) // Less than 10MB
}

Common Testing Patterns

Testing Retries

test("retries on failure") {
  for {
    attempts <- Ref.make(0)
    result   <- (
                  attempts.updateAndGet(_ + 1).flatMap { n =>
                    if (n < 3) ZIO.fail("Temporary failure")
                    else ZIO.succeed("Success")
                  }
                ).retry(Schedule.recurs(5))
  } yield assertTrue(result == "Success")
}

Testing Caching

test("caches results") {
  for {
    calls  <- Ref.make(0)
    cached <- expensiveOperation(calls).cached
    _      <- cached
    _      <- cached
    _      <- cached
    count  <- calls.get
  } yield assertTrue(count == 1)  // Only called once
}

Testing Transactions

test("rolls back on failure") {
  for {
    _      <- UserRepository.save(User(1, "Alice", "alice@example.com"))
    _      <- transferFunds(1, 2, 100).either  // Fails
    user   <- UserRepository.findById(1)
  } yield assertTrue(user.balance == 1000)  // Unchanged
}

Key Takeaways

  • ZIO Test provides everything you need to test ZIO applications
  • Effects are testable because they're descriptions, not actions
  • ZLayer makes mocking trivial - swap implementations without code changes
  • Property-based testing verifies invariants across many inputs
  • Test aspects modify behavior: timeouts, retries, sampling
  • Integration tests compose multiple layers naturally
  • Testing concurrent code is straightforward with ZIO's deterministic runtime

What's Next?

You now know how to comprehensively test ZIO applications. In Lesson 8: Advanced ZIO Patterns and Optimization, you'll learn:

  • Using Ref for safe mutable state
  • Queues and Hubs for concurrent communication
  • Software Transactional Memory (STM)
  • Performance optimization techniques
  • Advanced concurrency patterns

Ready to master advanced ZIO techniques? Let's continue!

Additional Resources