ZIO Test Guide: Aspects, Property Testing, and Functional Test Patterns

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:

  • TestClock for controlling time without sleeping
  • TestRandom for deterministic randomness
  • TestConsole for capturing console output
  • Full ZLayer dependency 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") { ... }
) @@ withDatabase

Property-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 value

Combining 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.

Read more