ScalaTest provides built-in support for property-based testing through generators, table-driven tests, and property checking utilities. This enables testing with automatically generated test data and verification of properties that should hold for all inputs.
Core trait for property-based testing that combines generator-driven and table-driven approaches.
trait PropertyChecks extends TableDrivenPropertyChecks with GeneratorDrivenPropertyChecks {
/**
* Check a property using generated values
*/
def forAll[A](gen: Generator[A])(fun: A => Assertion): Assertion
def forAll[A, B](genA: Generator[A], genB: Generator[B])(fun: (A, B) => Assertion): Assertion
def forAll[A, B, C](genA: Generator[A], genB: Generator[B], genC: Generator[C])(fun: (A, B, C) => Assertion): Assertion
/**
* Check a property using table data
*/
def forAll[A](table: TableFor1[A])(fun: A => Assertion): Assertion
def forAll[A, B](table: TableFor2[A, B])(fun: (A, B) => Assertion): Assertion
def forAll[A, B, C](table: TableFor3[A, B, C])(fun: (A, B, C) => Assertion): Assertion
/**
* Conditional property checking
*/
def whenever(condition: Boolean)(fun: => Assertion): Assertion
}
object PropertyChecks extends PropertyChecksUsage Examples:
import org.scalatest.prop.PropertyChecks
import org.scalatest.funsuite.AnyFunSuite
class PropertyExample extends AnyFunSuite with PropertyChecks {
test("string reverse property") {
forAll { (s: String) =>
s.reverse.reverse should equal (s)
}
}
test("addition is commutative") {
forAll { (a: Int, b: Int) =>
whenever(a > 0 && b > 0) {
a + b should equal (b + a)
}
}
}
test("list concatenation properties") {
forAll { (list1: List[Int], list2: List[Int]) =>
val combined = list1 ++ list2
combined.length should equal (list1.length + list2.length)
combined.take(list1.length) should equal (list1)
combined.drop(list1.length) should equal (list2)
}
}
}Core generators for creating test data with shrinking support.
trait Generator[T] {
/**
* Generate the next value with shrinking support
*/
def next(szp: SizeParam, edges: List[T], rnd: Randomizer): (RoseTree[T], Randomizer)
/**
* Transform generated values
*/
def map[U](f: T => U): Generator[U]
/**
* Flat map for composing generators
*/
def flatMap[U](f: T => Generator[U]): Generator[U]
/**
* Filter generated values
*/
def filter(f: T => Boolean): Generator[T]
/**
* Create pairs of generated values
*/
def zip[U](other: Generator[U]): Generator[(T, U)]
/**
* Generate samples for testing
*/
def sample: Option[T]
def samples(n: Int): List[T]
}
object Generator {
/**
* Create generator from a function
*/
def apply[T](f: (SizeParam, List[T], Randomizer) => (RoseTree[T], Randomizer)): Generator[T]
/**
* Generator that always produces the same value
*/
def const[T](value: T): Generator[T]
/**
* Generator that chooses randomly from provided values
*/
def oneOf[T](values: T*): Generator[T]
def oneOf[T](gen: Generator[T], gens: Generator[T]*): Generator[T]
/**
* Generator that chooses from a weighted distribution
*/
def frequency[T](weightedGens: (Int, Generator[T])*): Generator[T]
/**
* Generator for lists with specified size range
*/
def listOfN[T](n: Int, gen: Generator[T]): Generator[List[T]]
def listOf[T](gen: Generator[T]): Generator[List[T]]
}Usage Examples:
import org.scalatest.prop.Generator
// Custom generators
val evenIntGen = Generator.choose(0, 100).map(_ * 2)
val nonEmptyStringGen = Generator.alphaStr.filter(_.nonEmpty)
// Composed generators
val personGen = for {
name <- Generator.alphaStr.filter(_.nonEmpty)
age <- Generator.choose(0, 120)
email <- Generator.alphaStr.map(_ + "@example.com")
} yield Person(name, age, email)
// Using generators in tests
forAll(evenIntGen) { n =>
n % 2 should equal (0)
}
forAll(personGen) { person =>
person.name should not be empty
person.age should be >= 0
person.email should include ("@")
}Pre-built generators for common data types.
trait CommonGenerators {
// Numeric generators
def choose(min: Int, max: Int): Generator[Int]
def choose(min: Double, max: Double): Generator[Double]
def chooseNum[T: Numeric](min: T, max: T): Generator[T]
// String generators
def alphaChar: Generator[Char]
def alphaNumChar: Generator[Char]
def alphaStr: Generator[String]
def alphaNumStr: Generator[String]
def numStr: Generator[String]
// Collection generators
def listOf[T](gen: Generator[T]): Generator[List[T]]
def vectorOf[T](gen: Generator[T]): Generator[Vector[T]]
def setOf[T](gen: Generator[T]): Generator[Set[T]]
def mapOf[K, V](keyGen: Generator[K], valueGen: Generator[V]): Generator[Map[K, V]]
// Option and Either generators
def option[T](gen: Generator[T]): Generator[Option[T]]
def either[A, B](genA: Generator[A], genB: Generator[B]): Generator[Either[A, B]]
// Tuple generators
def tuple2[A, B](genA: Generator[A], genB: Generator[B]): Generator[(A, B)]
def tuple3[A, B, C](genA: Generator[A], genB: Generator[B], genC: Generator[C]): Generator[(A, B, C)]
}
object CommonGenerators extends CommonGeneratorsUsage Examples:
import org.scalatest.prop.CommonGenerators._
// Using pre-built generators
val emailGen = for {
username <- alphaStr.filter(_.nonEmpty)
domain <- alphaStr.filter(_.nonEmpty)
} yield s"$username@$domain.com"
val phoneGen = numStr.map(_.take(10).padTo(10, '0'))
val addressGen = for {
street <- alphaNumStr
city <- alphaStr
zipCode <- numStr.map(_.take(5))
} yield Address(street, city, zipCode)Structured approach to testing with predefined data sets.
/**
* Table with one column of test data
*/
case class TableFor1[A](heading: String, rows: A*) extends Iterable[A] {
def iterator: Iterator[A] = rows.iterator
}
/**
* Table with two columns of test data
*/
case class TableFor2[A, B](heading1: String, heading2: String, rows: (A, B)*) extends Iterable[(A, B)] {
def iterator: Iterator[(A, B)] = rows.iterator
}
/**
* Helper for creating tables
*/
object Table {
def apply[A](heading: String, rows: A*): TableFor1[A] = TableFor1(heading, rows: _*)
def apply[A, B](heading1: String, heading2: String, rows: (A, B)*): TableFor2[A, B] =
TableFor2(heading1, heading2, rows: _*)
}
trait TableDrivenPropertyChecks {
/**
* Check property for all rows in table
*/
def forAll[A](table: TableFor1[A])(fun: A => Assertion): Assertion
def forAll[A, B](table: TableFor2[A, B])(fun: (A, B) => Assertion): Assertion
}Usage Examples:
import org.scalatest.prop.{Table, TableDrivenPropertyChecks}
class TableDrivenExample extends AnyFunSuite with TableDrivenPropertyChecks {
test("mathematical operations") {
val examples = Table(
("input", "expected"),
(2, 4),
(3, 9),
(4, 16),
(5, 25)
)
forAll(examples) { (input, expected) =>
input * input should equal (expected)
}
}
test("string operations") {
val stringExamples = Table(
"input" -> "expected",
"hello" -> "HELLO",
"world" -> "WORLD",
"test" -> "TEST"
)
forAll(stringExamples) { (input, expected) =>
input.toUpperCase should equal (expected)
}
}
}Configurable parameters for property-based testing.
trait Configuration {
/**
* Minimum successful tests before property passes
*/
def minSuccessful: Int = 100
/**
* Maximum number of discarded tests before failure
*/
def maxDiscarded: Int = 500
/**
* Minimum size parameter for generators
*/
def minSize: Int = 0
/**
* Maximum size parameter for generators
*/
def maxSize: Int = 100
/**
* Number of workers for parallel testing
*/
def workers: Int = 1
}
/**
* Immutable configuration for property checks
*/
case class PropertyCheckConfiguration(
minSuccessful: Int = 100,
maxDiscarded: Int = 500,
minSize: Int = 0,
maxSize: Int = 100,
workers: Int = 1
) extends ConfigurationUsage Examples:
// Custom configuration
implicit val config = PropertyCheckConfiguration(
minSuccessful = 50, // Fewer required successes
maxDiscarded = 1000, // Allow more discarded tests
maxSize = 200 // Larger test data
)
forAll { (list: List[Int]) =>
list.reverse.reverse should equal (list)
}
// Configuration for specific test
forAll(PropertyCheckConfiguration(minSuccessful = 1000)) { (n: Int) =>
math.abs(n) should be >= 0
}/**
* Random value generator with seed
*/
case class Randomizer(seed: Long) {
def nextInt: (Int, Randomizer)
def nextLong: (Long, Randomizer)
def nextDouble: (Double, Randomizer)
def nextBoolean: (Boolean, Randomizer)
def choose[T](values: Vector[T]): (T, Randomizer)
}
/**
* Size parameter for generators
*/
case class SizeParam(value: Int) extends AnyVal {
def inc: SizeParam = SizeParam(value + 1)
def dec: SizeParam = SizeParam(math.max(0, value - 1))
}
/**
* Tree structure for shrinking test failures
*/
trait RoseTree[+T] {
def value: T
def shrinks: Stream[RoseTree[T]]
def map[U](f: T => U): RoseTree[U]
def flatMap[U](f: T => RoseTree[U]): RoseTree[U]
}
object RoseTree {
def apply[T](value: T, shrinks: Stream[RoseTree[T]] = Stream.empty): RoseTree[T]
}