Skip to content

Commit

Permalink
Add classification/labelling of test cases
Browse files Browse the repository at this point in the history
Issue 71: #71

Taken from a combination of the following PRs:

- hedgehogqa/haskell-hedgehog#253
- hedgehogqa/haskell-hedgehog#262
  • Loading branch information
charleso committed Aug 22, 2019
1 parent 3941075 commit 9637830
Show file tree
Hide file tree
Showing 6 changed files with 236 additions and 16 deletions.
117 changes: 117 additions & 0 deletions core/src/main/scala/hedgehog/core/Coverage.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package hedgehog.core

/** Whether a test is covered by a classifier, and therefore belongs to a `Class` */
sealed trait Cover {

def ++(o: Cover): Cover =
this match {
case Cover.NoCover =>
o match {
case Cover.NoCover =>
Cover.NoCover
case Cover.Cover =>
Cover.Cover
}
case Cover.Cover =>
Cover.Cover
}
}

object Cover {

case object NoCover extends Cover
case object Cover extends Cover

implicit def Boolean2Cover(b: Boolean): Cover =
if (b) Cover else NoCover
}

/** The total number of tests which are covered by a classifier. */
case class CoverCount(toInt: Int) {

def +(o: CoverCount): CoverCount =
CoverCount(toInt + o.toInt)

def percentage(tests: SuccessCount): CoverPercentage =
CoverPercentage(((toInt.toDouble / tests.value.toDouble) * 100 * 10).round / 10)
}

object CoverCount {

def fromCover(c: Cover): CoverCount =
c match {
case Cover.NoCover =>
CoverCount(0)
case Cover.Cover =>
CoverCount(1)
}
}

/** The relative number of tests which are covered by a classifier. */
case class CoverPercentage(toDouble: Double)

object CoverPercentage {

implicit def Double2CoveragePercentage(d: Double): CoverPercentage =
CoverPercentage(d)
}

/** The name of a classifier. */
case class LabelName(render: String)

object LabelName {

implicit def String2LabelName(s: String): LabelName =
LabelName(s)
}

/**
* The extent to which a test is covered by a classifier.
*
* _When a classifier's coverage does not exceed the required minimum, the test will be failed._
*/
case class Label[A](
name : LabelName
, minimum : CoverPercentage
, annotation : A
)

object Label {

def covered(label: Label[CoverCount], tests: SuccessCount): Boolean =
label.annotation.percentage(tests).toDouble >= label.minimum.toDouble
}

case class Coverage[A](labels: Map[LabelName, Label[A]])

object Coverage {

def empty[A]: Coverage[A] =
Coverage(Map.empty[LabelName, Label[A]])

def fromLogs(logs: List[Log]): Coverage[CoverCount] =
fromLabels(logs.flatMap {
case Log.LabelX(l) =>
List(l)
case _ =>
Nil
})

def fromLabels(labels: List[Label[Cover]]): Coverage[CoverCount] = {
val cv = labels.map(l => Coverage(Map(l.name -> l)))
.foldLeft(Coverage.empty[Cover])(union(_, _)(_ ++ _))
cv.copy(labels = cv.labels.mapValues(l => l.copy(annotation = CoverCount.fromCover(l.annotation))))
}

def union[A](a: Coverage[A], b: Coverage[A])(append: (A, A) => A): Coverage[A] =
Coverage(b.labels.toList.foldLeft(a.labels) { case (m, (k, v)) =>
m + (k -> m.get(k).map(x => x.copy(annotation = append(x.annotation, v.annotation))).getOrElse(v))
})

def uncovered(coverage: Coverage[CoverCount], tests: SuccessCount): List[Label[CoverCount]] = {
val (_, un) = coverage.labels.values.partition(Label.covered(_, tests))
// FIXME should probably return the covered as well for debugging
un.toList
}
}

52 changes: 41 additions & 11 deletions core/src/main/scala/hedgehog/core/PropertyT.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ case class Error(value: Exception) extends Log

object Log {

case class LabelX(value: Label[Cover]) extends Log

implicit def String2Log(s: String): Log =
Info(s)
}
Expand Down Expand Up @@ -66,6 +68,27 @@ case class PropertyT[A](
}
)
))

/** Records the proportion of tests which satisfy a given condition. */
def cover(minimum: CoverPercentage, name: LabelName, covered: A => Cover): PropertyT[A] =
flatMap(a =>
propertyT.writeLog(Log.LabelX(Label(name, minimum, covered(a))))
.map(_ => a)
)

/**
* Add a label for each test run.
* It produces a table showing the percentage of test runs that produced each label.
*/
def label(name: LabelName): PropertyT[A] =
cover(0, name, _ => true)

/** Like 'label', but uses the `toString` value as the label. */
def collect: PropertyT[A] =
flatMap(a =>
propertyT.writeLog(Log.LabelX(Label(a.toString, 0, Cover.Cover)))
.map(_ => a)
)
}

object PropertyT {
Expand Down Expand Up @@ -128,30 +151,37 @@ trait PropertyTReporting {
// Start the size at whatever remainder we have to ensure we run with "max" at least once
val sizeInit = Size(Size.max % Math.min(config.testLimit.value, Size.max)).incBy(sizeInc)
@annotation.tailrec
def loop(successes: SuccessCount, discards: DiscardCount, size: Size, seed: Seed): Report =
def loop(successes: SuccessCount, discards: DiscardCount, size: Size, seed: Seed, coverage: Coverage[CoverCount]): Report =
if (size.value > Size.max)
loop(successes, discards, sizeInit, seed)
loop(successes, discards, sizeInit, seed, coverage)
else if (successes.value >= config.testLimit.value)
Report(successes, discards, OK)
// we've hit the test limit
Coverage.uncovered(coverage, successes) match {
case Nil =>
Report(successes, discards, coverage, OK)
case l =>
Report(successes, discards, coverage, Status.failed(ShrinkCount(0), l.map(x => Info(x.toString))))
}
else if (discards.value >= config.discardLimit.value)
Report(successes, discards, GaveUp)
Report(successes, discards, coverage, GaveUp)
else {
val x = p.run.run(size, seed)
x.value._2 match {
case None =>
loop(successes, discards.inc, size.incBy(sizeInc), x.value._1)
loop(successes, discards.inc, size.incBy(sizeInc), x.value._1, coverage)

case Some((_, None)) =>
Report(successes, discards, takeSmallest(ShrinkCount(0), config.shrinkLimit, x.map(_._2)))
Report(successes, discards, coverage, takeSmallest(ShrinkCount(0), config.shrinkLimit, x.map(_._2)))

case Some((_, Some(r))) =>
case Some((logs, Some(r))) =>
if (!r.success){
Report(successes, discards, takeSmallest(ShrinkCount(0), config.shrinkLimit, x.map(_._2)))
Report(successes, discards, coverage, takeSmallest(ShrinkCount(0), config.shrinkLimit, x.map(_._2)))
} else
loop(successes.inc, discards, size.incBy(sizeInc), x.value._1)
loop(successes.inc, discards, size.incBy(sizeInc), x.value._1,
Coverage.union(Coverage.fromLogs(logs), coverage)(_ + _))
}
}
loop(SuccessCount(0), DiscardCount(0), size0.getOrElse(sizeInit), seed0)
loop(SuccessCount(0), DiscardCount(0), size0.getOrElse(sizeInit), seed0, Coverage.empty)
}

def recheck(config: PropertyConfig, size: Size, seed: Seed)(p: PropertyT[Result]): Report =
Expand Down Expand Up @@ -214,4 +244,4 @@ object Status {
OK
}

case class Report(tests: SuccessCount, discards: DiscardCount, status: Status)
case class Report(tests: SuccessCount, discards: DiscardCount, coverage: Coverage[CoverCount], status: Status)
25 changes: 25 additions & 0 deletions example/src/main/scala/hedgehog/examples/CoverageTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package hedgehog.examples

import hedgehog._
import hedgehog.runner._

object CoverageTest extends Properties {

override def tests: List[Test] =
List(
property("collect int", testCollectInt)
, property("cover booleans", testBoolean)
)

def testCollectInt: Property =
for {
_ <- Gen.int(Range.linear(1, 10)).forAll.collect
} yield Result.success

def testBoolean: Property =
for {
_ <- Gen.boolean.forAll
.cover(40, "true", x => x)
.cover(40, "false", x => !x)
} yield Result.success
}
48 changes: 48 additions & 0 deletions test/src/test/scala/hedgehog/CoverageTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package hedgehog

import hedgehog.core._
import hedgehog.runner._

object CoverageTest extends Properties {

override def tests: List[Test] =
List(
example("test cover passes", testCoverPass)
, example("test cover fails", testCoverFail)
)

def testCoverPass: Result = {
val g =
for {
_ <- Gen.boolean.forAll
.cover(40, "true", x => x)
.cover(40, "false", x => !x)
} yield Result.success

val r = Property.checkRandom(PropertyConfig.default, g)
Result.all(List(
r.coverage.labels.keys.toList.sortBy(_.render) ==== List(LabelName("false"), LabelName("true"))
, r.status ==== Status.ok
))
}

def testCoverFail: Result = {
val g =
for {
_ <- Gen.boolean.forAll
.cover(60, "true", x => x)
.cover(40, "false", x => !x)
} yield Result.success

val r = Property.checkRandom(PropertyConfig.default, g)
Result.all(List(
r.coverage.labels.keys.toList.sortBy(_.render) ==== List(LabelName("false"), LabelName("true"))
, r.status match {
case _: Failed =>
Result.success
case _ =>
Result.failure
}
))
}
}
4 changes: 2 additions & 2 deletions test/src/test/scala/hedgehog/GenTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ object GenTest extends Properties {

def testFromSomeSome: Result = {
val r = Property.checkRandom(PropertyConfig.default, Gen.fromSome(Gen.constant(Result.success).option).forAll)
r ==== Report(SuccessCount(100), DiscardCount(0), OK)
r ==== Report(SuccessCount(100), DiscardCount(0), Coverage.empty, OK)
}

def testFromSomeNone: Result = {
val r = Property.checkRandom(PropertyConfig.default, Gen.fromSome(Gen.constant(Option.empty[Result])).forAll)
r ==== Report(SuccessCount(0), DiscardCount(100), GaveUp)
r ==== Report(SuccessCount(0), DiscardCount(100), Coverage.empty, GaveUp)
}

def testApplicative: Result = {
Expand Down
6 changes: 3 additions & 3 deletions test/src/test/scala/hedgehog/PropertyTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ object PropertyTest extends Properties {
y <- int(Range.linear(0, 50)).log("y")
_ <- if (y % 2 == 0) Property.discard else Property.point(())
} yield Result.assert(y < 87 && x <= 'r'), seed)
r ==== Report(SuccessCount(2), DiscardCount(4), Failed(ShrinkCount(2), List(
r ==== Report(SuccessCount(2), DiscardCount(4), Coverage.empty, Failed(ShrinkCount(2), List(
ForAll("x", "s")
, ForAll("y", "1"))
))
Expand All @@ -39,7 +39,7 @@ object PropertyTest extends Properties {
(if (y % 2 == 0) Property.discard else Property.point(())).map(_ =>
Result.assert(y < 87 && x <= 'r')
)}, seed)
r ==== Report(SuccessCount(2), DiscardCount(4), Failed(ShrinkCount(2), List(
r ==== Report(SuccessCount(2), DiscardCount(4), Coverage.empty, Failed(ShrinkCount(2), List(
ForAll("x", "s")
, ForAll("y", "1"))
))
Expand Down Expand Up @@ -107,7 +107,7 @@ object PropertyTest extends Properties {
y <- order(expensive).log("expensive")
} yield Result.assert(merge(x, y).total.value == x.total.value + y.total.value)
, seed)
r ==== Report(SuccessCount(1), DiscardCount(0), Failed(ShrinkCount(4), List(
r ==== Report(SuccessCount(1), DiscardCount(0), Coverage.empty, Failed(ShrinkCount(4), List(
ForAll("cheap", "Order(List())")
, ForAll("expensive", "Order(List(Item(oculus,USD(1000))))"
))))
Expand Down

0 comments on commit 9637830

Please sign in to comment.