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

Replace expecty with clue #64

Merged
merged 2 commits into from
Oct 9, 2024
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ Weaver also includes support for

The various `test` functions have in common that they expect the developer to return a value of type `Expectations`, which is just a basic case class wrapping a `cats.data.Validated` value.

The most convenient way to build `Expectations` is to use the `expect` function. Based on [Eugene Yokota's](http://eed3si9n.com/about) excellent [expecty](https://github.com/eed3si9n/expecty), it captures the boolean expression at compile time and provides useful feedback on what goes wrong when it does :
The most convenient way to build `Expectations` is to use the `expect` and `clue` functions. `clue` captures the boolean expression at compile time and provides useful feedback on what goes wrong:

```scala
expect(clue(List(1, 2, 3).size) == 4)
```

![Oops](docs/assets/oops.png)

Expand Down
18 changes: 8 additions & 10 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ val Version = new {
val catsEffect = "3.5.4"
val catsLaws = "2.9.0"
val discipline = "1.5.1"
val expecty = "0.16.0"
val fs2 = "3.10.2"
val junit = "4.13.2"
val portableReflect = "1.1.2"
Expand All @@ -67,13 +66,16 @@ lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform)
.settings(
name := "weaver-core",
libraryDependencies ++= Seq(
"co.fs2" %%% "fs2-core" % Version.fs2,
"org.typelevel" %%% "cats-effect" % Version.catsEffect,
"com.eed3si9n.expecty" %%% "expecty" % Version.expecty,
"co.fs2" %%% "fs2-core" % Version.fs2,
"org.typelevel" %%% "cats-effect" % Version.catsEffect,
// https://github.com/portable-scala/portable-scala-reflect/issues/23
"org.portable-scala" %%% "portable-scala-reflect" % Version.portableReflect cross CrossVersion.for3Use2_13,
"org.typelevel" %% "scalac-compat-annotation" % Version.scalacCompatAnnotation,
"org.scalameta" %%% "munit-diff" % Version.munitDiff
"org.scalameta" %%% "munit-diff" % Version.munitDiff,
if (scalaVersion.value.startsWith("3."))
"org.scala-lang" % "scala-reflect" % scala213
else
"org.scala-lang" % "scala-reflect" % scalaVersion.value
),
// Shades the scala-diff dependency.
shadedDependencies += "org.scalameta" %%% "munit-diff" % "<ignored>",
Expand All @@ -86,11 +88,7 @@ lazy val coreJVM = core.jvm
.settings(
libraryDependencies ++= Seq(
"org.scala-js" %%% "scalajs-stubs" % Version.scalajsStubs % "provided" cross CrossVersion.for3Use2_13,
"junit" % "junit" % Version.junit % Optional,
if (scalaVersion.value.startsWith("3."))
"org.scala-lang" % "scala-reflect" % scala213
else
"org.scala-lang" % "scala-reflect" % scalaVersion.value
"junit" % "junit" % Version.junit % Optional
)
)

Expand Down
Binary file modified docs/assets/oops.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 12 additions & 6 deletions docs/features/expectations.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Expectations (assertions)

Expectations are pure, composable values. This forces developers to separate the test's checks from the scenario, which is generally cleaner/clearer.

The easiest way to construct expectactions is to call the `expect` macro, which is built using the [expecty](https://github.com/eed3si9n/expecty/) library.
The easiest way to construct expectactions is to call the `expect` macro. The `clue` function can be used to investigate failures.

## TL;DR

Expand All @@ -13,6 +13,12 @@ The easiest way to construct expectactions is to call the `expect` macro, which
expect(myVar == 25 && list.size == 4)
```

- Investigate failures using `clue`:

```scala mdoc:compile-only
expect(clue(myVar) == 25 && clue(list).size == 4)
```

- Compose expectations using `and`/`or`

```scala mdoc:compile-only
Expand Down Expand Up @@ -132,7 +138,7 @@ object ExpectationsSuite extends SimpleIOSuite {
pureTest("Simple expectations (failure)") {
val z = 15

expect(A.B.C.test(z) % 7 == 0)
expect(clue(A.B.C.test(z)) % 7 == 0)
}


Expand All @@ -141,7 +147,7 @@ object ExpectationsSuite extends SimpleIOSuite {
}

pureTest("And/Or composition (failure)") {
(expect(1 != 2) and expect(2 == 1)) or expect(2 == 3)
(expect(1 != clue(2)) and expect(2 == clue(1))) or expect(2 == clue(3))
}

pureTest("Varargs composition (success)") {
Expand All @@ -151,7 +157,7 @@ object ExpectationsSuite extends SimpleIOSuite {

pureTest("Varargs composition (failure)") {
// expect(1 + 1 == 2) && expect (2 + 2 == 4) && expect(4 * 2 == 8)
expect.all(1 + 1 == 2, 2 + 2 == 5, 4 * 2 == 8)
expect.all(clue(1 + 1) == 2, clue(2 + 2) == 5, clue(4 * 2) == 8)
}

pureTest("Working with collections (success)") {
Expand All @@ -166,7 +172,7 @@ object ExpectationsSuite extends SimpleIOSuite {
}

pureTest("Working with collections (failure 2)") {
exists(Option(39))(i => expect(i > 50))
exists(Option(39))(i => expect(clue(i) > 50))
}

import cats.Eq
Expand Down Expand Up @@ -220,7 +226,7 @@ object ExpectationsSuite extends SimpleIOSuite {
test("Failing fast expectations") {
for {
h <- IO.pure("hello")
_ <- expect(h.isEmpty).failFast
_ <- expect(clue(h).isEmpty).failFast
} yield success
}
}
Expand Down
2 changes: 1 addition & 1 deletion docs/samples/multiple_suites_failures.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ object MyAnotherSuite extends SimpleIOSuite {
} yield check(x).traced(here)
}

def check(x : String) = expect(x.length > 10)
def check(x : String) = expect(clue(clue(x).length) > 10)
}
```

Expand Down
18 changes: 0 additions & 18 deletions modules/core/shared/src/main/scala-2/weaver/Expect.scala

This file was deleted.

170 changes: 170 additions & 0 deletions modules/core/shared/src/main/scala-2/weaver/ExpectMacro.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package weaver

import scala.reflect.macros.blackbox
import weaver.internals.ClueHelpers

private[weaver] trait ExpectMacro {

/**
* Asserts that a boolean value is true.
*
* Use the [[Expectations.Helpers.clue]] function to investigate any failures.
*/
def apply(value: Boolean): Expectations = macro ExpectMacro.applyImpl

/**
* Asserts that a boolean value is true and displays a failure message if not.
*
* Use the [[Expectations.Helpers.clue]] function to investigate any failures.
*/
def apply(value: Boolean, message: => String): Expectations =
macro ExpectMacro.messageImpl

/**
* Asserts that boolean values are all true.
*
* Use the [[Expectations.Helpers.clue]] function to investigate any failures.
*/
def all(values: Boolean*): Expectations = macro ExpectMacro.allImpl
}

private[weaver] object ExpectMacro {

/**
* Constructs [[Expectations]] from several boolean values.
*
* If any value evaluates to false, all generated clues are displayed as part
* of the failed expectation.
*/
def allImpl(c: blackbox.Context)(values: c.Tree*): c.Tree = {
import c.universe._
val sourceLoc = new weaver.macros.Macros(c).fromContext.asInstanceOf[c.Tree]
val (cluesName, cluesValDef) = makeClues(c)
val clueMethodSymbol = getClueMethodSymbol(c)

val transformedValues =
values.toList.map(replaceClueMethodCalls(c)(clueMethodSymbol,
cluesName,
_))
makeExpectations(c)(cluesName,
cluesValDef,
transformedValues,
sourceLoc,
q"None")
}

/**
* Constructs [[Expectations]] from a boolean value and message.
*
* If the value evaluates to false, the message is displayed as part of the
* failed expectation.
*/
def messageImpl(c: blackbox.Context)(
value: c.Tree,
message: c.Tree): c.Tree = {
import c.universe._
val sourceLoc = new weaver.macros.Macros(c).fromContext.asInstanceOf[c.Tree]
val (cluesName, cluesValDef) = makeClues(c)
val clueMethodSymbol = getClueMethodSymbol(c)

val transformedValue =
replaceClueMethodCalls(c)(clueMethodSymbol, cluesName, value)
makeExpectations(c)(cluesName,
cluesValDef,
List(transformedValue),
sourceLoc,
q"Some($message)")
}

/**
* Constructs [[Expectations]] from a boolean value.
*
* A macro is needed to support clues. The value expression may contain calls
* to [[ClueHelpers.clue]], which generate clues for values under test.
*
* This macro constructs a local collection of [[Clues]] and adds the
* generated clues to it. Calls to [[ClueHelpers.clue]] are rewritten to calls
* to [[Clues.addClue]].
*
* After the value is evaluated, the [[Clues]] collection is used to contruct
* [[Expectations]].
*/
def applyImpl(c: blackbox.Context)(value: c.Tree): c.Tree = {

import c.universe._
val sourceLoc = new weaver.macros.Macros(c).fromContext.asInstanceOf[c.Tree]
val (cluesName, cluesValDef) = makeClues(c)
val clueMethodSymbol = getClueMethodSymbol(c)

val transformedValue =
replaceClueMethodCalls(c)(clueMethodSymbol, cluesName, value)
makeExpectations(c)(cluesName,
cluesValDef,
List(transformedValue),
sourceLoc,
q"None")
}

/** Constructs [[Expectations]] from the local [[Clues]] collection. */
private def makeExpectations(c: blackbox.Context)(
cluesName: c.TermName,
cluesValDef: c.Tree,
values: List[c.Tree],
sourceLoc: c.Tree,
message: c.Tree): c.Tree = {
import c.universe._
val block =
q"$cluesValDef; _root_.weaver.internals.Clues.toExpectations($sourceLoc, $message, $cluesName, ..$values)"
val untyped = c.untypecheck(block)
val retyped = c.typecheck(untyped, pt = c.typeOf[Expectations])
retyped

}

/** Get the [[ClueHelpers.clue]] symbol. */
private def getClueMethodSymbol(c: blackbox.Context): c.Symbol = {
import c.universe._
symbolOf[ClueHelpers].info.member(TermName("clue"))
}

/** Construct a [[Clues]] collection local to the `expect` call. */
private def makeClues(c: blackbox.Context): (c.TermName, c.Tree) = {
import c.universe._
val cluesName = TermName(c.freshName("clues$"))
val cluesValDef =
q"val $cluesName: _root_.weaver.internals.Clues = new _root_.weaver.internals.Clues()"
(cluesName, cluesValDef)
}

/**
* Replaces all calls to [[ClueHelpers.clue]] with calls to [[Clues.addClue]].
*/
private def replaceClueMethodCalls(c: blackbox.Context)(
clueMethodSymbol: c.Symbol,
cluesName: c.TermName,
value: c.Tree): c.Tree = {

import c.universe._

// This transformation outputs code that adds clues to a local
// clues collection `cluesName`. It recurses over the input code and replaces
// all calls of `ClueHelpers.clue` with `cluesName.addClue`.
object transformer extends Transformer {

override def transform(input: Tree): Tree = input match {
case c.universe.Apply(fun, List(clueValue))
if fun.symbol == clueMethodSymbol =>
// The input tree corresponds to `ClueHelpers.clue(clueValue)` .
// Transform it into `clueName.addClue(clueValue)`
// Apply the transformation recursively to `clueValue` to support nested clues.
val transformedClueValue = super.transform(clueValue)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious about this transformation here. Can you explain ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course!

For context, the user writes clue(x) where clue refers to ClueHelpers.clue. There's an implicit conversion from x to a Clue. Explicitly, this is written as clue(Clue.generateClue(x)) .

The transformation replaces ClueHelpers.clue with addClue on a clues collection. In other words, it replaces:
clue(Clue.generateClue(x)) with clues.addClue(Clue.generateClue(x)).

The clues collection is generated as part of the overall expect macro. For example:

expect(clue(List(x, clue(y))))

becomes

expect {
  val clues = new Clues()
  clues.addClue(List(x, clues.addClue(y)))
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh gotcha, hadn't thought of nested clues, but it makes sense 👍

q"""${cluesName}.addClue($transformedClueValue)"""
case o =>
// Otherwise, recurse over the input.
super.transform(o)
}
}

transformer.transform(value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package weaver

// kudos to https://github.com/monix/minitest
// format: off
import scala.reflect.macros.whitebox
import scala.reflect.macros.blackbox

trait SourceLocationMacro {

Expand All @@ -22,10 +22,10 @@ trait SourceLocationMacro {
}

object macros {
class Macros(val c: whitebox.Context) {
class Macros(val c: blackbox.Context) {
import c.universe._

def fromContext: Tree = {
def fromContext: c.Tree = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

val (pathExpr, relPathExpr, lineExpr) = getSourceLocation
val SourceLocationSym = symbolOf[SourceLocation].companion
q"""$SourceLocationSym($pathExpr, $relPathExpr, $lineExpr)"""
Expand Down
50 changes: 50 additions & 0 deletions modules/core/shared/src/main/scala-2/weaver/internals/Clue.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package weaver.internals

import cats.Show

/**
* Captures the source code, type information, and runtime representation of a
* value.
*
* Clues are useful for investigating failed assertions. A clue for a given
* value is summoned with the [[ClueHelpers.clue]] function. This constructs a
* clue for a given value using an implicit conversion.
*
* @param source
* The source code of the value
* @param value
* The runtime value
* @param valueType
* The string representation of the type of the value
* @param show
* The [[cats.Show]] typeclass used to display the value.
*/
private[weaver] class Clue[T](
source: String,
private[internals] val value: T,
valueType: String,
show: Show[T]
) {
private[internals] def prettyPrint: String =
s"${source}: ${valueType} = ${show.show(value)}"
}

private[internals] trait LowPriorityClueImplicits {

/**
* Generates a clue for a given value using the [[toString]] function to print
* the value.
*/
implicit def generateClueFromToString[A](value: A): Clue[A] =
macro ClueMacro.showFromToStringImpl
}
private[weaver] object Clue extends LowPriorityClueImplicits {

/**
* Generates a clue for a given value using a [[Show]] instance to print the
* value.
*/
implicit def generateClue[A](value: A)(implicit catsShow: Show[A]): Clue[A] =
macro ClueMacro.impl

}
Loading