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!
Comments
Be the first to comment on this lesson!