From 8558ab134313c30f94936c5a1ca5596bda4d6c67 Mon Sep 17 00:00:00 2001 From: Andrew Valencik Date: Sat, 16 Apr 2022 11:03:17 -0400 Subject: [PATCH] Revive "Strict Equality" for `assertEquals()` (#521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make FailException and ComparisonFailException work more similarly. Previously, FailException had some custom nice-to-have features that ComparisonFailException didn't have. * Introduce "strict equality" mode for `assertEquals()` and friends. Previously, MUnit had a subtyping constraint on `assertEquals(a, b)` so that it would fail to compile if `a` was not a subtype of `b`. This was a suboptimal solution because the compile error messages could become cryptic in some cases. Additionally, this API didn't integrate with other libaries like Cats that has its own `cats.Eq[A,B]` type-class. Now, MUnit uses a new `munit.Compare[A,B]` type-class for comparing values of different types. By default, MUnit provides a "universal" instance that permits comparison between all types and uses the built-in `==` method. Users can optionally enable "strict equality" by adding the compiler option `"-Xmacro-settings.munit.strictEquality"` in Scala 2. In Scala 3, we use the `Eql[A, B]` type-classes instead to determine type equality. * Address review feedback * Drop strict equality, allow comparison between supertypes/subtypes This is a fourth attempt at improving strict equality in MUnit `assertEquals()` assertions. * First attempt (current release version): require second argument to be a supertype of the first argument. This has the flaw that the compile error message is cryptic and that the ordering of the arguments affects compilation. * Second attempt: use `Eql[A, B]` in Scala 3 and allow comparing any types in Scala 2. This has the flaw that it's a regression in some cases for Scala 2 users and that `Eql[A, B]` is not really usable in its current form, see related discussion https://contributors.scala-lang.org/t/should-multiversal-equality-provide-default-eql-instances/4574 * Third attempt: implement "strict equality" for Scala 2 with a macro and `Eql[T, T]` in Scala. This improves the situation for Scala 2, but would mean relying on a feature that we can't easily port to Scala 3. * Fourth attempt (this commit): improve the first attempt (current release) by allowing `Compare[A, B]` as long as `A <:< B` OR `B <:< A`. This is possible thanks to an observation by Gabriele Petronella that it's possible to layer the implicits to avoid diverging implicit search. The benefit of the fourth approach is that it works the same way for Scala 3 and Scala 3. It's very nice that we can avoid macros as well. * Address review feedback * Run scalafmtSbt * Remove unused import * Fix dotty tests in AssertionsSuite The Scala 3 (dotty) tests now use compareSubtypeWithSupertype instead of compareSupertypeWithSubtype. Additionally, the "unrelated" test was not seeing the context code above and so I've moved all the code into compileErrors. * Add mima exclusions for assertEquals and co * Remove unused import in scala-3 MacroCompat * Reintroduce special-case msgs for comparing arrays * Reintroduce better string inequality error msgs * Update Clue deprecation to 1.0 Co-authored-by: Ólafur Páll Geirsson * Fix typo in AssertionsSuite test name Co-authored-by: Ólafur Páll Geirsson Co-authored-by: Olafur Pall Geirsson Co-authored-by: Ólafur Páll Geirsson --- build.sbt | 55 ++++-- docs/assertions.md | 24 ++- .../src/main/scala/munit/Assertions.scala | 79 ++------ munit/shared/src/main/scala/munit/Clue.scala | 4 +- munit/shared/src/main/scala/munit/Clues.scala | 4 + .../shared/src/main/scala/munit/Compare.scala | 122 +++++++++++++ .../scala/munit/ComparisonFailException.scala | 38 +++- .../src/main/scala/munit/FailException.scala | 12 +- .../main/scala/munit/FailExceptionLike.scala | 4 +- .../ComparisonFailExceptionHandler.scala | 18 ++ .../scala/munit/BaseFrameworkSuite.scala | 0 .../scala/munit/BaseSuite.scala | 9 +- .../src/main/scala/munit/CustomCompare.scala | 22 +++ .../munit/StackTraceFrameworkSuite.scala | 7 +- .../test/scala/munit/AssertionsSuite.scala | 172 +++++++++++++++++- .../munit/ComparisonFailExceptionSuite.scala | 9 +- .../test/scala/munit/CustomCompareSuite.scala | 21 +++ .../test/scala/munit/FailExceptionSuite.scala | 3 +- 18 files changed, 483 insertions(+), 120 deletions(-) create mode 100644 munit/shared/src/main/scala/munit/Compare.scala rename tests/shared/src/{test => main}/scala/munit/BaseFrameworkSuite.scala (100%) rename tests/shared/src/{test => main}/scala/munit/BaseSuite.scala (78%) create mode 100644 tests/shared/src/main/scala/munit/CustomCompare.scala create mode 100644 tests/shared/src/test/scala/munit/CustomCompareSuite.scala diff --git a/build.sbt b/build.sbt index 8cf89dfd..11713fa9 100644 --- a/build.sbt +++ b/build.sbt @@ -87,6 +87,24 @@ lazy val mimaEnable: List[Def.Setting[_]] = List( "munit.internal.junitinterface.JUnitComputer.this" ), // Known breaking changes for MUnit v1 + ProblemFilters.exclude[DirectMissingMethodProblem]( + "munit.Assertions.assertNotEquals" + ), + ProblemFilters.exclude[DirectMissingMethodProblem]( + "munit.Assertions.assertEquals" + ), + ProblemFilters.exclude[IncompatibleMethTypeProblem]( + "munit.Assertions.assertNotEquals" + ), + ProblemFilters.exclude[IncompatibleMethTypeProblem]( + "munit.Assertions.assertEquals" + ), + ProblemFilters.exclude[IncompatibleMethTypeProblem]( + "munit.FunSuite.assertNotEquals" + ), + ProblemFilters.exclude[IncompatibleMethTypeProblem]( + "munit.FunSuite.assertEquals" + ), ProblemFilters.exclude[IncompatibleMethTypeProblem]( "munit.FunSuite.munitTestTransform" ), @@ -194,22 +212,8 @@ lazy val junit = project lazy val munit = crossProject(JSPlatform, JVMPlatform, NativePlatform) .settings( sharedSettings, - Compile / unmanagedSourceDirectories ++= { - val root = (ThisBuild / baseDirectory).value / "munit" - val base = root / "shared" / "src" / "main" - val result = mutable.ListBuffer.empty[File] - val partialVersion = CrossVersion.partialVersion(scalaVersion.value) - if (isPreScala213(partialVersion)) { - result += base / "scala-pre-2.13" - } - if (isNotScala211(partialVersion)) { - result += base / "scala-post-2.11" - } - if (isScala2(partialVersion)) { - result += base / "scala-2" - } - result.toList - }, + Compile / unmanagedSourceDirectories ++= + crossBuildingDirectories("munit", "main").value, libraryDependencies ++= List( "org.scala-lang" % "scala-reflect" % { if (isScala3Setting.value) scala213 @@ -308,6 +312,8 @@ lazy val tests = crossProject(JSPlatform, JVMPlatform, NativePlatform) ((ThisBuild / baseDirectory).value / "tests" / "shared" / "src" / "main").getAbsolutePath.toString, scalaVersion ), + Test / unmanagedSourceDirectories ++= + crossBuildingDirectories("tests", "test").value, publish / skip := true ) .nativeConfigure(sharedNativeConfigure) @@ -348,3 +354,20 @@ lazy val docs = project Global / excludeLintKeys ++= Set( mimaPreviousArtifacts ) +def crossBuildingDirectories(name: String, config: String) = + Def.setting[Seq[File]] { + val root = (ThisBuild / baseDirectory).value / name + val base = root / "shared" / "src" / config + val result = mutable.ListBuffer.empty[File] + val partialVersion = CrossVersion.partialVersion(scalaVersion.value) + if (isPreScala213(partialVersion)) { + result += base / "scala-pre-2.13" + } + if (isNotScala211(partialVersion)) { + result += base / "scala-post-2.11" + } + if (isScala2(partialVersion)) { + result += base / "scala-2" + } + result.toList + } diff --git a/docs/assertions.md b/docs/assertions.md index faf893a8..b4067e7d 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -101,23 +101,29 @@ assertEquals( Comparing two values of different types is a compile error. ```scala mdoc:fail -assertEquals(1, "") +assertEquals(Option("message"), "message") ``` -The "expected" value (second argument) must be a subtype of the "obtained" value -(first argument). +It's a compile error even if the comparison is true at runtime. -```scala mdoc -assertEquals(Option(1), Some(1)) +```scala mdoc:fail +assertEquals(List(1), Vector(1)) ``` -It's a compile error if you swap the order of the arguments. - ```scala mdoc:fail -assertEquals(Some(1), Option(1)) +assertEquals('a', 'a'.toInt) +``` + +It's OK to compare two types as long as one argument is a subtype of the other +type. + +```scala mdoc +assertEquals(Option(1), Some(1)) // OK +assertEquals(Some(1), Option(1)) // OK ``` -Use `assertEquals[Any, Any]` if you really want to compare two different types. +Use `assertEquals[Any, Any]` if you think it's OK to compare the two types at +runtime. ```scala mdoc val right1: Either[String , Int] = Right(42) diff --git a/munit/shared/src/main/scala/munit/Assertions.scala b/munit/shared/src/main/scala/munit/Assertions.scala index 40576fcd..24642843 100644 --- a/munit/shared/src/main/scala/munit/Assertions.scala +++ b/munit/shared/src/main/scala/munit/Assertions.scala @@ -18,19 +18,6 @@ trait Assertions extends MacroCompat.CompileErrorMacro { def munitAnsiColors: Boolean = true - private def munitComparisonHandler( - actualObtained: Any, - actualExpected: Any - ): ComparisonFailExceptionHandler = - new ComparisonFailExceptionHandler { - override def handle( - message: String, - unusedObtained: String, - unusedExpected: String, - loc: Location - ): Nothing = failComparison(message, actualObtained, actualExpected)(loc) - } - private def munitFilterAnsi(message: String): String = if (munitAnsiColors) message else AnsiColors.filterAnsi(message) @@ -67,20 +54,25 @@ trait Assertions extends MacroCompat.CompileErrorMacro { Diffs.assertNoDiff( obtained, expected, - munitComparisonHandler(obtained, expected), + ComparisonFailExceptionHandler.fromAssertions(this, Clues.empty), munitPrint(clue), printObtainedAsStripMargin = true ) } } + /** + * Asserts that two elements are not equal according to the `Compare[A, B]` type-class. + * + * By default, uses `==` to compare values. + */ def assertNotEquals[A, B]( obtained: A, expected: B, clue: => Any = "values are the same" - )(implicit loc: Location, ev: A =:= B): Unit = { + )(implicit loc: Location, compare: Compare[A, B]): Unit = { StackTraces.dropInside { - if (obtained == expected) { + if (compare.isEqual(obtained, expected)) { failComparison( s"${munitPrint(clue)} expected same: $expected was not: $obtained", obtained, @@ -91,32 +83,17 @@ trait Assertions extends MacroCompat.CompileErrorMacro { } /** - * Asserts that two elements are equal using `==` equality. - * - * The "expected" value (second argument) must have the same type or be a - * subtype of the "obtained" value (first argument). For example: - * {{{ - * assertEquals(Option(1), Some(1)) // OK - * assertEquals(Some(1), Option(1)) // Error: Option[Int] is not a subtype of Some[Int] - * }}} + * Asserts that two elements are equal according to the `Compare[A, B]` type-class. * - * Use `assertEquals[Any, Any](a, b)` as an escape hatch to compare two - * values of different types. For example: - * {{{ - * val a: Either[List[String], Int] = Right(42) - * val b: Either[String, Int] = Right(42) - * assertEquals[Any, Any](a, b) // OK - * assertEquals(a, b) // Error: Either[String, Int] is not a subtype of Either[List[String], Int] - * }}} + * By default, uses `==` to compare values. */ def assertEquals[A, B]( obtained: A, expected: B, clue: => Any = "values are not the same" - )(implicit loc: Location, ev: B <:< A): Unit = { + )(implicit loc: Location, compare: Compare[A, B]): Unit = { StackTraces.dropInside { - if (obtained != expected) { - + if (!compare.isEqual(obtained, expected)) { (obtained, expected) match { case (a: Array[_], b: Array[_]) if a.sameElements(b) => // Special-case error message when comparing arrays. See @@ -137,34 +114,7 @@ trait Assertions extends MacroCompat.CompileErrorMacro { ) case _ => } - - Diffs.assertNoDiff( - munitPrint(obtained), - munitPrint(expected), - munitComparisonHandler(obtained, expected), - munitPrint(clue), - printObtainedAsStripMargin = false - ) - // try with `.toString` in case `munitPrint()` produces identical formatting for both values. - Diffs.assertNoDiff( - obtained.toString(), - expected.toString(), - munitComparisonHandler(obtained, expected), - munitPrint(clue), - printObtainedAsStripMargin = false - ) - if (obtained.toString() == expected.toString()) - failComparison( - s"values are not equal even if they have the same `toString()`: $obtained", - obtained, - expected - ) - else - failComparison( - s"values are not equal, even if their text representation only differs in leading/trailing whitespace and ANSI escape characters: $obtained", - obtained, - expected - ) + compare.failEqualsComparison(obtained, expected, clue, loc, this) } } } @@ -320,7 +270,8 @@ trait Assertions extends MacroCompat.CompileErrorMacro { munitFilterAnsi(munitLines.formatLine(loc, message, clues)), obtained, expected, - loc + loc, + isStackTracesEnabled = false ) } diff --git a/munit/shared/src/main/scala/munit/Clue.scala b/munit/shared/src/main/scala/munit/Clue.scala index 898c6362..cb905bee 100644 --- a/munit/shared/src/main/scala/munit/Clue.scala +++ b/munit/shared/src/main/scala/munit/Clue.scala @@ -10,5 +10,7 @@ class Clue[+T]( override def toString(): String = s"Clue($source, $value)" } object Clue extends MacroCompat.ClueMacro { - def empty[T](value: T): Clue[T] = new Clue("", value, "") + @deprecated("use fromValue instead", "1.0.0") + def empty[T](value: T): Clue[T] = fromValue(value) + def fromValue[T](value: T): Clue[T] = new Clue("", value, "") } diff --git a/munit/shared/src/main/scala/munit/Clues.scala b/munit/shared/src/main/scala/munit/Clues.scala index 11eb04cc..8659a06a 100644 --- a/munit/shared/src/main/scala/munit/Clues.scala +++ b/munit/shared/src/main/scala/munit/Clues.scala @@ -5,3 +5,7 @@ import munit.internal.console.Printers class Clues(val values: List[Clue[_]]) { override def toString(): String = Printers.print(this) } +object Clues { + def empty: Clues = new Clues(List()) + def fromValue[T](value: T): Clues = new Clues(List(Clue.fromValue(value))) +} diff --git a/munit/shared/src/main/scala/munit/Compare.scala b/munit/shared/src/main/scala/munit/Compare.scala new file mode 100644 index 00000000..15745d7a --- /dev/null +++ b/munit/shared/src/main/scala/munit/Compare.scala @@ -0,0 +1,122 @@ +package munit + +import munit.internal.difflib.Diffs +import munit.internal.difflib.ComparisonFailExceptionHandler +import scala.annotation.implicitNotFound + +/** + * A type-class that is used to compare values in MUnit assertions. + * + * By default, uses == and allows comparison between any two types as long + * they have a supertype/subtype relationship. For example: + * + * - Compare[T, T] OK + * - Compare[Some[Int], Option[Int]] OK, subtype + * - Compare[Option[Int], Some[Int]] OK, supertype + * - Compare[List[Int], collection.Seq[Int]] OK, subtype + * - Compare[List[Int], Vector[Int]] Error, requires upcast to `Seq[Int]` + */ +@implicitNotFound( + // NOTE: Dotty ignores this message if the string is formatted as a multiline string """...""" + "Can't compare these two types:\n First type: ${A}\n Second type: ${B}\nPossible ways to fix this error:\n Alternative 1: provide an implicit instance for Compare[${A}, ${B}]\n Alternative 2: upcast either type into `Any` or a shared supertype" +) +trait Compare[A, B] { + + /** + * Returns true if the values are equal according to the rules of this `Compare[A, B]` instance. + * + * The default implementation of this method uses `==`. + */ + def isEqual(obtained: A, expected: B): Boolean + + /** + * Throws an exception to fail this assertion when two values are not equal. + * + * Override this method to customize the error message. For example, it may + * be helpful to generate an image/HTML file if you're comparing visual + * values. Anything is possible, use your imagination! + * + * @return should ideally throw a org.junit.ComparisonFailException in order + * to support the IntelliJ diff viewer. + */ + def failEqualsComparison( + obtained: A, + expected: B, + title: Any, + loc: Location, + assertions: Assertions + ): Nothing = { + val diffHandler = new ComparisonFailExceptionHandler { + override def handle( + message: String, + _obtained: String, + _expected: String, + loc: Location + ): Nothing = + assertions.failComparison( + message, + obtained, + expected + )(loc) + } + // Attempt 1: custom pretty-printer that produces multiline output, which is + // optimized for line-by-line diffing. + Diffs.assertNoDiff( + assertions.munitPrint(obtained), + assertions.munitPrint(expected), + diffHandler, + title = assertions.munitPrint(title), + printObtainedAsStripMargin = false + )(loc) + + // Attempt 2: try with `.toString` in case `munitPrint()` produces identical + // formatting for both values. + Diffs.assertNoDiff( + obtained.toString(), + expected.toString(), + diffHandler, + title = assertions.munitPrint(title), + printObtainedAsStripMargin = false + )(loc) + + // Attempt 3: string comparison is not working, unconditionally fail the test. + if (obtained.toString() == expected.toString()) + assertions.failComparison( + s"values are not equal even if they have the same `toString()`: $obtained", + obtained, + expected + )(loc) + else + assertions.failComparison( + s"values are not equal, even if their text representation only differs in leading/trailing whitespace and ANSI escape characters: $obtained", + obtained, + expected + )(loc) + } + +} + +object Compare extends ComparePriority1 { + private val anyEquality: Compare[Any, Any] = _ == _ + def defaultCompare[A, B]: Compare[A, B] = + anyEquality.asInstanceOf[Compare[A, B]] +} + +/** Allows comparison between A and B when A is a subtype of B */ +trait ComparePriority1 extends ComparePriority2 { + implicit def compareSubtypeWithSupertype[A, B](implicit + ev: A <:< B + ): Compare[A, B] = Compare.defaultCompare +} + +/** + * Allows comparison between A and B when B is a subtype of A. + * + * This implicit is defined separately from ComparePriority1 in order to avoid + * diverging implicit search when comparing equal types. + */ +trait ComparePriority2 { + implicit def compareSupertypeWithSubtype[A, B](implicit + ev: A <:< B + ): Compare[B, A] = Compare.defaultCompare +} diff --git a/munit/shared/src/main/scala/munit/ComparisonFailException.scala b/munit/shared/src/main/scala/munit/ComparisonFailException.scala index 42191d80..74eb75c7 100644 --- a/munit/shared/src/main/scala/munit/ComparisonFailException.scala +++ b/munit/shared/src/main/scala/munit/ComparisonFailException.scala @@ -11,22 +11,54 @@ import org.junit.ComparisonFailure * * @param message the exception message. * @param obtained the obtained value from this comparison. + * @param obtainedString the pretty-printed representation of the obtained value. + * This string is displayed in the IntelliJ diff viewer. * @param expected the expected value from this comparison. + * @param expectedString the pretty-printed representation of the obtained value. + * This string is displayed in the IntelliJ diff viewer. * @param location the source location where this exception was thrown. */ class ComparisonFailException( val message: String, val obtained: Any, + val obtainedString: String, val expected: Any, - val location: Location -) extends ComparisonFailure(message, s"$expected", s"$obtained") + val expectedString: String, + val location: Location, + val isStackTracesEnabled: Boolean +) extends ComparisonFailure(message, expectedString, obtainedString) with FailExceptionLike[ComparisonFailException] { + def this( + message: String, + obtained: Any, + expected: Any, + location: Location, + isStackTracesEnabled: Boolean + ) = this( + message, + obtained, + s"$obtained", + expected, + s"$expected", + location, + isStackTracesEnabled + ) override def getMessage: String = message def withMessage(newMessage: String): ComparisonFailException = new ComparisonFailException( newMessage, obtained, + obtainedString, expected, - location + expectedString, + location, + isStackTracesEnabled ) + override def fillInStackTrace(): Throwable = { + val result = super.fillInStackTrace() + if (!isStackTracesEnabled) { + result.setStackTrace(result.getStackTrace().slice(0, 1)) + } + result + } } diff --git a/munit/shared/src/main/scala/munit/FailException.scala b/munit/shared/src/main/scala/munit/FailException.scala index 8000f7db..67414893 100644 --- a/munit/shared/src/main/scala/munit/FailException.scala +++ b/munit/shared/src/main/scala/munit/FailException.scala @@ -6,26 +6,25 @@ class FailException( val isStackTracesEnabled: Boolean, val location: Location ) extends AssertionError(message, cause) - with FailExceptionLike[FailException] - with Serializable { + with FailExceptionLike[FailException] { def this(message: String, location: Location) = - this(message, null, true, location) + this(message, null, isStackTracesEnabled = true, location) def this(message: String, cause: Throwable, location: Location) = this( message, cause, - true, + isStackTracesEnabled = true, location ) def withMessage(newMessage: String): FailException = copy(message = newMessage) - def copy( + + private[munit] def copy( message: String = this.message, cause: Throwable = this.cause, isStackTracesEnabled: Boolean = this.isStackTracesEnabled, location: Location = this.location ): FailException = new FailException(message, cause, isStackTracesEnabled, location) - override def fillInStackTrace(): Throwable = { val result = super.fillInStackTrace() if (!isStackTracesEnabled) { @@ -33,4 +32,5 @@ class FailException( } result } + } diff --git a/munit/shared/src/main/scala/munit/FailExceptionLike.scala b/munit/shared/src/main/scala/munit/FailExceptionLike.scala index 6c3289eb..c7bfa19c 100644 --- a/munit/shared/src/main/scala/munit/FailExceptionLike.scala +++ b/munit/shared/src/main/scala/munit/FailExceptionLike.scala @@ -13,7 +13,9 @@ package munit * `org.junit.ComparisonFailure` and this base trait. Internally, MUnit should * match against `FailExceptionLike[_]` instead of `munit.FailException` directly. */ -trait FailExceptionLike[T <: AssertionError] { self: AssertionError => +trait FailExceptionLike[T <: AssertionError] extends Serializable { + self: AssertionError => def withMessage(message: String): T def location: Location + def isStackTracesEnabled: Boolean } diff --git a/munit/shared/src/main/scala/munit/internal/difflib/ComparisonFailExceptionHandler.scala b/munit/shared/src/main/scala/munit/internal/difflib/ComparisonFailExceptionHandler.scala index bb761560..5b51cbf8 100644 --- a/munit/shared/src/main/scala/munit/internal/difflib/ComparisonFailExceptionHandler.scala +++ b/munit/shared/src/main/scala/munit/internal/difflib/ComparisonFailExceptionHandler.scala @@ -1,6 +1,8 @@ package munit.internal.difflib import munit.Location +import munit.Assertions +import munit.Clues trait ComparisonFailExceptionHandler { def handle( @@ -10,3 +12,19 @@ trait ComparisonFailExceptionHandler { location: Location ): Nothing } +object ComparisonFailExceptionHandler { + def fromAssertions( + assertions: Assertions, + clues: => Clues + ): ComparisonFailExceptionHandler = + new ComparisonFailExceptionHandler { + def handle( + message: String, + obtained: String, + expected: String, + loc: Location + ): Nothing = { + assertions.failComparison(message, obtained, expected, clues)(loc) + } + } +} diff --git a/tests/shared/src/test/scala/munit/BaseFrameworkSuite.scala b/tests/shared/src/main/scala/munit/BaseFrameworkSuite.scala similarity index 100% rename from tests/shared/src/test/scala/munit/BaseFrameworkSuite.scala rename to tests/shared/src/main/scala/munit/BaseFrameworkSuite.scala diff --git a/tests/shared/src/test/scala/munit/BaseSuite.scala b/tests/shared/src/main/scala/munit/BaseSuite.scala similarity index 78% rename from tests/shared/src/test/scala/munit/BaseSuite.scala rename to tests/shared/src/main/scala/munit/BaseSuite.scala index 94f22ee5..ea2289a5 100644 --- a/tests/shared/src/test/scala/munit/BaseSuite.scala +++ b/tests/shared/src/main/scala/munit/BaseSuite.scala @@ -4,15 +4,16 @@ import munit.internal.PlatformCompat class BaseSuite extends FunSuite { + def isDotty: Boolean = + !BuildInfo.scalaVersion.startsWith("2.") + def is213: Boolean = + BuildInfo.scalaVersion.startsWith("2.13") || isDotty + override def munitTestTransforms: List[TestTransform] = super.munitTestTransforms ++ List( new TestTransform( "BaseSuite", { test => - def isDotty: Boolean = - !BuildInfo.scalaVersion.startsWith("2.") - def is213: Boolean = - BuildInfo.scalaVersion.startsWith("2.13") || isDotty if (test.tags(NoDotty) && isDotty) { test.tag(Ignore) } else if (test.tags(Only213) && !is213) { diff --git a/tests/shared/src/main/scala/munit/CustomCompare.scala b/tests/shared/src/main/scala/munit/CustomCompare.scala new file mode 100644 index 00000000..0cd62cdd --- /dev/null +++ b/tests/shared/src/main/scala/munit/CustomCompare.scala @@ -0,0 +1,22 @@ +package munit + +trait CustomCompare[A, B] { + def isEqual(a: A, b: B): Boolean +} + +object CustomCompare { + implicit val optionEquality: CustomCompare[Some[Int], Option[Int]] = + new CustomCompare[Some[Int], Option[Int]] { + def isEqual(a: Some[Int], b: Option[Int]): Boolean = { + if (a.contains(42)) sys.error("boom") + else a == b + } + } + implicit def fromCustomEquality[A, B](implicit + my: CustomCompare[A, B] + ): Compare[A, B] = { + new Compare[A, B] { + def isEqual(a: A, b: B) = my.isEqual(a, b) + } + } +} diff --git a/tests/shared/src/main/scala/munit/StackTraceFrameworkSuite.scala b/tests/shared/src/main/scala/munit/StackTraceFrameworkSuite.scala index f6171a05..a2071c5d 100644 --- a/tests/shared/src/main/scala/munit/StackTraceFrameworkSuite.scala +++ b/tests/shared/src/main/scala/munit/StackTraceFrameworkSuite.scala @@ -28,9 +28,6 @@ object FullStackTraceFrameworkSuite extends BaseStackTraceFrameworkSuite( Array("-F"), """|at munit.Assertions:failComparison - | at munit.Assertions:failComparison$ - | at munit.FunSuite:failComparison - | at munit.Assertions$$anon$1:handle |==> failure munit.StackTraceFrameworkSuite.fail - /scala/munit/StackTraceFrameworkSuite.scala:5 |4: test("fail") { |5: assertNoDiff("a", "b") @@ -47,9 +44,7 @@ object FullStackTraceFrameworkSuite object SmallStackTraceFrameworkSuite extends BaseStackTraceFrameworkSuite( Array(), - """|at munit.FunSuite:assertNoDiff - | at munit.StackTraceFrameworkSuite:$anonfun$new$1 - | at scala.runtime.java8.JFunction0$mcV$sp:apply + """|at munit.Assertions:failComparison |==> failure munit.StackTraceFrameworkSuite.fail - /scala/munit/StackTraceFrameworkSuite.scala:5 |4: test("fail") { |5: assertNoDiff("a", "b") diff --git a/tests/shared/src/test/scala/munit/AssertionsSuite.scala b/tests/shared/src/test/scala/munit/AssertionsSuite.scala index 38a855e0..0f4035c8 100644 --- a/tests/shared/src/test/scala/munit/AssertionsSuite.scala +++ b/tests/shared/src/test/scala/munit/AssertionsSuite.scala @@ -50,12 +50,133 @@ class AssertionsSuite extends BaseSuite { test("subtype".tag(NoDotty)) { assertEquals(Option(1), Some(1)) + assertEquals(Some(1), Option(1)) + assertEquals(Option(1), Option(1)) + } + + test("false-negative") { assertNoDiff( - compileErrors("assertEquals(Some(1), Option(1))"), - """|error: Cannot prove that Option[Int] <:< Some[Int]. - |assertEquals(Some(1), Option(1)) - | ^ - |""".stripMargin + compileErrors("assertEquals(List(1), Vector(1))"), + if (isDotty) + """|error: + |Can't compare these two types: + | First type: List[Int] + | Second type: Vector[Int] + |Possible ways to fix this error: + | Alternative 1: provide an implicit instance for Compare[List[Int], Vector[Int]] + | Alternative 2: upcast either type into `Any` or a shared supertype. + |I found: + | + | munit.Compare.compareSubtypeWithSupertype[List[Int], Vector[Int]]( + | /* missing */summon[List[Int] <:< Vector[Int]] + | ) + | + |But no implicit values were found that match type List[Int] <:< Vector[Int]. + | + |The following import might make progress towards fixing the problem: + | + | import munit.CustomCompare.fromCustomEquality + | + |assertEquals(List(1), Vector(1)) + | ^ + |""".stripMargin + else + """|error: + |Can't compare these two types: + | First type: List[Int] + | Second type: scala.collection.immutable.Vector[Int] + |Possible ways to fix this error: + | Alternative 1: provide an implicit instance for Compare[List[Int], scala.collection.immutable.Vector[Int]] + | Alternative 2: upcast either type into `Any` or a shared supertype + |assertEquals(List(1), Vector(1)) + | ^ + |""".stripMargin + ) + } + + test("unrelated") { + assertNoDiff( + compileErrors(""" +class A { + override def equals(x: Any): Boolean = true +} +class B { + override def equals(x: Any): Boolean = true +} +assertEquals(new A, new B) + """), + if (isDotty) + """|error: + |Can't compare these two types: + | First type: A + | Second type: B + |Possible ways to fix this error: + | Alternative 1: provide an implicit instance for Compare[A, B] + | Alternative 2: upcast either type into `Any` or a shared supertype. + |I found: + | + | munit.Compare.compareSubtypeWithSupertype[A, B](/* missing */summon[A <:< B]) + | + |But no implicit values were found that match type A <:< B. + | + |The following import might make progress towards fixing the problem: + | + | import munit.CustomCompare.fromCustomEquality + | + |assertEquals(new A, new B) + | ^ + |""".stripMargin + else + """|error: + |Can't compare these two types: + | First type: A + | Second type: B + |Possible ways to fix this error: + | Alternative 1: provide an implicit instance for Compare[A, B] + | Alternative 2: upcast either type into `Any` or a shared supertype + |assertEquals(new A, new B) + | ^ + |""".stripMargin + ) + } + + test("char-int-nok") { + assertNoDiff( + compileErrors("assertEquals('a', 'a'.toInt)"), + if (isDotty) + """|error: + |Can't compare these two types: + | First type: Char + | Second type: Int + |Possible ways to fix this error: + | Alternative 1: provide an implicit instance for Compare[Char, Int] + | Alternative 2: upcast either type into `Any` or a shared supertype. + |I found: + | + | munit.Compare.compareSubtypeWithSupertype[Char, Int]( + | /* missing */summon[Char <:< Int] + | ) + | + |But no implicit values were found that match type Char <:< Int. + | + |The following import might make progress towards fixing the problem: + | + | import munit.CustomCompare.fromCustomEquality + | + |assertEquals('a', 'a'.toInt) + | ^ + |""".stripMargin + else + """|error: + |Can't compare these two types: + | First type: Char + | Second type: Int + |Possible ways to fix this error: + | Alternative 1: provide an implicit instance for Compare[Char, Int] + | Alternative 2: upcast either type into `Any` or a shared supertype + |assertEquals('a', 'a'.toInt) + | ^ + |""".stripMargin ) } test("array-sameElements") { @@ -68,4 +189,45 @@ class AssertionsSuite extends BaseSuite { ) ) } + + test("some-none-nokj") { + assertNoDiff( + compileErrors("assertEquals(None, Some(1))"), + if (isDotty) + """|error: + |Can't compare these two types: + | First type: None.type + | Second type: Some[Int] + |Possible ways to fix this error: + | Alternative 1: provide an implicit instance for Compare[None.type, Some[Int]] + | Alternative 2: upcast either type into `Any` or a shared supertype. + |I found: + | + | munit.Compare.compareSubtypeWithSupertype[None.type, Some[Int]]( + | /* missing */summon[None.type <:< Some[Int]] + | ) + | + |But no implicit values were found that match type None.type <:< Some[Int]. + | + |The following import might make progress towards fixing the problem: + | + | import munit.CustomCompare.fromCustomEquality + | + |assertEquals(None, Some(1)) + | ^ + |""".stripMargin + else + """|error: + |Can't compare these two types: + | First type: None.type + | Second type: Some[Int] + |Possible ways to fix this error: + | Alternative 1: provide an implicit instance for Compare[None.type, Some[Int]] + | Alternative 2: upcast either type into `Any` or a shared supertype + |assertEquals(None, Some(1)) + | ^ + |""".stripMargin + ) + } + } diff --git a/tests/shared/src/test/scala/munit/ComparisonFailExceptionSuite.scala b/tests/shared/src/test/scala/munit/ComparisonFailExceptionSuite.scala index 36c1e697..b929bf41 100644 --- a/tests/shared/src/test/scala/munit/ComparisonFailExceptionSuite.scala +++ b/tests/shared/src/test/scala/munit/ComparisonFailExceptionSuite.scala @@ -15,6 +15,7 @@ class ComparisonFailExceptionSuite extends BaseSuite { assertEquals[Any, Any](List("1", "2", "3"), List(1, 2)) } assert(clue(e).isInstanceOf[ComparisonFailure]) + assert(clue(e).isInstanceOf[Serializable]) // NOTE: assert that we use the `toString` of values in the // `org.junit.ComparisionFailure` exception. The stdout message in the // console still uses `munitPrint()`, which would have displayed `List("1", @@ -60,10 +61,10 @@ class ComparisonFailExceptionSuite extends BaseSuite { } assertNoDiff( e.getMessage(), - """|ComparisonFailExceptionSuite.scala:59 - |58: val e = intercept[ComparisonFailException] { - |59: assertNoDiff("", "Lorem ipsum") - |60: } + """|ComparisonFailExceptionSuite.scala:60 + |59: val e = intercept[ComparisonFailException] { + |60: assertNoDiff("", "Lorem ipsum") + |61: } |Obtained empty output! |=> Expected: |Lorem ipsum diff --git a/tests/shared/src/test/scala/munit/CustomCompareSuite.scala b/tests/shared/src/test/scala/munit/CustomCompareSuite.scala new file mode 100644 index 00000000..3e7bf43b --- /dev/null +++ b/tests/shared/src/test/scala/munit/CustomCompareSuite.scala @@ -0,0 +1,21 @@ +package munit + +class CustomCompareSuite extends BaseSuite { + import CustomCompare.fromCustomEquality + test("ok") { + assertEquals(Some(1), Option(1)) + } + + test("boom") { + interceptMessage[RuntimeException]("boom")( + assertEquals(Some(42), Option(42)) + ) + } + + test("fallback-to-default") { + // NOTE: Users who rely on custom equality won't get a compile error for + // comparisons between supertype/subtype relationships. They only get a + // compile error when comparing unrelated types (same as default behavior). + assertEquals(List(1), collection.Seq(1)) + } +} diff --git a/tests/shared/src/test/scala/munit/FailExceptionSuite.scala b/tests/shared/src/test/scala/munit/FailExceptionSuite.scala index a6c2c2af..0fcfcfae 100644 --- a/tests/shared/src/test/scala/munit/FailExceptionSuite.scala +++ b/tests/shared/src/test/scala/munit/FailExceptionSuite.scala @@ -2,8 +2,9 @@ package munit class FailExceptionSuite extends BaseSuite { test("assertion-error") { - intercept[AssertionError] { + val e = intercept[AssertionError] { fail("hello world!") } + assert(clue(e).isInstanceOf[Serializable]) } }