Check that the tests fail
def lettersOnly(string: String): Boolean = false
Tests fail assert(lettersOnly("") == true)
Implement the feature
def lettersOnly(string: String): Boolean = {
val letters = ('a' until 'z').toSet
string.forall(c => letters.contains(c.toLower))
}
Run the tests
- All tests pass
- Code coverage: 100%
- Branch coverage: 100%
This looks good, but our code is actually WRONG:
def lettersOnly(string: String): Boolean = {
val letters = ('a' until 'z').toSet
string.forall(c => letters.contains(c.toLower))
}
Can you spot the error?
How could we avoid this?
Problems with our approach
- Tests only test a small sample of the total input space
- Ideally, we would like to test the whole input space
- ...
- Possible Solution: Use random inputs
Error: lettersOnly("qudwhdwqhizuqhidioa") is false
- This helps, but it's hard to find out what's causing the bug
QuickCheck
- Created in 1999 by Koen Claessen and John Hughes, in Haskell
- Ported to multiple languages (e.g. ScalaCheck)
- Two main concepts:
Gen[T]
- Generates an arbitrary value of typeT
Shrinks[T]
- Given a value, attempts to generate a simpler value
- For each property:
- Generate a random input
x: T
usingGen[T]
- If the test succeeds, test again with another
x
(N times) - If the test fails, repeatedly shrink the input with
Shrink[T]
until it the test succeeds or it can't be shrunk anymore.
- Generate a random input
Arbitrary vs Gen
- QuickCheck contains an extra typeclass
Arbitrary[T](arbitrary: Gen[T])
- In ScalaCheck,
Gen
s are explicit whileArbitrary
s are used in implicit searches.
Example
Arbitrary.arbitrary[String]: Gen[String]
- DefaultString
generatorGen.alphaStr: Gen[String]
- Generates onlyString
s in[a-zA-Z]*
The included generators are also biased towards known edge cases (empty strings, 0,...)
Properties
Our tests are now written as properties (Prop
) of the form
Example
forAll((x: Int) => prop(x)) // Using Arbitrary[Int]
forAll(Gen.alphaStr)(str => prop(str)) // Using a custom Gen[String]
forAll((x: Int, str: String) => prop(x, str)) // Using multiple arguments
Properties can be combined (&&
, ||
, ==>
,...) or tested (check()
)
Example
forAll((x: Int) => x != 0 ==> exists((y: Int) => prop(y / x))).check()
QuickCheck approach
Our new test suite:
- Tests known
true
casesforAll(Gen.alphaLowerStr)(str => lettersOnly(str) == true).check() forAll(Gen.alphaUpperStr)(str => lettersOnly(str) == true).check() forAll(Gen.alphaStr)(str => lettersOnly(str) == true).check()
- Tests known
false
cases// Create a new generator for strings with numbers // (Possibly with letters) val genMixedStr = Gen.zip( Gen.alphaStr, // Note that this can be empty Gen.choose(0, 1000)).map { case (s, n) => s + n } forAll(genMixedStr)(str => lettersOnly(str) == false).check() // Creates a generator for whitespace strings val genWhitespaceStr = Gen.nonEmptyListOf(Gen.oneOf(' ', '\t', '\r', '\n', '\u0000')) .map(_.mkString) forAll(genWhitespaceStr)(str => lettersOnly(str) == false).check()
We should also add a Shrink[String]
to shrink our examples:
// Shrink our examples by removing one letter from the string
implicit val shrinkString: Shrink[String] = Shrink { s =>
Set(s.drop(1), s.dropRight(1)).filter(_ != s).toStream
}
Test Results
! Falsified after 6 passed tests.
> ARG_0: "z"
> ARG_0_ORIGINAL: "eivzh"
ARG_0_ORIGINAL
: Original tested value (e.g."eivzh"
)ARG_0
: Shrunken value (e.g"z"
)
It appears that z
is an invalid input!
def lettersOnly(string: String): Boolean = {
val letters = ('a' until 'z').toSet
string.forall(c => letters.contains(c.toLower))
}
Should actually be:
def lettersOnly(string: String): Boolean = {
val letters = ('a' to 'z').toSet
string.forall(c => letters.contains(c.toLower))
}
Shrinking
We implemented our custom Shrink[String]
, but ScalaCheck already comes with a default implementation.
Let's try it!
Result with the default shrinker:
! Falsified after 4 passed tests.
> ARG_0: "\u0000"
> ARG_0_ORIGINAL: "ezg"
- Something went wrong...
lettersOnly("\u0000") == false
, as expected.- Our test was
forAll(Gen.alphaLowerStr)(str => lettersOnly(str) == true)
- It failed the test because it used a
String
that is not analphaLowerStr
- It failed the test because it used a
- The Shrinker has no knowledge of the generator, so it can generate invalid values!!!
Hedgehog
- Alternative to QuickCheck
- Created in 2017
Shrink
is now part ofGen
- Instead of generating values,
Gen
generates aTree[A](value: A, children: LazyList[Tree[A]])
internally - This solves our problem
- Creating a new
Gen
is slightly more cumbersome
- Instead of generating values,
- Less supported than QuickCheck
Hedgehog implementation
// Helper function to avoid the hedgehog-runtime boilerplate
def forAll(gen: GenT[String], expected: Boolean): Unit = ???
// Manually define our generators
val alphaLowerStr = Gen.string(Gen.lower, Range.linear(0, 10))
val alphaUpperStr = Gen.string(Gen.upper, Range.linear(0, 10))
val alphaStr = Gen.string(Gen.alpha, Range.linear(0, 10))
forAll(alphaLowerStr, true)
forAll(alphaUpperStr, true)
forAll(alphaStr, true)
// Create a new generator for numeric strings
val numStr = Gen.string(Gen.digit, Range.linear(1, 10))
forAll(numStr, false)
// Create a new generator for mixed strings
val genMixedStr: GenT[String] = for {
alpha <- alphaStr
num <- numStr
} yield alpha + num
forAll(genMixedStr, false)
Tips
- When a test fails, add it to your unit tests (can be a useful regression test)
- Avoid the use of
Gen.filter
- Don't reimplement your logic
- Imagine if we did
Gen.listOf(('a' until 'z'))
- Test properties using other unrelated methods
- e.g.
forAll { list: List[Int] => list.lastOption == list.reverse.headOption }
- e.g.
- Imagine if we did
- You don't need to use property based tests everywhere
- e.g. "
foo
should parse a gzipped csv"
- e.g. "
Testing Stateful Systems
We want to test a system that:
- Is deterministic
- Can perform side-effects
- Has an internal state
Example Problem
We want to write a simple mutable Hash Map
class HashMap[K, V](???) {
def get(key: K): Option[V] = ???
def put(key: K, value: V): Unit = ???
def delete(key: K): Unit = ???
def toList(): List[(K, V)] = ???
}
Naive approach
Write a set of test cases with known results
- Create a new HashMap
val map = new HashMap[String, Int]()
- Check that the map starts empty
assert(map.toList.isEmpty == true)
assert(map.get("") == None)
assert(map.get("asd") == None)
- Check that insertions only insert the specified value
map.put("asd", 1)
assert(map.get("") == None)
assert(map.get("asd") == Some(1))
assert(map.get("dsa") == None)
map.put("dsa", 2)
assert(map.get("") == None)
assert(map.get("asd") == Some(1))
assert(map.get("dsa") == Some(2))
- Check that updates only update the specified value
map.put("asd", 3)
assert(map.get("") == None)
assert(map.get("asd") == Some(3))
assert(map.get("dsa") == Some(2))
assert(map.toList.sortBy(_._1) == List("asd" -> 3, "dsa" -> 2))
- Check that deletions remove the specified value
map.delete("asd")
assert(map.get("asd") == None)
assert(map.get("dsa") == Some(2))
- Inspect the final state
assert(map.toList.sortBy(_._1) == List("dsa" -> 2))
Check that the tests fail
class HashMap[K, V]() {
def get(key: K): Option[V] = None
def put(key: K, value: V): Unit = ()
def delete(key: K): Unit = ()
def toList(): List[(K, V)] = Nil
}
Fails on assert(map.get("asd") == Some(1))
Implement the feature
class HashMap[K, V](capacity: Int = 128) {
private val buffer: mutable.ArrayBuffer[List[(K, V)]] = mutable.ArrayBuffer.fill(capacity)(List.empty)
def get(key: K): Option[V] = {
val pos = Math.floorMod(key.##, capacity)
buffer(pos).find(_._1 == key).map(_._2)
}
def put(key: K, value: V): Unit = {
val pos = Math.floorMod(key.##, capacity)
val newList = (key, value) :: buffer(pos).filter(_._1 != key)
buffer(pos) = newList
}
def delete(key: K): Unit = {
val pos = Math.floorMod(key.##, capacity)
buffer(pos) = Nil
}
def toList(): List[(K, V)] = buffer.flatten.toList
}
Run the tests
- All tests pass
- Code coverage: 100%
- Branch coverage: 100%
However, again, our code is WRONG!
Problems with our approach
- Tests only a sequence of state transitions
- Ideally, we would like to test multiple sequences of state transitions
Stateful tests with QuickCheck
- ScalaCheck provides a
Commands
API with:Sut
: Mutable system under testState
: Immutable state that models ourSut
(usually a simpler model)Command
: An operation to execute on ourSut
that:- Has a pre-condition
- Has a post-condition
- Performs a
State
transition
Commands API
We define our State
and Sut
and initial states:
type State = Map[String, Int]
type Sut = HashMap[String, Int]
// We can always create a new HashMap
// This is useful if, for example, our SUT calls a running container
def canCreateNewSut(
newState: State,
initSuts: Traversable[State],
runningSuts: Traversable[Sut]): Boolean = true
def destroySut(sut: Sut): Unit = ()
def genInitialState: Gen[State] = Gen.const(Map.empty)
// Used to shrink the initial state
def initialPreCondition(state: State): Boolean = state.isEmpty
We define our Commands:
case class Get(key: String) extends Command {
type Result = Option[Int]
def run(sut: Sut): Result = sut.get(key)
def nextState(state: State): State = state
def preCondition(state: State): Boolean = true
def postCondition(state: State, result: Try[Result]): Prop = result == Success(state.get(key))
}
case class Put(key: String, value: Int) extends UnitCommand {
def run(sut: Sut): Unit = sut.put(key, value)
def nextState(state: State): State = state + (key -> value)
def preCondition(state: State): Boolean = true
def postCondition(state: State, success: Boolean): Prop = success
}
case class Delete(key: String) extends UnitCommand {
def run(sut: Sut): Result = sut.delete(key)
def nextState(state: State): State = state - key
def preCondition(state: State): Boolean = true
def postCondition(state: State, success: Boolean): Prop = success
}
case object ToList extends Command {
type Result = List[(String, Int)]
def run(sut: Sut): Result = sut.toList()
def nextState(state: State): State = state
def preCondition(state: State): Boolean = true
def postCondition(state: State, result: Try[Result]): Prop =
result.map(_.sortBy(_._1)) == Success(state.toList.sortBy(_._1))
}
We define our Gen[Command]
:
def genCommand(state: State): Gen[Command] = {
// Generates a key that is present on the map
val keyGen = Gen.oneOf[String]("default_key" :: state.keys.toList)
Gen.oneOf(
Gen.alphaLowerStr.map(str => Get(str)),
keyGen.map(str => Get(str)),
Gen.zip(Gen.alphaLowerStr, arbitrary[Int]).map { case (k, v) => Put(k, v) },
Gen.zip(keyGen, arbitrary[Int]).map { case (k, v) => Put(k, v) },
Gen.alphaLowerStr.map(str => Delete(str)),
keyGen.map(str => Delete(str)),
Gen.const(ToList))
}
And finally, we run our tests:
! Falsified after 64 passed tests.
> Labels of failing property:
initialstate = Map()
seqcmds = (Put(suyqjkjeehbqtlaqnxazyatmzbnxfjqvmkporafjpgtuxtluylwzhkinfm,0
); Delete(default_key); ToList => List())
> ARG_0: Actions(Map(),List(Put(suyqjkjeehbqtlaqnxazyatmzbnxfjqvmkporafjpgt
uxtluylwzhkinfm,0), Delete(default_key), ToList),List())
> ARG_0_ORIGINAL: Actions(Map(),List(Get(cokxwqslowszddtkgncnlvclqdiuahsjzb
ukerkgyhibjifhncndzatxkjomc), Delete(default_key), Put(default_key,-21474
83648), Delete(ihmvdlplfwlckxiyutg), Get(yrylpvrjhaneqnmhnujyrvumtmxuilpu
cbeqjwddemmxjbioaxymoyvpebr), Get(ykjetehimcu), Put(default_key,-21474836
48), ToList, Get(mjylrfcfhhvghjvzrreifwqbydceuvpxuxqx), ToList, ToList, P
ut(suyqjkjeehbqtlaqnxazyatmzbnxfjqvmkporafjpgtuxtluylwzhkinfm,0), Get(pxp
oszdvqnkekigkkrtaxsmhywnrbxwkxtlncugtcknjrqhajbcmolxvupsfdz), Put(bxmquea
fgojulofqmynoey,-1957587762), Get(tleuphfwc), Delete(bxmqueafgojulofqmyno
ey), Get(default_key), Delete(default_key), ToList, Delete(default_key),
ToList, Delete(oplfxpgkekfyeutounmnayx), Get(suyqjkjeehbqtlaqnxazyatmzbnx
fjqvmkporafjpgtuxtluylwzhkinfm), Delete(suyqjkjeehbqtlaqnxazyatmzbnxfjqvm
kporafjpgtuxtluylwzhkinfm), Put(twjsvt,-1691763425), Put(twjsvt,117511834
3), Delete(wfcihiykssmeunkuyhwxwcdvfgyuijwgcadveoxxeqedkurrogfqmiisjiwdjp
je), Get(lrmfsyyylnmolhalgzajyonco), ToList, Put(nsumljqwsqa,-706203824),
Get(nsumljqwsqa), Put(yjtuzxjmseidjypombuyjolvtmquwqohpxqvzyckqllvrjlpxh
agmc,-1), Put(zmidlhfuptraspkvlydwxwnexfobbwjhdlcgsvpk,1), Put(zmidlhfupt
raspkvlydwxwnexfobbwjhdlcgsvpk,2147483647), Get(default_key), Delete(bnbj
nbdyqvcot), Get(twjsvt), Put(jyjsiozaaoxdcvecgcjupbgpmufqqjpgkxeoqrbgtwnq
,-459846117), Put(fxklvxqvrvxmmaiiyimdoxcnlgwkxmdmoyoafiizbjmasxt,0), Del
ete(yjtuzxjmseidjypombuyjolvtmquwqohpxqvzyckqllvrjlpxhagmc), Delete(mxdhd
hcxgdkrnkpmatwcxlyejlcvypfdkcqelbomqsgumhbvjnpl), Get(default_key), Put(g
zmvbjkvlabvfoxac,2081989555), Get(fmujwqjwuoadrpwcxiebjaxkkzkbpvvqvjw), D
elete(rqnwjg), ToList, Delete(zqybwwzmdaqtuappuhon), Put(fxklvxqvrvxmmaii
yimdoxcnlgwkxmdmoyoafiizbjmasxt,-2147483648), Delete(wzgovsegmodtugwqdahg
piwfxswcwvnqrzrlnmsekfczeictkycflys), Delete(kmdisbyowaoqnrmurtbbhylwmrtk
hdkl), Get(ztjaikdqvzjuvgloyflunvunrzsqoehojzxxuptlyrzony), Delete(zmidlh
fuptraspkvlydwxwnexfobbwjhdlcgsvpk), Delete(cmksdudjpbiknumyhejatsobcnlcr
ldlvdhlif), Delete(fccbggmlakbnwxnwavwrw), ToList, Put(ritukulpo,1), Put(
fxklvxqvrvxmmaiiyimdoxcnlgwkxmdmoyoafiizbjmasxt,1), ToList, Put(ritukulpo
,-700753175), Put(apqcnwqwywwnsuclkiloxgiuubhnf,-751821836), Put(wlewoygr
f,2147483647), Get(gzmvbjkvlabvfoxac), Get(twjsvt), Put(jyjsiozaaoxdcvecg
cjupbgpmufqqjpgkxeoqrbgtwnq,1)),List())
- Those are a lot of commands... Let's look at the shrinked result
Put(suyqjkjeehbqtlaqnxazyatmzbnxfjqvmkporafjpgtuxtluylwzhkinfm,0)
Delete(default_key)
ToList
=>List()
- Wow, what happened here?
Delete(default_key)
also deletedsuyqjkjeehbqtlaqnxazyatmzbnxfjqvmkporafjpgtuxtluylwzhkinfm
hash(default_key) == hash(suyqjkjeehbqtlaqnxazyatmzbnxfjqvmkporafjpgtuxtluylwzhkinfm)
- Our delete fails when there's an hash collision!
def delete(key: K): Unit = {
val pos = Math.floorMod(key.##, capacity)
buffer(pos) = Nil
}
should be
def delete(key: K): Unit = {
val pos = Math.floorMod(key.##, capacity)
val newList = buffer(pos).filter(_._1 != key)
buffer(pos) = newList
}
Testing Race Conditions
- ScalaCheck & co. can also test for race conditions
- Just configure the following variables:
threadCount
- commands to run in parallelmaxParComb
- maximum combinations to test
- Runs the test in two phases
- Sequential phase
- Parallel phase
- Sequential phase
- Runs a sequence of commands
- Post-conditions checked after each command
- Parallel phase
- Runs
threadCount
sequences of commands in parallel - Can't check post conditions! (commands might be interleaved)
- Calculate "all" (up to
maxParComb
) command interleavings - The test passes if one of the possible
(lastCommand, lastState)
post condition succeeds- There was a interleaving that, if executed sequentially, would reach that state
- Calculate "all" (up to
- Runs
Toy Example:
+-----------+ +--------+ +--------+
+->|Put("b", 5)+-->|Del("a")+------------------------>|ToList()|
| +-----------+ +--------+ +--------+
+-----------+ +--------+ |
|Put("a", 5)+-->|Get("a")+----+
+-----------+ +--------+ |
| +--------+ +--------+
+----------------------------->|Del("b")+-->|ToList()|
+--------+ +--------+
- Unique last command / end state pairs:
ToList()
/Map()
- PASSToList()
/Map("b" -> 5)
- FAIL
Test passes
Tips
- Your model should be a simpler version of your system
- Give some love to
genCommand
- e.g. use
Gen.frequency
to test interesting command sequences
- e.g. use
- Parallel tests are very sensitive to commands with weak post-conditions
- e.g. what would happen if all end commands were
Get("unknownKey")
?
- e.g. what would happen if all end commands were
Some other projects
- scalacheck-shapeless: Reduce the boilerplate necessary to generate case classes
- discipline: Helper library for typeclass hierarchy laws
- Used by most libraries with huge typeclass hierarchies (cats, spire, scalaz...)
- Useful test typeclass instances "for free" (e.g.
checkAll("MovieRating", spire.laws.GroupLaws[MovieRating].cMonoid)
)
- zio-test
- Has the concept of
Sample[-R, +A](value: A, shrink: ZStream[R, Nothing, Sample[R, A]])
, which is a value and a stream that generates shrinked values - A
Gen[-R, +A]
is simply a stream ofSample
s, so generation and shrinking also get coupled - Still experimental
- Has the concept of