or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

assertions.mdconfiguration.mdindex.mdproperty-testing.mdtest-aspects.mdtest-definition.mdtest-services.md
tile.json

property-testing.mddocs/

Property-Based Testing

Advanced property-based testing with sophisticated generators, automatic shrinking, and configurable test execution.

Capabilities

Check Functions

Run property-based tests with generated inputs to verify properties hold across many test cases.

/**
 * Check that a property holds for generated values from a generator
 * @param rv generator for test values  
 * @param test property to verify for each generated value
 * @return test result indicating success or failure with shrunk counterexamples
 */
def check[R <: ZAny, A](rv: Gen[R, A])(test: A => TestResult): ZIO[R with TestConfig, Nothing, TestResult]

/**
 * Check property with two generators
 */
def check[R <: ZAny, A, B](rv1: Gen[R, A], rv2: Gen[R, B])(
  test: (A, B) => TestResult
): ZIO[R with TestConfig, Nothing, TestResult]

/**
 * Check property with up to 8 generators (additional overloads available)
 */
def check[R <: ZAny, A, B, C](rv1: Gen[R, A], rv2: Gen[R, B], rv3: Gen[R, C])(
  test: (A, B, C) => TestResult
): ZIO[R with TestConfig, Nothing, TestResult]

/**
 * Check that property holds for ALL values from a finite generator
 * @param rv finite, deterministic generator
 * @param test property to verify
 * @return test result
 */
def checkAll[R <: ZAny, A](rv: Gen[R, A])(test: A => TestResult): ZIO[R with TestConfig, Nothing, TestResult]

/**
 * Check property in parallel for better performance with finite generators
 * @param rv generator for test values
 * @param parallelism number of parallel executions
 * @param test property to verify
 */
def checkAllPar[R <: ZAny, A](rv: Gen[R, A], parallelism: Int)(
  test: A => TestResult
): ZIO[R with TestConfig, Nothing, TestResult]

Usage Examples:

import zio.test._
import zio.test.Gen._

// Basic property testing
test("list reverse is idempotent") {
  check(listOf(anyInt)) { list =>
    assertTrue(list.reverse.reverse == list)
  }
}

// Multiple generators
test("addition is commutative") {
  check(anyInt, anyInt) { (a, b) =>
    assertTrue(a + b == b + a)
  }
}

// Effectful property testing
test("database round trip") {
  check(Gen.user) { user =>
    for {
      saved <- Database.save(user)
      retrieved <- Database.findById(saved.id)
    } yield assertTrue(retrieved.contains(user))
  }
}

// Exhaustive testing with finite generator
test("boolean operations") {
  checkAll(Gen.boolean, Gen.boolean) { (a, b) =>
    assertTrue((a && b) == !((!a) || (!b))) // De Morgan's law
  }
}

Gen Trait

Core generator trait for producing random values with shrinking capability.

/**
 * Generator for values of type A in environment R
 */
trait Gen[+R, +A] {
  /**
   * Transform generated values
   * @param f transformation function
   * @return generator producing transformed values
   */
  def map[B](f: A => B): Gen[R, B]
  
  /**
   * Chain generators together
   * @param f function producing dependent generator
   * @return generator with chained dependencies
   */
  def flatMap[R1 <: R, B](f: A => Gen[R1, B]): Gen[R1, B]
  
  /**
   * Filter generated values
   * @param f predicate function
   * @return generator producing only values satisfying predicate
   */
  def filter(f: A => Boolean): Gen[R, A]
  
  /**
   * Generate a stream of samples with shrinking information
   * @return stream of generated samples
   */
  def sample: ZStream[R, Nothing, Sample[R, A]]
  
  /**
   * Combine with another generator using applicative
   * @param that other generator
   * @return generator producing tuples
   */
  def <*>[R1 <: R, B](that: Gen[R1, B]): Gen[R1, (A, B)]
  
  /**
   * Generate values within specified size bounds
   * @param min minimum size
   * @param max maximum size
   * @return sized generator
   */
  def between(min: Int, max: Int): Gen[R with Sized, A]
}

Primitive Generators

Built-in generators for basic data types.

// Numeric generators
val anyByte: Gen[Any, Byte]
val anyShort: Gen[Any, Short] 
val anyInt: Gen[Any, Int]
val anyLong: Gen[Any, Long]
val anyFloat: Gen[Any, Float]
val anyDouble: Gen[Any, Double]

// Bounded numeric generators
def byte(min: Byte, max: Byte): Gen[Any, Byte]
def short(min: Short, max: Short): Gen[Any, Short]
def int(min: Int, max: Int): Gen[Any, Int]
def long(min: Long, max: Long): Gen[Any, Long]
def double(min: Double, max: Double): Gen[Any, Double]

// Character and string generators
val anyChar: Gen[Any, Char]
val anyString: Gen[Sized, String]
val anyASCIIString: Gen[Sized, String]
val alphaNumericString: Gen[Sized, String]
val alphaChar: Gen[Any, Char]
val numericChar: Gen[Any, Char]

// Boolean generator
val boolean: Gen[Any, Boolean]

// UUID generator
val anyUUID: Gen[Any, java.util.UUID]

Usage Examples:

import zio.test.Gen._

// Basic generators
test("numeric properties") {
  check(anyInt) { n =>
    assertTrue(n + 0 == n)
  }
}

// Bounded generators
test("percentage calculations") {
  check(double(0.0, 100.0)) { percentage =>
    assertTrue(percentage >= 0.0 && percentage <= 100.0)
  }
}

// String generators
test("string length properties") {
  check(anyString) { str =>
    assertTrue(str.length >= 0 && str.reverse.length == str.length)
  }
}

Collection Generators

Generators for collections with configurable sizes.

/**
 * Generate lists of specified size
 * @param n exact size of generated lists
 * @param g generator for list elements
 * @return generator producing lists of size n
 */
def listOfN[R, A](n: Int)(g: Gen[R, A]): Gen[R, List[A]]

/**
 * Generate lists with sizes bounded by current Sized environment
 * @param g generator for list elements
 * @return generator producing variable-sized lists
 */
def listOf[R, A](g: Gen[R, A]): Gen[R with Sized, List[A]]

/**
 * Generate lists with size between min and max
 * @param min minimum list size
 * @param max maximum list size
 * @param g generator for list elements
 */
def listOfBounded[R, A](min: Int, max: Int)(g: Gen[R, A]): Gen[R, List[A]]

// Vector generators
def vectorOfN[R, A](n: Int)(g: Gen[R, A]): Gen[R, Vector[A]]
def vectorOf[R, A](g: Gen[R, A]): Gen[R with Sized, Vector[A]]

// Set generators (automatically deduplicates)
def setOfN[R, A](n: Int)(g: Gen[R, A]): Gen[R, Set[A]]
def setOf[R, A](g: Gen[R, A]): Gen[R with Sized, Set[A]]

// Map generators
def mapOfN[R, A, B](n: Int)(g: Gen[R, (A, B)]): Gen[R, Map[A, B]]
def mapOf[R, A, B](g: Gen[R, (A, B)]): Gen[R with Sized, Map[A, B]]

// Non-empty collections
def listOf1[R, A](g: Gen[R, A]): Gen[R with Sized, List[A]]
def setOf1[R, A](g: Gen[R, A]): Gen[R with Sized, Set[A]]

Usage Examples:

import zio.test.Gen._

// Fixed-size collections
test("list operations") {
  check(listOfN(5)(anyInt)) { list =>
    assertTrue(list.size == 5 && list.reverse.reverse == list)
  }
}

// Variable-size collections
test("set properties") {
  check(setOf(anyInt)) { set =>
    assertTrue(set.union(set) == set && set.intersect(set) == set)
  }
}

// Non-empty collections
test("head and tail operations") {
  check(listOf1(anyString)) { list =>
    assertTrue(list.nonEmpty && list.head :: list.tail == list)
  }
}

// Maps
test("map properties") {
  check(mapOf(anyString.zip(anyInt))) { map =>
    assertTrue(map.keys.toSet.size <= map.size)
  }
}

Generator Combinators

Functions for combining and transforming generators.

/**
 * Choose randomly from provided generators
 * @param first first generator option
 * @param rest additional generator options
 * @return generator that randomly selects from provided options
 */
def oneOf[R, A](first: Gen[R, A], rest: Gen[R, A]*): Gen[R, A]

/**
 * Create generator from effectful computation
 * @param effect ZIO effect producing values
 * @return generator that runs the effect
 */
def fromZIO[R, A](effect: ZIO[R, Nothing, A]): Gen[R, A]

/**
 * Generate values using unfold pattern
 * @param s initial state
 * @param f function from state to next state and value
 * @return generator using unfold pattern
 */
def unfoldGen[R, S, A](s: S)(f: S => Gen[R, (S, A)]): Gen[R, A]

/**
 * Choose from generators with specified weights
 * @param generators weighted generator options
 * @return generator that selects based on weights
 */
def weighted[R, A](generators: (Gen[R, A], Double)*): Gen[R, A]

/**
 * Generate constant value
 * @param a constant value to generate
 * @return generator always producing the constant
 */
def const[A](a: => A): Gen[Any, A]

/**
 * Generate from explicit list of values
 * @param first first value option
 * @param rest additional value options
 * @return generator randomly selecting from provided values
 */
def elements[A](first: A, rest: A*): Gen[Any, A]

/**
 * Generate optional values (Some or None)
 * @param g generator for Some values
 * @return generator producing Option[A]
 */
def option[R, A](g: Gen[R, A]): Gen[R, Option[A]]

/**
 * Generate Either values
 * @param left generator for Left values
 * @param right generator for Right values
 * @return generator producing Either[A, B]
 */
def either[R, A, B](left: Gen[R, A], right: Gen[R, B]): Gen[R, Either[A, B]]

/**
 * Generate tuples
 * @param g1 generator for first element
 * @param g2 generator for second element
 * @return generator producing tuples
 */
def zip[R, A, B](g1: Gen[R, A], g2: Gen[R, B]): Gen[R, (A, B)]

Usage Examples:

import zio.test.Gen._

// Choose from generators
val stringOrInt: Gen[Any, Any] = oneOf(anyString, anyInt)

// Weighted choice
val mostlyPositive: Gen[Any, Int] = weighted(
  int(1, 100) -> 0.8,
  int(-100, 0) -> 0.2
)

// Constant and elements
val httpMethods = elements("GET", "POST", "PUT", "DELETE")
val alwaysTrue = const(true)

// Option and Either
test("optional values") {
  check(option(anyInt)) { optInt =>
    optInt match {
      case Some(n) => assertTrue(n.toString.nonEmpty)
      case None => assertTrue(true)
    }
  }
}

test("either values") {
  check(either(anyString, anyInt)) { stringOrInt =>
    stringOrInt match {
      case Left(s) => assertTrue(s.isInstanceOf[String])
      case Right(n) => assertTrue(n.isInstanceOf[Int])
    }
  }
}

Custom Generators

Building domain-specific generators for complex data types.

/**
 * Create generator that may fail to produce values
 * @param pf partial function defining generation logic
 * @return generator with filtering logic
 */
def unfoldGen[R, S, A](s: S)(f: S => Gen[R, (S, A)]): Gen[R, A]

/**
 * Create recursive generators with size control
 * @param base base case generator for small sizes
 * @param rec recursive case for larger sizes
 * @return size-aware recursive generator
 */
def sized[R, A](f: Int => Gen[R, A]): Gen[R with Sized, A]

/**
 * Generate from a ZIO effect
 * @param effect effectful value generation
 * @return generator wrapping the effect
 */
def fromZIO[R, A](effect: ZIO[R, Nothing, A]): Gen[R, A]

Usage Examples:

import zio.test.Gen._

// Custom data types
case class User(id: Int, name: String, email: String, age: Int)

val genUser: Gen[Any, User] = for {
  id <- int(1, 10000)
  name <- elements("Alice", "Bob", "Charlie", "Diana")
  domain <- elements("example.com", "test.org", "demo.net")
  age <- int(18, 80)
} yield User(id, name, s"${name.toLowerCase}@$domain", age)

// Recursive data structures
sealed trait Tree[A]
case class Leaf[A](value: A) extends Tree[A]
case class Branch[A](left: Tree[A], right: Tree[A]) extends Tree[A]

def genTree[A](genA: Gen[Any, A]): Gen[Sized, Tree[A]] = {
  val genLeaf = genA.map(Leaf(_))
  val genBranch = for {
    left <- genTree(genA)
    right <- genTree(genA)
  } yield Branch(left, right)
  
  sized { size =>
    if (size <= 1) genLeaf
    else oneOf(genLeaf, genBranch)
  }
}

// Using custom generators
test("user validation") {
  check(genUser) { user =>
    assertTrue(
      user.id > 0 &&
      user.name.nonEmpty &&
      user.email.contains("@") &&
      user.age >= 18
    )
  }
}

Sample Type

Represents generated values with shrinking information for counterexample minimization.

/**
 * A generated sample with shrinking capability
 */
trait Sample[+R, +A] {
  /**
   * The generated value
   */
  def value: A
  
  /**
   * Stream of shrunk variants of this sample
   * @return stream of smaller samples for counterexample minimization
   */
  def shrink: ZStream[R, Nothing, Sample[R, A]]
  
  /**
   * Transform the sample value
   * @param f transformation function
   * @return sample with transformed value
   */
  def map[B](f: A => B): Sample[R, B]
  
  /**
   * Transform with environment modification
   * @param f environment transformation
   * @return sample in transformed environment
   */
  def mapZIO[R1, B](f: A => ZIO[R1, Nothing, B]): Sample[R1, B]
}

Sized Environment

Controls the size of generated collections and data structures.

/**
 * Environment service that provides size bounds for generators
 */
case class Sized(size: Int) extends AnyVal

object Sized {
  /**
   * Create a Sized service with fixed size
   * @param size the size value
   * @return layer providing Sized service
   */
  def live(size: Int): ULayer[Sized]
  
  /**
   * Access current size from environment
   */
  val size: URIO[Sized, Int]
}

Usage Examples:

import zio.test._

// Control generation size
test("large collections").provideLayer(Sized.live(1000)) {
  check(listOf(anyInt)) { largeList =>
    assertTrue(largeList.size <= 1000)
  }
}

// Size-aware generators
val genSizedString: Gen[Sized, String] = sized { size =>
  listOfN(size)(alphaChar).map(_.mkString)
}