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" % TestWriting 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 numbersCustom 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.