ScalaCheck Advanced Generators: Custom Gen, Shrink, and Stateful Testing

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.

Read more

Start now free