ZIO Test Guide: Aspects, Property Testing, and Functional Test Patterns
ZIO Test is the native testing framework for ZIO applications. It integrates deeply with ZIO's effect system, giving you precise control over concurrency, timing, and resource management in tests. Unlike JUnit or ScalaTest, ZIO Test treats tests as effects — first-class ZIO values that compose, retry, and parallelize naturally.
This guide covers the core ZIO Test API, test aspects for cross-cutting concerns, property-based testing, and patterns for testing layered ZIO applications.
Why ZIO Test
Traditional testing frameworks run tests in raw Threads and use blocking assertions. In a ZIO application, this means stepping outside the effect system for tests — losing control over fiber scheduling, clock manipulation, and resource cleanup.
ZIO Test runs tests as ZIO effects inside the ZIO runtime. This gives you:
TestClockfor controlling time without sleepingTestRandomfor deterministic randomnessTestConsolefor capturing console output- Full
ZLayerdependency injection in test environments - Fiber-level concurrency control
Basic Test Structure
import zio._
import zio.test._
import zio.test.Assertion._
object UserSpec extends ZIOSpecDefault {
def spec = suite("User")(
test("email is normalized on creation") {
for {
user <- User.create("ALICE@EXAMPLE.COM", "password123")
} yield assert(user.email)(equalTo("alice@example.com"))
},
test("rejects invalid email format") {
for {
result <- User.create("not-an-email", "password").exit
} yield assert(result)(fails(hasMessage(containsString("invalid email"))))
}
)
}Every test block returns a ZIO[R, E, TestResult]. The assert function and its matchers come from zio.test.Assertion.
Running Tests
# sbt
sbt <span class="hljs-built_in">test
<span class="hljs-comment"># Run specific suite
sbt <span class="hljs-string">"testOnly UserSpec"
<span class="hljs-comment"># With test output
sbt <span class="hljs-string">"testOnly UserSpec -- -v"Assertions
ZIO Test ships with a rich assertion library that composes with &&, ||, and !:
// Equality
assert(value)(equalTo(42))
assert(value)(not(equalTo(0)))
// Collections
assert(list)(hasSize(equalTo(3)))
assert(list)(contains(42))
assert(list)(isEmpty)
assert(list)(isNonEmpty)
assert(list)(forall(isPositive))
// Options
assert(opt)(isSome(equalTo("value")))
assert(opt)(isNone)
// Either
assert(either)(isRight(equalTo(42)))
assert(either)(isLeft(containsString("error")))
// Effects and exits
for {
exit <- someEffect.exit
} yield assert(exit)(succeeds(equalTo("ok")))
for {
exit <- failingEffect.exit
} yield assert(exit)(fails(isSubtype[IllegalArgumentException](anything)))Custom Assertions
val isValidEmail: Assertion[String] =
Assertion.assertion("isValidEmail") { email =>
email.contains("@") && email.contains(".")
}
test("email is valid") {
assert("alice@example.com")(isValidEmail)
}Test Aspects
Test aspects apply cross-cutting behavior to individual tests or entire suites. They're the ZIO Test equivalent of JUnit annotations or ScalaTest tags.
Built-in Aspects
import zio.test.TestAspect._
suite("Integration tests")(
test("connects to database") { ... } @@ timeout(10.seconds),
test("retries on failure") { ... } @@ retry(Schedule.recurs(3)),
test("runs only on CI") { ... } @@ ifEnv("CI")("true"),
test("ignored until fixed") { ... } @@ ignore,
test("runs sequentially") { ... } @@ sequential,
test("flaky but important") { ... } @@ flaky(5) // retry up to 5 times
)Apply aspects to entire suites:
object SlowSpec extends ZIOSpecDefault {
def spec = suite("Slow integration tests")(
// ... tests
) @@ sequential @@ timeout(60.seconds)
}Common Aspects
| Aspect | Effect |
|---|---|
timeout(d) |
Fail test if it exceeds duration |
retry(s) |
Retry failed test on given schedule |
flaky(n) |
Allow up to n failures before failing |
ignore |
Skip the test |
sequential |
Run suite tests one at a time |
parallel(n) |
Run up to n tests concurrently |
before(e) |
Run effect before test |
after(e) |
Run effect after test (always) |
around(b, a) |
Run before and after |
timed |
Report test duration |
samples(n) |
Set property test sample count |
Custom Test Aspects
val withDatabase: TestAspect[Nothing, TestEnvironment, Throwable, Any] =
new TestAspect.PerTest[Nothing, TestEnvironment, Throwable, Any] {
override def perTest[R <: TestEnvironment, E](
test: ZIO[R, TestFailure[E], TestSuccess]
): ZIO[R, TestFailure[E], TestSuccess] = {
for {
_ <- ZIO.serviceWith[DatabasePool](_.reset).mapError(TestFailure.die)
result <- test
} yield result
}
}
suite("DB tests")(
test("inserts record") { ... }
) @@ withDatabaseProperty-Based Testing
ZIO Test includes property-based testing via Gen (generators) and check:
test("addition is commutative") {
check(Gen.int, Gen.int) { (a, b) =>
assert(a + b)(equalTo(b + a))
}
}
test("list reverse is involutive") {
check(Gen.listOf(Gen.int)) { list =>
assert(list.reverse.reverse)(equalTo(list))
}
}By default, ZIO Test runs 200 samples per property test. Control with @@ samples(n):
test("email normalization") {
check(Gen.alphaNumericStringBounded(1, 20)) { name =>
for {
user <- User.create(s"${name.toUpperCase}@EXAMPLE.COM", "pass")
} yield assert(user.email)(isLowerCase)
}
} @@ samples(500)Built-in Generators
Gen.int // any Int
Gen.int(1, 100) // bounded Int
Gen.long
Gen.double
Gen.string // any String
Gen.alphaNumericString
Gen.alphaNumericStringBounded(5, 20)
Gen.uuid
Gen.boolean
Gen.listOf(gen) // List of random length
Gen.listOfN(5)(gen) // List of exactly 5
Gen.setOf(gen)
Gen.mapOf(keyGen, valueGen)
Gen.option(gen)
Gen.either(leftGen, rightGen)
Gen.elements(a, b, c) // pick from fixed set
Gen.const(value) // always this valueCombining Generators
case class User(name: String, age: Int, email: String)
val userGen: Gen[Any, User] = for {
name <- Gen.alphaNumericStringBounded(2, 30)
age <- Gen.int(18, 100)
email <- Gen.alphaNumericStringBounded(3, 15).map(n => s"$n@example.com")
} yield User(name, age, email)
test("user serialization roundtrip") {
check(userGen) { user =>
val json = user.toJson
val decoded = json.fromJson[User]
assert(decoded)(isRight(equalTo(user)))
}
}Controlled Clock and Time
ZIO Test provides TestClock to manipulate time without sleeping:
import zio.test.TestClock
test("token expires after 1 hour") {
for {
token <- TokenService.generate
_ <- TestClock.adjust(61.minutes)
expired <- TokenService.isExpired(token)
} yield assert(expired)(isTrue)
}
test("scheduled job fires at midnight") {
for {
results <- Ref.make(List.empty[String])
fiber <- ScheduledJob.run(results).fork
_ <- TestClock.adjust(24.hours)
_ <- fiber.interrupt
logs <- results.get
} yield assert(logs)(contains("midnight job ran"))
}This eliminates actual sleep calls from tests — a 1-hour expiry test completes in milliseconds.
ZLayer in Tests
ZIO Test integrates with ZLayer for dependency injection. Provide test layers in the spec:
object UserServiceSpec extends ZIOSpecDefault {
val testDbLayer: ZLayer[Any, Throwable, DatabasePool] =
ZLayer.scoped {
ZIO.acquireRelease(
TestDatabase.create()
)(_.shutdown)
}
val testEmailLayer: ZLayer[Any, Nothing, EmailService] =
ZLayer.succeed(new EmailService {
def send(to: String, body: String): UIO[Unit] = ZIO.unit
})
def spec = suite("UserService")(
test("creates user and sends welcome email") {
for {
service <- ZIO.service[UserService]
user <- service.register("alice@example.com", "pass123")
} yield assert(user.id)(isPositive)
}
).provide(
UserService.layer,
testDbLayer,
testEmailLayer
)
}Shared State Between Tests
Use Ref for shared mutable state that's safe in concurrent tests:
test("counter increments atomically") {
for {
counter <- Ref.make(0)
_ <- ZIO.foreachParDiscard(1 to 1000)(_ => counter.update(_ + 1))
final <- counter.get
} yield assert(final)(equalTo(1000))
}Summary
ZIO Test makes testing functional Scala applications natural and powerful. Tests are ZIO effects — they compose, retry, and parallelize with the same primitives as production code. Test aspects handle cross-cutting concerns without annotations. Property-based testing through Gen and check finds edge cases automatically. TestClock eliminates sleep-based timing tests. ZLayer provides clean dependency injection in test suites.
Combine ZIO Test with HelpMeTest for continuous production monitoring — unit tests catch logic bugs pre-deploy, while HelpMeTest verifies your live application keeps working 24/7.