Skip to Content
Course content

94: Property-Based Testing with ScalaCheck

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR for a colleague who had written a custom function to merge two sorted lists. He had a great set of unit tests: he tested two empty lists, two lists of equal length, and one empty list with one populated one. Everything passed. I felt confident, we merged it, and two days later, the production logs started screaming. It turned out that when the lists contained duplicate values in a specific alternating pattern, the merge logic skipped an element. We had missed a case that was mathematically possible but didn't occur to us during the "brainstorming" phase of writing unit tests.

That's the fundamental weakness of example-based testing: you are limited by your own imagination. You only test the scenarios you can think of. Property-based testing (PBT), and specifically ScalaCheck, flips this on its head. Instead of providing specific inputs and expected outputs, you define properties—invariants that should always hold true regardless of the input—and you let the library try its hardest to prove you wrong by throwing thousands of random inputs at your code.

Defining Invariants Over Examples

When you use ScalaCheck, you stop thinking about "If I give it X, I should get Y" and start thinking about "No matter what I give it, Z should always be true." For instance, if you're writing a function to reverse a list, you don't need to test List(1, 2, 3). Instead, you define a property: reversing a list twice should return the original list. This is an invariant.

import org.scalacheck.Properties
import org.scalacheck.Gen

object ListProperties extends Properties("ListProperties") {
  property("reversing twice returns original") = forAll { (list: List[Int]) =>
    list.reverse.reverse == list
  }
}

In the example above, forAll is where the magic happens. ScalaCheck looks at the type List[Int] and automatically generates a vast array of test cases: empty lists, lists with one element, lists with a million elements, and lists with huge integers. If it finds a case that returns false, it doesn't just tell you it failed; it tells you exactly which input caused the crash.

Generating Domain-Specific Data

Generic types like Int or String are great, but in the real world, your functions usually expect data that fits a specific shape. You wouldn't want to test a "User Age" field with negative numbers or strings that are ten megabytes long. This is where Gen comes in. You can create custom generators to constrain the data ScalaCheck produces.

I've found that the most powerful way to use generators is by combining them. If you have a User case class, you don't want to manually instantiate it 100 times; you build a generator that composes smaller generators together.

case class User(id: Int, username: String, age: Int)

val userGen: Gen[User] = for {
  id <- Gen.choose(1, 10000)
  username <- Gen.alphaNumStr
  age <- Gen.choose(18, 120)
} yield User(id, username, age)

property("user age is always within legal bounds") = forAll(userGen) { user =>
  user.age >= 18 && user.age <= 120
}

By narrowing the scope of the input, you spend less time debugging "impossible" failures and more time finding the actual edge cases in your business logic.

The Power of Shrinking

One of the most frustrating things about random testing is when the library finds a failure but the input is a massive, messy string of 500 characters that is impossible to debug. This is why ScalaCheck's "shrinking" process is a lifesaver. When ScalaCheck finds a failing input, it doesn't just stop. It attempts to "shrink" that input to the smallest possible value that still triggers the failure.

If a list of 100 integers causes your function to throw an exception, ScalaCheck will try removing elements or reducing the size of the integers until it finds the minimal example. Instead of a 100-element list, it might tell you: "This fails specifically when the list contains exactly one negative number." This turns a needle-in-a-haystack debugging session into a trivial fix. It's honestly a bit like having a tiny, malicious agent whose only job is to find the most efficient way to break your code.




📋 Practical Task

Testing a Balanced Parentheses Parser

You have been handed a function called isBalanced(input: String): Boolean. This function is supposed to return true if the parentheses in a string are properly balanced (e.g., "(())"` is true, "()()"` is true, but "(()"` is false).

Your task is to write a ScalaCheck property test to verify this function. However, simply testing a few strings isn't enough. You must:

  • Create a custom Gen[String] that generates strings containing only parentheses '(' and ')'.
  • Write a property that verifies a "known truth": if you take any balanced string and wrap it in another pair of parentheses (e.g., "()" becomes "(())"), the resulting string must also be balanced.
  • Write a second property that verifies that any string with an odd length can never be balanced.

Implement the Properties class and run it against the provided isBalanced implementation to see if you can find any edge cases where the logic fails.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.