diff --git a/docs/tests.md b/docs/tests.md index 55e05784..eaa2651a 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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 @@ -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. +. ## Declare tests inside a helper function diff --git a/munit/shared/src/main/scala/munit/Assertions.scala b/munit/shared/src/main/scala/munit/Assertions.scala index 24642843..ec3e058b 100644 --- a/munit/shared/src/main/scala/munit/Assertions.scala +++ b/munit/shared/src/main/scala/munit/Assertions.scala @@ -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) } } diff --git a/munit/shared/src/main/scala/munit/Printer.scala b/munit/shared/src/main/scala/munit/Printer.scala index 743abd3f..ee94cf53 100644 --- a/munit/shared/src/main/scala/munit/Printer.scala +++ b/munit/shared/src/main/scala/munit/Printer.scala @@ -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 */ diff --git a/tests/shared/src/test/scala/munit/CustomPrinterSuite.scala b/tests/shared/src/test/scala/munit/CustomPrinterSuite.scala new file mode 100644 index 00000000..610a9d10 --- /dev/null +++ b/tests/shared/src/test/scala/munit/CustomPrinterSuite.scala @@ -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) + } + } + +}