Skip to content

Commit

Permalink
Replace expecty with clue.
Browse files Browse the repository at this point in the history
  • Loading branch information
zainab-ali committed Sep 17, 2024
1 parent 0e0ca96 commit 1bda9bb
Show file tree
Hide file tree
Showing 22 changed files with 770 additions and 109 deletions.
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.

162 changes: 162 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,162 @@
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._
object transformer extends Transformer {

override def transform(input: Tree): Tree = input match {
case c.universe.Apply(fun, List(clueValue))
if fun.symbol == clueMethodSymbol =>
val transformedClueValue = super.transform(clueValue)
val clueName = TermName(c.freshName("clue$"))
q"""{val $clueName = ${transformedClueValue}; ${cluesName}.addClue($clueName)}"""
case o => 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 = {
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

0 comments on commit 1bda9bb

Please sign in to comment.