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

Cats support #29

Merged
merged 4 commits into from
Dec 10, 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
20 changes: 17 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ ThisBuild / developers := List(

lazy val scala3mock = project
.in(file("."))
.aggregate(core, scalatest)
.aggregate(core, cats, scalatest)
.settings(publish / skip := true)

lazy val core = project
Expand All @@ -43,6 +43,19 @@ lazy val core = project
libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M10" % Test
)

lazy val cats = project
.in(file("./cats"))
.dependsOn(core)
.settings(
name := "scala3mock-cats",
libraryDependencies += "org.typelevel" %% "cats-core" % "2.9.0",

libraryDependencies ++= Seq(
"org.scalameta" %% "munit" % "1.0.0-M10" % Test,
"org.typelevel" %% "cats-effect" % "3.5.2" % Test
)
)

lazy val scalatest = project
.in(file("./scalatest"))
.dependsOn(core)
Expand All @@ -53,11 +66,12 @@ lazy val scalatest = project

lazy val docs = project
.in(file("site-docs")) // important: it must not be docs/
.dependsOn(core, scalatest)
.dependsOn(core, cats, scalatest)
.enablePlugins(MdocPlugin, DocusaurusPlugin)
.settings(
publish / skip := true,
mdocVariables := Map(
"VERSION" -> (core / version).value
)
),
libraryDependencies += "org.typelevel" %% "cats-effect" % "3.5.2"
)
72 changes: 72 additions & 0 deletions cats/src/main/scala/eu/monniot/scala3mock/cats/ScalaMocks.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package eu.monniot.scala3mock.cats

import cats.MonadError
import eu.monniot.scala3mock.context.{Call, MockContext}
import eu.monniot.scala3mock.functions.MockFunction1
import eu.monniot.scala3mock.handlers.{CallHandler, Handler, UnorderedHandlers}
import eu.monniot.scala3mock.MockExpectationFailed

import scala.annotation.unused
import scala.collection.mutable.ListBuffer
import scala.util.control.NonFatal

object ScalaMocks extends ScalaMocks

/** Helper trait that provide access to all components (mandatory or optional)
* used by the library to build mocks.
*/
trait ScalaMocks
extends eu.monniot.scala3mock.functions.MockFunctions
with eu.monniot.scala3mock.macros.Mocks
with eu.monniot.scala3mock.matchers.Matchers:

// apparently using export in 3.2.2 lose the default value of the
// parameter. That might have been fixed in 3.3+, but we can't use
// that version so for now we will duplicate the definition.
def withExpectations[F[_], A](verifyAfterRun: Boolean = true)(
f: MockContext ?=> F[A]
)(using MonadError[F, Throwable]): F[A] =
eu.monniot.scala3mock.cats.withExpectations(verifyAfterRun)(f)

// A standalone function to run a test with a mock context, asserting all expectations at the end.
def withExpectations[F[_], A](verifyAfterRun: Boolean = true)(
f: MockContext ?=> F[A]
)(using MonadError[F, Throwable]): F[A] =

val ctx = new MockContext:
override type ExpectationException = MockExpectationFailed

override def newExpectationException(
message: String,
methodName: Option[String]
): ExpectationException =
new MockExpectationFailed(message, methodName)

override def toString() = s"MockContext(callLog = $callLog)"

def initializeExpectations(): Unit =
val initialHandlers = new UnorderedHandlers

ctx.callLog = new ListBuffer[Call]
ctx.expectationContext = initialHandlers

def verifyExpectations(): Unit =
ctx.callLog foreach ctx.expectationContext.verify _

val oldCallLog = ctx.callLog
val oldExpectationContext = ctx.expectationContext

if !oldExpectationContext.isSatisfied then
ctx.reportUnsatisfiedExpectation(oldCallLog, oldExpectationContext)

initializeExpectations()
val me = MonadError[F, Throwable]

me.flatMap(f(using ctx)) { a =>
if verifyAfterRun then
try
verifyExpectations()
me.pure(a)
catch case t => me.raiseError(t)
else me.pure(a)
}
21 changes: 21 additions & 0 deletions cats/src/test/scala/eu/monniot/scala3mock/cats/CatsSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package eu.monniot.scala3mock.cats

import cats.effect.SyncIO

class CatsSuite extends munit.FunSuite with ScalaMocks {

test("it should validate expectations after a lazy data type evaluated") {
// An implicit assumption of this code block is that the expectations
// are not validated on exit of the `withExpectations` function but when
// the returned Monad is being evaluated.
val fa = withExpectations() {
val intToStringMock = mockFunction[Int, String]
intToStringMock.expects(*)

SyncIO(intToStringMock(2))
}

assertEquals(fa.unsafeRunSync(), null)
}

}
33 changes: 33 additions & 0 deletions docs/user-guide/cats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
title: Cats Integration
---

Scala3Mock comes with a Cats integration that provides a `withExpectations` function that work with a `MonadError[?, Throwable]` instead of the simple value that the default library provide. This is pretty useful if your tests are defined in term of monads (Cats-Effect's `IO`, Scala's `Future` via alleycats, etc…).

You'll need to add a new dependency to your **build.sbt**:
```scala
libraryDependencies += "eu.monniot" %% "scala3mock-cats" % "@VERSION@" % Test
```

Once added, simply replace the `ScalaMocks` import with the Cats one:

```diff
- import eu.monniot.scala3mock.ScalaMocks.*
+ import eu.monniot.scala3mock.cats.ScalaMocks.*
```

You can then use the library like usual, with the value passed to `withExpectations` being a monad instead of any values.

```scala mdoc
import eu.monniot.scala3mock.cats.ScalaMocks.*
import cats.effect.SyncIO

val fa = withExpectations() {
val fn = mockFunction[Int, String]
fn.expects(*).returns("Hello reader")

SyncIO(fn(2))
}

fa.unsafeRunSync()
```