ScalaCheck Advanced Generators: Custom Gen, Shrink, and Stateful Testing
ScalaCheck's basic API — forAll, Gen.choose, Arbitrary — covers most property-based testing needs. But real production code involves domain types, complex constraints, and stateful systems. This guide covers the advanced ScalaCheck features that handle those cases: custom generators with combinators, custom Shrink instances, and the Commands API for stateful testing.
Custom Gen Combinators
Gen is a monad. You can compose generators using map, flatMap, and for comprehensions:
import org.scalacheck.Gen
case class Address(
street: String,
city: String,
zipCode: String,
country: String
)
val genZipCode: Gen[String] =
Gen.listOfN(5, Gen.numChar).map(_.mkString)
val genCity: Gen[String] =
Gen.oneOf("New York", "London", "Paris", "Berlin", "Tokyo", "Sydney")
val genStreet: Gen[String] = for {
number <- Gen.choose(1, 9999)
name <- Gen.identifier.map(_.capitalize)
suffix <- Gen.oneOf("St", "Ave", "Blvd", "Dr", "Rd", "Ln")
} yield s"$number $name $suffix"
val genAddress: Gen[Address] = for {
street <- genStreet
city <- genCity
zipCode <- genZipCode
country <- Gen.const("US")
} yield Address(street, city, zipCode, country)Recursive Generators
For tree-like or nested structures, Gen.lzy avoids infinite recursion:
sealed trait Expr
case class Num(n: Int) extends Expr
case class Add(left: Expr, right: Expr) extends Expr
case class Mul(left: Expr, right: Expr) extends Expr
case class Neg(expr: Expr) extends Expr
def genExpr(depth: Int): Gen[Expr] =
if (depth <= 0)
Gen.choose(-100, 100).map(Num.apply)
else
Gen.frequency(
3 -> Gen.choose(-100, 100).map(Num.apply),
2 -> Gen.lzy(for {
l <- genExpr(depth - 1)
r <- genExpr(depth - 1)
} yield Add(l, r)),
2 -> Gen.lzy(for {
l <- genExpr(depth - 1)
r <- genExpr(depth - 1)
} yield Mul(l, r)),
1 -> Gen.lzy(genExpr(depth - 1).map(Neg.apply))
)
val genSmallExpr: Gen[Expr] = genExpr(depth = 3)Gen.frequency picks between generators with given weights. Gen.lzy defers evaluation to avoid stack overflows in recursive generators.
Sized Generators
Gen.sized accesses the current size parameter (default 0–100) to scale generated structures:
def genList[A](genA: Gen[A]): Gen[List[A]] =
Gen.sized(size => Gen.listOfN(size / 10 + 1, genA))
// Generate trees proportional to size
def genTree[A](genA: Gen[A]): Gen[Tree[A]] =
Gen.sized {
case 0 => genA.map(Leaf.apply)
case n => Gen.frequency(
1 -> genA.map(Leaf.apply),
3 -> Gen.resize(n / 2, for {
value <- genA
left <- genTree(genA)
right <- genTree(genA)
} yield Branch(value, left, right))
)
}Gen.resize(n, gen) runs gen with a fixed size n, overriding the current size.
Arbitrary Instances
Define Arbitrary[T] to make forAll work without explicit generators:
import org.scalacheck.{Arbitrary, Gen}
case class Email(value: String)
case class UserId(value: UUID)
case class Money(amount: BigDecimal, currency: String)
object Arbitraries {
implicit val arbEmail: Arbitrary[Email] = Arbitrary(
for {
local <- Gen.identifier
domain <- Gen.oneOf("gmail.com", "yahoo.com", "example.com", "test.org")
} yield Email(s"$local@$domain")
)
implicit val arbUserId: Arbitrary[UserId] =
Arbitrary(Gen.uuid.map(UserId.apply))
implicit val arbMoney: Arbitrary[Money] = Arbitrary(
for {
amount <- Gen.choose(BigDecimal("0.01"), BigDecimal("100000.00"))
.map(_.setScale(2, BigDecimal.RoundingMode.HALF_UP))
currency <- Gen.oneOf("USD", "EUR", "GBP", "JPY")
} yield Money(amount, currency)
)
}With implicit in scope, forAll picks up the generator automatically:
import Arbitraries._
property("email round-trips through parser") = forAll { email: Email =>
EmailParser.parse(email.value).exists(_.value == email.value)
}Custom Shrink Instances
By default, ScalaCheck shrinks integers toward 0, strings toward "", and lists toward empty. For custom types, define Shrink[T]:
import org.scalacheck.Shrink
case class NonEmptyString(value: String)
implicit val shrinkNonEmptyString: Shrink[NonEmptyString] =
Shrink { nes =>
// Shrink by removing characters from the end, but never produce empty string
val shrunkValues = for {
n <- (1 until nes.value.length).toStream
} yield NonEmptyString(nes.value.take(n))
shrunkValues
}
case class PositiveInt(value: Int)
implicit val shrinkPositiveInt: Shrink[PositiveInt] =
Shrink { pi =>
// Shrink toward 1, not toward 0
Shrink.shrink(pi.value)
.filter(_ > 0)
.map(PositiveInt.apply)
}Shrink takes a function T => Stream[T] (or LazyList[T] in Scala 2.13+) that produces shrunk versions. ScalaCheck tries each in order until it can't shrink further.
To disable shrinking for a type:
implicit val noShrink: Shrink[ComplexType] = Shrink(_ => Stream.empty)The Commands API for Stateful Testing
Commands tests a system by generating random command sequences and verifying a model against the real system:
import org.scalacheck.commands.Commands
import org.scalacheck.{Gen, Prop, Properties}
object BankAccountCommands extends Commands {
// The system under test
type Sut = BankAccount
// The model (reference implementation)
case class State(balance: BigDecimal, transactions: List[BigDecimal])
// Initial state generator
def genInitialState: Gen[State] =
Gen.const(State(BigDecimal("0.00"), List.empty))
// Create the real system
def newSut(state: State): Sut = {
val account = new BankAccount()
// Apply initial state if any
state.transactions.foreach {
case amount if amount > 0 => account.deposit(amount)
case amount => account.withdraw(-amount)
}
account
}
def destroySut(sut: Sut): Unit = sut.close()
def canCreateNewSut(newState: State, initSuts: Iterable[State], runningSuts: Iterable[Sut]): Boolean =
true
def initialPreCondition(state: State): Boolean =
state.balance >= 0
// Define commands
def genCommand(state: State): Gen[Command] =
Gen.frequency(
3 -> genDeposit,
2 -> genWithdraw(state),
1 -> Gen.const(GetBalance)
)
private val genDeposit: Gen[Deposit] =
Gen.choose(BigDecimal("0.01"), BigDecimal("1000.00"))
.map(Deposit.apply)
private def genWithdraw(state: State): Gen[Withdraw] = {
val max = state.balance.min(BigDecimal("1000.00"))
if (max <= 0) Gen.const(Withdraw(BigDecimal("0.00")))
else Gen.choose(BigDecimal("0.01"), max).map(Withdraw.apply)
}
// Command implementations
case class Deposit(amount: BigDecimal) extends Command {
type Result = BigDecimal
def run(sut: Sut): Result = {
sut.deposit(amount)
sut.getBalance
}
def nextState(state: State): State =
state.copy(
balance = state.balance + amount,
transactions = state.transactions :+ amount
)
def preCondition(state: State): Boolean = amount > 0
def postCondition(state: State, result: Try[Result]): Prop =
result match {
case Success(balance) =>
Prop(balance == state.balance + amount) :| s"Balance after deposit: expected ${state.balance + amount}, got $balance"
case Failure(e) => Prop.exception(e)
}
}
case class Withdraw(amount: BigDecimal) extends Command {
type Result = Either[String, BigDecimal]
def run(sut: Sut): Result =
sut.withdraw(amount).map(_ => sut.getBalance)
def nextState(state: State): State =
if (amount <= state.balance)
state.copy(balance = state.balance - amount)
else state
def preCondition(state: State): Boolean = amount >= 0
def postCondition(state: State, result: Try[Result]): Prop =
result match {
case Success(Right(balance)) if amount <= state.balance =>
Prop(balance == state.balance - amount)
case Success(Left(_)) if amount > state.balance =>
Prop.proved
case other =>
Prop.falsified :| s"Unexpected result: $other for amount=$amount state=$state"
}
}
case object GetBalance extends Command {
type Result = BigDecimal
def run(sut: Sut): Result = sut.getBalance
def nextState(state: State): State = state
def preCondition(state: State): Boolean = true
def postCondition(state: State, result: Try[Result]): Prop =
result match {
case Success(balance) => Prop(balance == state.balance)
case Failure(e) => Prop.exception(e)
}
}
}
object BankAccountProperties extends Properties("BankAccount") {
property("stateful") = BankAccountCommands.property()
}Gen Helpers Worth Knowing
// Pick one from a list of generators with equal probability
Gen.oneOf(genA, genB, genC)
// Pick from values
Gen.oneOf(1, 2, 3, 4, 5)
// With weights
Gen.frequency(
10 -> Gen.alphaStr, // 10x more likely
1 -> Gen.const("") // empty string occasionally
)
// Containers
Gen.listOf(genA) // 0-n elements
Gen.nonEmptyListOf(genA) // 1-n elements
Gen.listOfN(5, genA) // exactly 5
Gen.containerOf[Set, Int](genA) // any container
// Options and Either
Gen.option(genA) // None or Some(a)
Gen.some(genA) // always Some(a)
Gen.either(genA, genB) // Left(a) or Right(b)
// Maps
Gen.mapOf(Gen.zip(genKey, genValue))
// Conditional
genA.suchThat(predicate) // filter — use sparingly
// Transform
genA.map(f)
genA.flatMap(a => genB(a))Integration with ScalaTest
import org.scalatest.propspec.AnyPropSpec
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
class SortingSpec extends AnyPropSpec with ScalaCheckPropertyChecks {
property("sorted list is idempotent") {
forAll { (list: List[Int]) =>
val sorted = list.sorted
sorted.sorted === sorted
}
}
property("sort preserves elements") {
forAll { (list: List[Int]) =>
list.sorted.toSet === list.toSet
}
}
property("sort is order-preserving") {
forAll { (list: List[Int]) =>
val sorted = list.sorted
sorted.zip(sorted.tail).forall { case (a, b) => a <= b }
}
}
}Integration with MUnit
import munit.ScalaCheckSuite
import org.scalacheck.Prop.forAll
class JsonCodecSuite extends ScalaCheckSuite {
property("encode then decode is identity") {
forAll(genJsonValue) { value =>
val encoded = JsonEncoder.encode(value)
val decoded = JsonDecoder.decode(encoded)
decoded == Right(value)
}
}
property("encoded string is valid JSON") {
forAll(genJsonValue) { value =>
val encoded = JsonEncoder.encode(value)
JsonParser.isValidJson(encoded)
}
}
}Performance Tips
Use Gen.const for expensive objects: If creating a generator is expensive, wrap the result in Gen.const and share it across tests.
Prefer Gen.oneOf(values) over filter: suchThat discards invalid values and retries. For constrained domains, generate the constrained value directly.
Control size for recursive structures: Use Gen.resize to prevent generators from creating extremely deep structures that slow tests.
Profile with verbose = true: forAll(verbose = true)(property) prints each generated value. Use during development to verify your generators produce realistic inputs.