ScalaCheck: Property-Based Testing in Scala

ScalaCheck: Property-Based Testing in Scala

Property-based testing doesn't replace example-based tests — it answers a different question. Where unit tests ask "does this specific input produce this output?", property-based tests ask "does this property hold for ALL inputs of this shape?" ScalaCheck, the original property-based testing library for Scala (and inspiration for Haskell's QuickCheck), automates the generation of hundreds of inputs and reduces failures to minimal examples.

Installation

Standalone or with ScalaTest:

// Standalone
"org.scalacheck" %% "scalacheck" % "1.18.0" % Test

// With ScalaTest integration
"org.scalatest" %% "scalatest-scalacheck" % "3.2.18.0" % Test

Writing Your First Property

ScalaCheck properties describe universal truths about your code:

import org.scalacheck.Prop.forAll
import org.scalacheck.Properties

object StringSpecification extends Properties("String") {

  property("startsWith") = forAll { (a: String, b: String) =>
    (a + b).startsWith(a)
  }

  property("concatenate length") = forAll { (a: String, b: String) =>
    (a + b).length == a.length + b.length
  }

  property("reverse reverse is identity") = forAll { (s: String) =>
    s.reverse.reverse == s
  }
}

Run with:

sbt "testOnly StringSpecification"

ScalaCheck runs each property 100 times (configurable) with randomly generated inputs. If any run fails, it reports the failure and shrinks it to the minimal failing case.

Generators (Gen)

ScalaCheck generates values using Gen. Built-in generators cover most primitive types automatically via Arbitrary:

import org.scalacheck.Gen

// Numeric generators
Gen.posNum[Int]                    // positive integers
Gen.negNum[Int]                    // negative integers
Gen.choose(1, 100)                 // range
Gen.oneOf(1, 2, 3)                 // pick from values

// String generators
Gen.alphaStr                       // alphabetic strings
Gen.numStr                         // numeric strings
Gen.alphaNumStr                    // alphanumeric
Gen.identifier                     // valid identifier

// Collections
Gen.listOf(Gen.posNum[Int])        // list of positive ints
Gen.nonEmptyList(Gen.alphaChar)    // non-empty list
Gen.mapOf(Gen.alphaStr, Gen.posNum[Int])

// Conditional
Gen.posNum[Int].suchThat(_ % 2 == 0)  // even positive numbers

Custom Generators

Define generators for your domain types:

import org.scalacheck.{Arbitrary, Gen}

case class Email(value: String)
case class User(name: String, email: Email, age: Int)

val genEmail: Gen[Email] = for {
  user    <- Gen.identifier
  domain  <- Gen.identifier
  tld     <- Gen.oneOf("com", "org", "net", "io")
} yield Email(s"$user@$domain.$tld")

val genUser: Gen[User] = for {
  name  <- Gen.alphaStr.suchThat(_.nonEmpty)
  email <- genEmail
  age   <- Gen.choose(18, 120)
} yield User(name, email, age)

// Register as Arbitrary so ScalaCheck can use it implicitly
implicit val arbUser: Arbitrary[User] = Arbitrary(genUser)

Once you have implicit val arbUser: Arbitrary[User], ScalaCheck uses it automatically in forAll { (user: User) => ... }.

Using forAll

import org.scalacheck.Prop.forAll

// With Arbitrary (implicit)
forAll { (user: User) =>
  user.age >= 18 && user.age <= 120
}

// With explicit generator
forAll(genUser) { user =>
  user.email.value.contains("@")
}

// Multiple parameters
forAll(genUser, Gen.posNum[Int]) { (user, n) =>
  user.name.length + n > 0
}

// With labels for better error messages
forAll { (user: User) =>
  s"age must be >= 18" |: user.age >= 18
}

Combining Properties

import org.scalacheck.Prop._

// All must hold
forAll { (n: Int) =>
  (n * 2 must be even) &&
  (math.abs(n * 2) >= math.abs(n))
}

// Conditional (implication)
forAll { (n: Int) =>
  (n > 0) ==> (n * 2 > n)  // Only check when n > 0
}

Shrinking

When ScalaCheck finds a failing case, it automatically shrinks the input to the minimal example that still fails. For List[Int], if a property fails with List(4, -3, 15, 8, -100, 42), ScalaCheck will try removing elements and reducing values until it finds the smallest failing case:

! Falsified after 23 passed tests.
> ARG_0: List(-1)
> ARG_0_ORIGINAL: List(4, -3, 15, 8, -100, 42)

Now you know -1 alone causes the failure, not the complex list.

Define custom shrink logic for your types:

import org.scalacheck.Shrink

implicit val shrinkUser: Shrink[User] = Shrink { user =>
  Shrink.shrink(user.name).map(name => user.copy(name = name)) #:::
  Shrink.shrink(user.age).map(age => user.copy(age = age))
}

Integration with ScalaTest

The most common setup pairs ScalaCheck with ScalaTest:

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks

class UserServicePropertySpec extends AnyFlatSpec
    with Matchers
    with ScalaCheckPropertyChecks {

  implicit val arbUser: Arbitrary[User] = Arbitrary(genUser)

  "UserService.validate" should "accept all valid users" in {
    forAll { (user: User) =>
      UserService.validate(user) shouldBe Right(user)
    }
  }

  "UserService.validate" should "reject users under 18" in {
    forAll(genUser.map(_.copy(age = 17))) { user =>
      UserService.validate(user) shouldBe a[Left[_, _]]
    }
  }

  "sorting" should "be idempotent" in {
    forAll { (list: List[Int]) =>
      val sorted = list.sorted
      sorted.sorted shouldEqual sorted
    }
  }
}

Configuring Test Parameters

import org.scalacheck.Test.Parameters

// More runs for critical properties
forAll(minSuccessful(1000)) { (s: String) =>
  s.length >= 0
}

// ScalaTestPlus configuration
implicit override val generatorDrivenConfig =
  PropertyCheckConfiguration(minSuccessful = 500)

Or globally in build.sbt:

Test / testOptions += Tests.Argument(TestFrameworks.ScalaCheck, "-minSuccessfulTests", "500")

Common Property Patterns

Encode/decode round-trips:

forAll { (s: String) =>
  Base64.decode(Base64.encode(s)) == s
}

Sort properties:

forAll { (list: List[Int]) =>
  val sorted = list.sorted
  sorted.zip(sorted.tail).forall { case (a, b) => a <= b }
}

Idempotence:

forAll { (list: List[Int]) =>
  list.distinct.distinct == list.distinct
}

Size invariants:

forAll { (list: List[Int], pred: Int => Boolean) =>
  list.filter(pred).length <= list.length
}

When to Use ScalaCheck

Property tests excel for:

  • Pure functions — parsers, encoders, math utilities
  • Data structures — sorting, searching, set operations
  • Protocol implementations — serialization formats
  • Validation logic — ensure validators reject all invalid inputs

They're less useful for:

  • Code with complex side effects or I/O
  • Business rules that are inherently example-based ("if user is in EU, apply VAT")
  • Tests where generating realistic domain data is prohibitively expensive

Testing Full Application Behavior

ScalaCheck tests Scala logic in isolation. For verifying your full application — including front-end interactions, API responses, and database roundtrips — HelpMeTest provides browser-level test automation. ScalaCheck catches property violations in your core logic; HelpMeTest catches integration failures in your live system.

Summary

ScalaCheck brings rigorous property-based testing to Scala:

  • Automatic input generation — 100+ random test cases per property
  • Shrinking — failures reduced to minimal examples automatically
  • Custom generators — model your domain types precisely
  • ScalaTest/Specs2 integration — works in your existing test framework
  • ==> implication — condition properties on valid inputs

Write your first property for a pure function: find an invariant that always holds, express it with forAll, and let ScalaCheck find the edge cases you missed.

Read more