Last updated on
Property-based testing
Welcome to week 12 of CS-214 — Software Construction!
As usual, ⭐️ indicates the most important exercises and questions and 🔥 indicates the most challenging ones. Exercises or questions marked 🧪 are intended to build up to concepts used in this week’s lab.
You do not need to complete all exercises to succeed in this class, and you do not need to do all exercises in the order they are written.
We strongly encourage you to solve the exercises on paper first, in groups. After completing a first draft on paper, you may want to check your solutions on your computer. To do so, you can download the scaffold code (ZIP).
Most of the tests we have worked with so far have checked whether a program behaves correctly on a given set of example inputs, where we specify what the “correct behavior” is for each input (e.g. by checking the observed output against the hard-coded expected output). But often, we have a more abstract understanding of what it means for the program to be correct: we want the program’s output to satisfy certain properties for all inputs. In practice, the set of all possible inputs may be too large to be worth testing exhaustively (or even infinite), so we instead run the program on some randomly generated inputs. This technique is called property-based testing (PBT).
Note that PBT does not give us a 100% guarantee that the program satisfies the stated properties, because the randomly generated inputs cover only a fraction of the whole space of possible inputs. However, it still increases our confidence in the correctness of the program, and has only a moderate cost in terms of time and effort, making it a valuable component of the software correctness toolkit.
For this exercise, we will use ScalaCheck, a Scala PBT library.
mSort
Problem 1 in the 2024 midterm gave you the implementation of an algorithm mSort
and asked, “for each of the following properties, indicate whether it is true for all lists ls
of type List[Int]
”. Below is the implementation of mSort
. The implementations of some helper functions were summarized with require
-ensuring
clauses in the midterm, but they are included in full below.
def mSort(ls: List[Int]): List[Int] =
ls match
case Nil => Nil
case a :: Nil => a :: Nil
case a :: b :: tail =>
val (ll, lr) = split(tail)
merge(mSort(ll), mSort(lr))
/** Check if a given list is sorted */
def isSortedAscending(ls: List[Int]): Boolean =
ls == ls.sortWith(_ < _)
/** Given two sorted lists, merge them into a new sorted list */
def merge(ll: List[Int], lr: List[Int]): List[Int] = {
require(isSortedAscending(ll) && isSortedAscending(lr))
(ll, lr) match
case (Nil, _) => lr
case (_, Nil) => ll
case (lh :: lt, rh :: rt) =>
if lh < rh then lh :: merge(lt, lr)
else rh :: merge(ll, rt)
}.ensuring { l =>
l == (ll ++ lr).sortWith(_ < _)
}
/** Split a list into two lists containing the first and second halves
* respectively
*/
def split[A](ls: List[A]): (List[A], List[A]) = {
val (ll, lr) = ls.splitAt(ls.length / 2)
(ll, lr)
}.ensuring { case (ll, lr) =>
ls == ll ++ lr && ll.length == ls.length / 2
}
src/main/scala/pbt/MSort.scala
And here are the properties under consideration:
// Property 1
mSort(ls).length <= 1
// Property 2
mSort(ls).length <= 2
// Property 3
isSortedAscending(mSort(ls))
// Property 4
mSort(ls).length == ls.length
// Property 5
mSort(ls).length <= ls.length
-
Specify those properties in
MSortTests.scala
using ScalaCheck. If you are not sure how to use the library, consult its documentation. Change their names from “Property X” to a more descriptive but concise phrase.property("Property 1") = ??? property("Property 2") = ??? property("Property 3") = ??? property("Property 4") = ??? property("Property 5") = ???
src/test/scala/pbt/MSortTests.scala
-
Run the tests with
test
in SBT and see if the properties hold. If a property fails, you will see a message like:failing seed for MyTest.myproperty is 8BrWGTql0mzLgIvMVGiNy0ElC7pOMWhVE3xcntwGbOF= [info] ! MyTest.myproperty: Falsified after 8 passed tests. [info] > ARG_0: List("0", "0") [info] > ARG_0_ORIGINAL: List("-1375979208", "2147483647")
- The “seed”,
8BrW…
, is for the pseudorandom input generator. It is printed so that you can reproduce the failure with the “same” random inputs if needed:testOnly -- -initialSeed 8BrW…
. ARG_0
refers to the first (zero-indexed) argument to the property.ARG_0_ORIGINAL
is the original input that triggered the failure; ScalaCheck tries to automatically simplify and minimize such inputs for easier debugging, andARG_0
is the minimal input it could find.
- The “seed”,
-
If a property doesn’t hold, decide whether it should hold for a correct implementation or the property itself is incorrect. Fix the implementation of
mSort
so that the right properties hold.
Quadtrees ⭐️
A few weeks ago, we tried to improve boids
with a new data structure:
In an attempt at making a
boids
callback better, we have implemented a quadtree data structure to hold boid collections. Quadtrees are a two-dimensional analogue of binary search trees: they are used to organize point clouds in a way that supports quick filtering and lookups (inboids
, this would allow us to quickly determine which boids are close to each other, in much less time than a linear filter of all boids).
We implemented a number of methods on the quadtree: size
, insert
, contains
, filter
, iterator
and a fromCollection
. Sadly, under release deadline pressure, our implementation was buggy. Your task in this exercise is to identify the bugs using property-based tests, find and fix them in the code, and finally (to finish the task we started) patch boids
to use the quadtree.
Here is the type definition of a quadtree:
enum QuadTree[+T] extends Iterable[T]:
case Empty
case Leaf(t: List[WithPos[T]])
case Quad(center: Vector2, nw: QuadTree[T], ne: QuadTree[T], sw: QuadTree[T], se: QuadTree[T])
src/main/scala/pbt/QuadTree.scala
Which properties?
First, write down, on paper, the properties that you think a quadtree with the methods mentioned above should satisfy. For example, what can you say about the size of a quadtree? How does the size change after inserting an element, or after filtering the quadtree with various predicates?
PBT with custom types
PBT works by generating random inputs of the right types for the properties being tested. ScalaCheck has predefined generators for standard types like Int
and List
, but not for the custom types we’ve defined, like Vector2
and QuadTree
, so you will have to write those generators. Here is an example for Vector2
:
case class PosBounds(xMin: Double, xMax: Double, yMin: Double, yMax: Double):
val xRange = xMax - xMin
val yRange = yMax - yMin
def transformX(x: Double): Double = x * xRange + xMin
def transformY(y: Double): Double = y * yRange + yMin
given PosBounds(-100, 100, -100, 100)
def position(using bounds: PosBounds): Gen[Vector2] =
for
x <- Gen.double
y <- Gen.double
yield Vector2(bounds.transformX(x), bounds.transformY(y))
src/test/scala/pbt/QuadTreeTests.scala
Gen.double
uniformly samples the interval [0, 1), which we linearly transform to the interval specified in bounds
. There is a default given instance of PosBounds
, but code that calls position
may pass in more specific instances.
Note that the generator does not explicitly use any randomness or specify the order in which random inputs are generated—it just generates one random input, delegating the randomness to Gen.double
. In general, all the generators you write will be composed of these constructors (such as Gen.const
) and combinators (such as Gen.listOfN
) provided by ScalaCheck. Read the ScalaCheck user guide to learn how to use them. If you need even more details, consult the API documentation.
-
Implement the generator for the
WithPos
class.def withPos(using bounds: PosBounds): Gen[WithPos[Double]] = ???
src/test/scala/pbt/QuadTreeTests.scala
-
Implement the generator for an
Empty
quadtree.val genEmpty: Gen[QuadTree[Double]] = ???
src/test/scala/pbt/QuadTreeTests.scala
-
Implement the generator for a
Leaf
quadtree. Respect the invariant that a leaf can only have up to 4 elements.def genLeaves(using bounds: PosBounds): Gen[QuadTree[Double]] = ???
src/test/scala/pbt/QuadTreeTests.scala
-
Implement the generator for a
Quad
quadtree. Use thebounds
parameter to respect the invariant that all elements in thenw
quadtree should be to the northwest of thepivot
of theQuad
, etc.def genQuads(using bounds: PosBounds): Gen[QuadTree[Double]] = ???
src/test/scala/pbt/QuadTreeTests.scala
-
Implement the generator for a
QuadTree
. Hint: “Generating case classes” in the ScalaCheck user guide.val genQuadTree: Gen[QuadTree[Double]] = ???
src/test/scala/pbt/QuadTreeTests.scala
Implementing properties
Now that we have generators for our custom types, implement the properties you previously identified.
🔜 Technically, there is one more step before ScalaCheck can use our custom generators, involving given
instances of the Arbitrary
class. We’ve already written that code for you, so you can ignore it for now, but you can read the relevant sections of the user guide if you are curious. We will come back to this in a later exercise.
Fixing the bugs
Run the tests and see if the properties you wrote hold. If they don’t, either fix the implementation of the quadtree so that they do, or fix the properties to better reflect how a “correct” quadtree should behave.
🔥 There is one bug that is nearly unreachable with PBT, that causes an infinite recursion. Post on Ed if you are able to find it with PBT or with some other technique! And come to the verification lecture to find out how to do even better than PBT and eliminate such bugs entirely.