Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(printer): mk printers more easily configurable #640

Merged
merged 8 commits into from
Apr 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
}

}