Skip to content

Commit

Permalink
feat(printer): mk printers more easily configurable (#640)
Browse files Browse the repository at this point in the history
* feat(printer): mk printers more easily configurable

- add `orElse` combinator for `Printer` for composability
- add utility constructor for easier instantiation : user will only pass whatever is actually meaningful to them in most cases, ie. the custom  logic to stringify the test values
- update `Assertion` to allow users to pass their custom `Printer` without overriding `munitPrint`
  • Loading branch information
wahtique authored Apr 5, 2023
1 parent 8ccca4e commit 22a27a7
Show file tree
Hide file tree
Showing 4 changed files with 305 additions and 3 deletions.
78 changes: 77 additions & 1 deletion docs/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,82 @@ class CustomTimeoutSuite extends munit.FunSuite {
> non-async tests. However, starting with MUnit v1.0 (latest milestone release:
> @VERSION@), the timeout applies to all tests including non-async tests.
## Customize value printers

MUnit uses its own `Printer`s to convert any value into a diff-ready string representation.
The resulting string is the actual value being compared, and is also used to generate the clues in case of a failure.

The default printing behaviour can be overriden for a given type by defining a custom `Printer` and overriding `printer`.

Override `printer` to customize the comparison of two values :

```scala mdoc
import java.time.Instant
import munit.FunSuite
import munit.Printer

class CompareDatesOnlyTest extends FunSuite {
override val printer = Printer.apply {
// take only the date part of the Instant
case instant: Instant => instant.toString.takeWhile(_ != 'T')
}

test("dates only") {
val expected = Instant.parse("2022-02-15T18:35:24.00Z")
val actual = Instant.parse("2022-02-15T18:36:01.00Z")
assertEquals(actual, expected) // true
}
}
```

or to customize the printed clue in case of a failure :

```scala mdoc
import munit.FunSuite
import munit.Printer

class CustomListOfCharPrinterTest extends FunSuite {
override val printer = Printer.apply {
case l: List[Char] => l.mkString
}

test("lists of chars") {
val expected = List('h', 'e', 'l', 'l', 'o')
val actual = List('h', 'e', 'l', 'l', '0')
assertEquals(actual, expected)
}
}
```

will yield

```
=> Obtained
hell0
=> Diff (- obtained, + expected)
-hell0
+hello
```

instead of the default

```
...
=> Obtained
List(
'h',
'e',
'l',
'l',
'0'
)
=> Diff (- obtained, + expected)
'l',
- '0'
+ 'o'
...
```

## Run tests in parallel

MUnit does not support running individual test cases in parallel. However, sbt
Expand All @@ -140,7 +216,7 @@ Test / testOptions += Tests.Argument(TestFrameworks.MUnit, "-b")
```

To learn more about sbt test execution, see
https://www.scala-sbt.org/1.x/docs/Testing.html.
<https://www.scala-sbt.org/1.x/docs/Testing.html>.

## Declare tests inside a helper function

Expand Down
4 changes: 3 additions & 1 deletion munit/shared/src/main/scala/munit/Assertions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,12 @@ trait Assertions extends MacroCompat.CompileErrorMacro {
}
def clues(clue: Clue[_]*): Clues = new Clues(clue.toList)

def printer: Printer = EmptyPrinter

def munitPrint(clue: => Any): String = {
clue match {
case message: String => message
case value => Printers.print(value)
case value => Printers.print(value, printer)
}
}

Expand Down
87 changes: 86 additions & 1 deletion munit/shared/src/main/scala/munit/Printer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,94 @@ trait Printer {
* Returns true if this value has been printed, false if FunSuite should fallback to the default pretty-printer.
*/
def print(value: Any, out: StringBuilder, indent: Int): Boolean
def height: Int = 100
def height: Int = Printer.defaultHeight
def isMultiline(string: String): Boolean =
string.contains('\n')

/**
* Combine two printers into a single printer.
*
* Order is important : this printer will be tried first, then the other printer.
* The new Printer's height will be the max of the two printers' heights.
*
* Example use case : define some default printers for some types for all tests,
* and override it for some tests only.
*
* {{{
*
* case class Person(name: String, age: Int, mail: String)
*
* trait MySuites extends FunSuite {
* override val printer = Printer.apply {
* case Person(name, age, mail) => s"$name:$age:$mail"
* case m: SomeOtherCaseClass => m.someCustomToString
* }
* }
*
* trait CompareMailsOnly extends MySuites {
* val mailOnlyPrinter = Printer.apply {
* case Person(_, _, mail) => mail
* }
* override val printer = mailOnlyPrinterPrinter orElse super.printer
* }
*
* }}}
*/
def orElse(other: Printer): Printer = {
val h = this.height
val p: (Any, StringBuilder, Int) => Boolean = this.print
new Printer {
def print(value: Any, out: StringBuilder, indent: Int): Boolean =
p.apply(value, out, indent) || other.print(
value,
out,
indent
)
override def height: Int = h.max(other.height)
}
}

}

object Printer {

val defaultHeight = 100

def apply(
height: Int
)(partialPrint: PartialFunction[Any, String]): Printer = {
val h = height
new Printer {
def print(value: Any, out: StringBuilder, indent: Int): Boolean = {
partialPrint.lift.apply(value) match {
case Some(string) =>
out.append(string)
true
case None => false
}
}

override def height: Int = h
}
}

/**
* Utiliy constructor defining a printer for some types.
*
* Example use case is overriding the string repr for types which default pretty-printers
* do not output helpful diffs.
*
* {{{
* type ByteArray = Array[Byte]
* val listPrinter = Printer.apply {
* case ll: ByteArray => ll.map(String.format("%02x", b)).mkString(" ")
* }
* val bytes = Array[Byte](1, 5, 8, 24)
* Printers.print(bytes, listPrinter) // "01 05 08 18"
* }}}
*/
def apply(partialPrint: PartialFunction[Any, String]): Printer =
apply(defaultHeight)(partialPrint)
}

/** Default printer that does not customize the pretty-printer */
Expand Down
139 changes: 139 additions & 0 deletions tests/shared/src/test/scala/munit/CustomPrinterSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package munit

import org.scalacheck.Prop.forAll
import munit.internal.console.Printers
import org.scalacheck.Arbitrary.arbitrary
import org.scalacheck.Prop

class CustomPrinterSuite extends FunSuite with ScalaCheckSuite {

private case class Foo(i: Int)

private case class Bar(l: List[Int])

private case class FooBar(foo: Foo, bar: Bar)

private val genFoo = arbitrary[Int].map(Foo(_))

// limit size to 10 to have a reasonable number of values
private val genBar = arbitrary[List[Int]].map(l => Bar(l.take(10)))

private val genFooBar = for {
foo <- genFoo
bar <- genBar
} yield FooBar(foo, bar)

private val longPrinter: Printer = Printer.apply { case l: Long =>
s"MoreThanInt($l)"
}

private val fooPrinter: Printer = Printer.apply { case Foo(i) =>
s"Foo(INT($i))"
}

private val listPrinter: Printer = Printer.apply { case l: List[_] =>
l.mkString("[", ",", "]")
}

private val intPrinter: Printer = Printer.apply { case i: Int =>
s"NotNaN($i)"
}

private val isScala213: Boolean = BuildInfo.scalaVersion.startsWith("2.13")

private def checkProp(
options: TestOptions,
isEnabled: Boolean = true
)(p: => Prop): Unit = {
test(options) {
assume(isEnabled, "disabled test")
p
}
}

checkProp("long") {
forAll(arbitrary[Long]) { (l: Long) =>
val obtained = Printers.print(l, longPrinter)
val expected = s"MoreThanInt($l)"
assertEquals(obtained, expected)
}
}

checkProp("list") {
forAll(arbitrary[List[Int]]) { l =>
val obtained = Printers.print(l, listPrinter)
val expected = l.mkString("[", ",", "]")
assertEquals(obtained, expected)
}
}

checkProp("product") {
forAll(genFoo) { foo =>
val obtained = Printers.print(foo, fooPrinter)
val expected = s"Foo(INT(${foo.i}))"
assertEquals(obtained, expected)
}
}

checkProp("int in product", isEnabled = isScala213) {
forAll(genFoo) { foo =>
val obtained = Printers.print(foo, intPrinter)
val expected = s"Foo(\n i = NotNaN(${foo.i})\n)"
assertEquals(obtained, expected)
}
}

checkProp("list in product", isEnabled = isScala213) {
forAll(genBar) { bar =>
val obtained = Printers.print(bar, listPrinter)
val expected = s"Bar(\n l = ${bar.l.mkString("[", ",", "]")}\n)"
assertEquals(obtained, expected)
}
}

checkProp("list and int in product", isEnabled = isScala213) {
forAll(genFooBar) { foobar =>
val obtained = Printers
.print(foobar, listPrinter.orElse(intPrinter))
.filterNot(_.isWhitespace)
val expected =
s"""|FooBar(
| foo = Foo(
| i = NotNaN(${foobar.foo.i})
| ),
| bar = Bar(
| l = ${foobar.bar.l.mkString("[", ",", "]")}
| )
|)
|""".stripMargin.filterNot(_.isWhitespace)
assertEquals(obtained, expected)
}
}

checkProp("all ints in product", isEnabled = isScala213) {
forAll(genFooBar) { foobar =>
val obtained = Printers
.print(foobar, intPrinter)
.filterNot(_.isWhitespace)

val expectedbBarList = foobar.bar.l match {
case Nil => "Nil"
case l =>
l.map(i => s"NotNaN($i)").mkString("List(", ",", ")")
}

val expected =
s"""|FooBar(
| foo = Foo(
| i = NotNaN(${foobar.foo.i})
| ),
| bar = Bar(
| l = $expectedbBarList
| )
|)
|""".stripMargin.filterNot(_.isWhitespace)
assertEquals(obtained, expected)
}
}

}

0 comments on commit 22a27a7

Please sign in to comment.