Skip to content

Commit

Permalink
Introduce static singleton object spying with real-method-call and st…
Browse files Browse the repository at this point in the history
…rict-stub default behaviour (#392)

* The reworking of discovering the version supports resolving the `version.properties` file, when referencing this project i.e. `mockito-scala`, from another project in the developer's workspace

* Found the right places for tests:

  1. Includes auto-verification within a session for unnecessary stubs; strictness is applied by default via implicits
  2. Proof that real methods are called when not stubbed

* Refactored/de-duped the implementation

* New SBT/Scala version and/or additional checks cause CircleCI to fail; `sbt test` runs green locally.

* Added convenience implicits and documentation explaining the intended use
  • Loading branch information
DarrenBishop authored Aug 17, 2021
1 parent afc0822 commit 398e0f2
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 41 deletions.
58 changes: 24 additions & 34 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
import scala.io.Source
import scala.language.postfixOps
import scala.util.Try
import sbt.io.Using

val currentScalaVersion = "2.13.6"

ThisBuild / scalaVersion := currentScalaVersion
inThisBuild(
Seq(
scalaVersion := currentScalaVersion,
//Load version from the file so that Gradle/Shipkit and SBT use the same version
version := sys.env
.get("PROJECT_VERSION")
.filter(_.trim.nonEmpty)
.orElse {
lazy val VersionRE = """^version=(.+)$""".r
Using.file(Source.fromFile)(baseDirectory.value / "version.properties") {
_.getLines.collectFirst { case VersionRE(v) => v }
}
}
.map { _.replace(".*", "-SNAPSHOT") }
.get
)
)

lazy val commonSettings =
Seq(
organization := "org.mockito",
//Load version from the file so that Gradle/Shipkit and SBT use the same version
version := {
val versionFromEnv = System.getenv("PROJECT_VERSION")
if (versionFromEnv != null && versionFromEnv.trim().nonEmpty) {
versionFromEnv
} else {
val pattern = """^version=(.+)$""".r
val source = Source.fromFile("version.properties")
val version = Try(source.getLines.collectFirst { case pattern(v) =>
v
}.get)
source.close
version.get.replace(".*", "-SNAPSHOT")
}
},
crossScalaVersions := Seq(currentScalaVersion, "2.12.14", "2.11.12"),
scalafmtOnCompile := true,
scalacOptions ++= Seq(
Expand Down Expand Up @@ -163,29 +165,17 @@ lazy val core = (project in file("core"))
//TODO remove when we remove the deprecated classes in org.mockito.integrations.Dependencies.scalatest
libraryDependencies += Dependencies.scalatest % "provided",
// include the macro classes and resources in the main jar
mappings in (Compile, packageBin) ++= mappings
.in(macroSub, Compile, packageBin)
.value,
Compile / packageBin / mappings ++= (macroSub / Compile / packageBin / mappings).value,
// include the macro sources in the main source jar
mappings in (Compile, packageSrc) ++= mappings
.in(macroSub, Compile, packageSrc)
.value,
Compile / packageSrc / mappings ++= (macroSub / Compile / packageSrc / mappings).value,
// include the common classes and resources in the main jar
mappings in (Compile, packageBin) ++= mappings
.in(common, Compile, packageBin)
.value,
Compile / packageBin / mappings ++= (common / Compile / packageBin / mappings).value,
// include the common sources in the main source jar
mappings in (Compile, packageSrc) ++= mappings
.in(common, Compile, packageSrc)
.value,
Compile / packageSrc / mappings ++= (common / Compile / packageSrc / mappings).value,
// include the common classes and resources in the main jar
mappings in (Compile, packageBin) ++= mappings
.in(macroCommon, Compile, packageBin)
.value,
Compile / packageBin / mappings ++= (macroCommon / Compile / packageBin / mappings).value,
// include the common sources in the main source jar
mappings in (Compile, packageSrc) ++= mappings
.in(macroCommon, Compile, packageSrc)
.value
Compile / packageSrc / mappings ++= (macroCommon / Compile / packageSrc / mappings).value
)

lazy val macroSub = (project in file("macro"))
Expand Down
34 changes: 31 additions & 3 deletions common/src/main/scala/org/mockito/MockitoAPI.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import org.mockito.mock.MockCreationSettings
import org.mockito.stubbing._
import org.mockito.verification.{ VerificationAfterDelay, VerificationMode, VerificationWithTimeout }
import org.scalactic.{ Equality, Prettifier }

import scala.collection.JavaConverters._
import scala.reflect.ClassTag
import scala.reflect.runtime.universe.WeakTypeTag
Expand Down Expand Up @@ -627,14 +626,29 @@ private[mockito] trait MockitoEnhancer extends MockCreator {
/**
* Mocks the specified object only for the context of the block
*/
def withObjectMocked[O <: AnyRef: ClassTag](block: => Any)(implicit defaultAnswer: DefaultAnswer, $pt: Prettifier): Unit = {
def withObjectMocked[O <: AnyRef: ClassTag](block: => Any)(implicit defaultAnswer: DefaultAnswer, $pt: Prettifier): Unit =
withObject[O](_ => withSettings(defaultAnswer), block)

/**
* Spies the specified object only for the context of the block
*
* Automatically pulls in [[org.mockito.LeniencySettings#strictStubs strict stubbing]] behaviour via implicits.
* To override this default (strict) behaviour, bring lenient settings into implicit scope;
* see [[org.mockito.leniency]] for details
*/
def withObjectSpied[O <: AnyRef: ClassTag](block: => Any)(implicit leniency: LeniencySettings, $pt: Prettifier): Unit = {
val settings = leniency(Mockito.withSettings().defaultAnswer(CALLS_REAL_METHODS))
withObject[O](settings.spiedInstance(_), block)
}

private[mockito] def withObject[O <: AnyRef: ClassTag](settings: O => MockSettings, block: => Any)(implicit $pt: Prettifier) = {
val objectClass = clazz[O]
objectClass.synchronized {
val moduleField = objectClass.getDeclaredField("MODULE$")
val realImpl: O = moduleField.get(null).asInstanceOf[O]

val threadAwareMock = createMock(
withSettings(defaultAnswer),
settings(realImpl),
(settings: MockCreationSettings[O], pt: Prettifier) => ThreadAwareMockHandler(settings, realImpl)(pt)
)

Expand All @@ -645,6 +659,20 @@ private[mockito] trait MockitoEnhancer extends MockCreator {
}
}

trait LeniencySettings {
def apply(settings: MockSettings): MockSettings
}

object LeniencySettings {
implicit val strictStubs: LeniencySettings = new LeniencySettings {
override def apply(settings: MockSettings): MockSettings = settings
}

val lenientStubs: LeniencySettings = new LeniencySettings {
override def apply(settings: MockSettings): MockSettings = settings.lenient()
}
}

private[mockito] trait Verifications {

/**
Expand Down
23 changes: 23 additions & 0 deletions common/src/main/scala/org/mockito/mockito.scala
Original file line number Diff line number Diff line change
Expand Up @@ -613,4 +613,27 @@ package object mockito {
new Equality[T] with Serializable {
override def areEqual(a: T, b: Any): Boolean = Equality.default[T].areEqual(a, b)
}

/**
* Implicit [[org.mockito.LeniencySettings LeniencySettings]] provided here for convenience
*
* Neither are in implicit scope as is; pull one or the other in to activate the respective semantics, for
* example:
*
* {{{
* import org.mockito.leniency.lenient
*
* withObjectSpied[SomeObject.type] {
* SomeObject.getExternalThing returns "external-thing"
* SomeObject.getOtherThing returns "other-thing"
* SomeObject.getExternalThing should be("external-thing")
* }
* }}}
*
* Note: strict stubbing is active by default via [[org.mockito.LeniencySettings#strictStubs strictStubs]]
*/
object leniency {
implicit val strict: LeniencySettings = LeniencySettings.strictStubs
implicit val lenient: LeniencySettings = LeniencySettings.lenientStubs
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import org.mockito.{ ArgumentMatchersSugar, IdiomaticStubbing }
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import user.org.mockito.matchers.{ ValueCaseClassInt, ValueCaseClassString, ValueClass }

import scala.collection.parallel.immutable
import scala.concurrent.{ Await, Future }
import scala.util.Random
Expand Down Expand Up @@ -246,8 +245,8 @@ class IdiomaticStubbingTest extends AnyWordSpec with Matchers with ArgumentMatch
}
}

"doStub" should {
"stub a spy that would fail if the real impl is called" in {
"spy" should {
"stub a function that would fail if the real impl is called" in {
val aSpy = spy(new Org)

an[IllegalArgumentException] should be thrownBy {
Expand All @@ -264,7 +263,7 @@ class IdiomaticStubbingTest extends AnyWordSpec with Matchers with ArgumentMatch
}
}

"stub a spy with an answer" in {
"stub a function with an answer" in {
val aSpy = spy(new Org)

((i: Int) => i * 10 + 2) willBe answered by aSpy.doSomethingWithThisInt(*)
Expand Down Expand Up @@ -295,6 +294,49 @@ class IdiomaticStubbingTest extends AnyWordSpec with Matchers with ArgumentMatch
org.doSomethingWithThisIntAndStringAndBoolean(1, "2", v3 = true) shouldBe "not mocked"
org.doSomethingWithThisIntAndStringAndBoolean(1, "2", v3 = false) shouldBe ""
}

"stub an object method" in {
FooObject.simpleMethod shouldBe "not mocked!"

withObjectSpied[FooObject.type] {
FooObject.simpleMethod returns "spied!"
FooObject.simpleMethod shouldBe "spied!"
}

FooObject.simpleMethod shouldBe "not mocked!"
}

"call real object method when not stubbed" in {
val now = FooObject.stateDependantMethod
withObjectSpied[FooObject.type] {
FooObject.simpleMethod returns s"spied!"
FooObject.simpleMethod shouldBe s"spied!"
FooObject.stateDependantMethod shouldBe now
}
}

"be thread safe" when {
"always stubbing object methods" in {
immutable.ParSeq.range(1, 100).foreach { i =>
withObjectSpied[FooObject.type] {
FooObject.simpleMethod returns s"spied!-$i"
FooObject.simpleMethod shouldBe s"spied!-$i"
}
}
}

"intermittently stubbing object methods" in {
val now = FooObject.stateDependantMethod
immutable.ParSeq.range(1, 100).foreach { i =>
if (i % 2 == 0)
withObjectSpied[FooObject.type] {
FooObject.stateDependantMethod returns i
FooObject.stateDependantMethod shouldBe i
}
else FooObject.stateDependantMethod shouldBe now
}
}
}
}

"mock" should {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -388,5 +388,51 @@ class MockitoScalaSessionTest extends AnyWordSpec with IdiomaticMockito with Mat

thrown.getMessage should include("You have a NullPointerException here:")
}

"verify object spies" when {

"successfully for uncalled lenient stubs" in {
MockitoScalaSession().run {
import org.mockito.leniency.lenient

withObjectSpied[FooObject.type] {
FooObject.stateDependantMethod returns 1234L
FooObject.simpleMethod returns s"spied!"
FooObject.simpleMethod shouldBe s"spied!"
}
}
}

"unsuccessfully for uncalled strict stubs" in {
val thrown = the[UnnecessaryStubbingException] thrownBy {
MockitoScalaSession().run {
import org.mockito.leniency.strict

withObjectSpied[FooObject.type] {
FooObject.stateDependantMethod returns 1234L
FooObject.simpleMethod returns s"spied!"
FooObject.simpleMethod shouldBe s"spied!"
}
}
}

thrown.getMessage should include("Unnecessary stubbings detected")
}

"unsuccessfully by default (strict) for uncalled stubs" in {

val thrown = the[UnnecessaryStubbingException] thrownBy {
MockitoScalaSession().run {
withObjectSpied[FooObject.type] {
FooObject.stateDependantMethod returns 1234L
FooObject.simpleMethod returns s"spied!"
FooObject.simpleMethod shouldBe s"spied!"
}
}
}

thrown.getMessage should include("Unnecessary stubbings detected")
}
}
}
}

0 comments on commit 398e0f2

Please sign in to comment.