Skip to content

DeepStubs play nicely with ScalaMockitoSession/MockitoFixture #39

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

Merged
merged 6 commits into from
Sep 1, 2018
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
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,14 +249,19 @@ Check the [tests](https://github.com/mockito/mockito-scala/blob/master/core/src/

## Default Answers
We defined a new type `org.mockito.DefaultAnswer` which is used to configure the default behaviour of a mock when a non-stubbed invocation
is made on it, the default behaviour is different to the Java version, instead of returning null for any non-primitive or non-final class,
mockito-scala will return a "Smart Null", which is basically a mock of the type returned by the called method.
The main advantage of this is that if the code tries to call any method on this mock, instead of failing with a NPE we will
throw a different exception with a hint of the non-stubbed method call (including its params) that returned this Smart Null,
this will make it much easier to find and fix a non-stubbed call

Most of the Answers defined in `org.mockito.Answers` have it's counterpart as a `org.mockito.DefaultAnswer`, and on top of that
we also provide `org.mockito.ReturnsEmptyValues` which will try its best to return an empty object for well known types,
is made on it.

The object `org.mockito.DefaultAnswers` contains each one of the provided ones

All the mocks created will use `ReturnsSmartNulls` by default, this is different to the Java version, which returns null for any non-primitive or non-final class.

A "Smart Null", is nothing else than a mock of the type returned by the called method.
The main advantage of doing that is that if the code tries to call any method on this mock, instead of failing with a NPE the mock will
throw a different exception with a hint of the non-stubbed method that was called (including its params),
this should make much easier the task of finding and fixing non-stubbed calls

Most of the Answers defined in `org.mockito.Answers` have it's counterpart in `org.mockito.DefaultAnswers`, and on top of that
we also provide `ReturnsEmptyValues` which will try its best to return an empty object for well known types,
i.e. `Nil` for `List`, `None` for `Option` etc.
This DefaultAnswer is not part of the default behaviour as we think a SmartNull is better, to explain why, let's imagine we
have the following code.
Expand Down
54 changes: 19 additions & 35 deletions core/src/main/scala/org/mockito/DefaultAnswer.scala
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package org.mockito

import java.lang.reflect.Modifier.{isAbstract, isFinal}
import java.lang.reflect.Modifier.isAbstract

import org.mockito.exceptions.base.MockitoException
import org.mockito.exceptions.verification.SmartNullPointerException
import org.mockito.internal.util.ObjectMethodsGuru.isToStringMethod
import org.mockito.invocation.InvocationOnMock
import org.mockito.stubbing.Answer
import org.mockito.Answers._
Expand All @@ -27,43 +25,18 @@ trait DefaultAnswer extends Answer[Any] with Function[InvocationOnMock, Option[A

object DefaultAnswer {
implicit val defaultAnswer: DefaultAnswer = ReturnsSmartNulls
}

object ReturnsDefaults extends DefaultAnswer {
override def apply(invocation: InvocationOnMock): Option[Any] = Option(RETURNS_DEFAULTS.answer(invocation))
def apply(from: Answer[_]): DefaultAnswer = new DecoratedAnswer(from)
}

object ReturnsSmartNulls extends DefaultAnswer {
override def apply(invocation: InvocationOnMock): Option[Any] = Option(RETURNS_DEFAULTS.answer(invocation)).orElse {
val returnType = invocation.getMethod.getReturnType

if (!returnType.isPrimitive && !isFinal(returnType.getModifiers))
Some(Mockito.mock(returnType, ThrowsSmartNullPointer(invocation)))
else
None
}

private case class ThrowsSmartNullPointer(unStubbedInvocation: InvocationOnMock) extends Answer[Any] {

override def answer(currentInvocation: InvocationOnMock): Any =
if (isToStringMethod(currentInvocation.getMethod))
s"""SmartNull returned by this un-stubbed method call on a mock:
|${unStubbedInvocation.toString}""".stripMargin
else
throw new SmartNullPointerException(
s"""You have a NullPointerException because this method call was *not* stubbed correctly:
|[$unStubbedInvocation] on the Mock [${unStubbedInvocation.getMock}]""".stripMargin)
}
}

object ReturnsDeepStubs extends DefaultAnswer {
override def apply(invocation: InvocationOnMock): Option[Any] = Option(RETURNS_DEEP_STUBS.answer(invocation))
}

object CallsRealMethods extends DefaultAnswer {
override def apply(invocation: InvocationOnMock): Option[Any] = Option(CALLS_REAL_METHODS.answer(invocation))
class DecoratedAnswer(from: Answer[_]) extends DefaultAnswer {
override def apply(invocation: InvocationOnMock): Option[Any] = Option(from.answer(invocation))
}

object ReturnsDefaults extends DecoratedAnswer(RETURNS_DEFAULTS)
object ReturnsDeepStubs extends DecoratedAnswer(RETURNS_DEEP_STUBS)
object CallsRealMethods extends DecoratedAnswer(CALLS_REAL_METHODS)
object ReturnsSmartNulls extends DecoratedAnswer(RETURNS_SMART_NULLS)
object ReturnsEmptyValues extends DefaultAnswer {
private val javaEmptyValuesAndPrimitives = new ReturnsMoreEmptyValues

Expand All @@ -88,3 +61,14 @@ object ReturnsEmptyValues extends DefaultAnswer {
override def apply(invocation: InvocationOnMock): Option[Any] =
Option(javaEmptyValuesAndPrimitives.answer(invocation)).orElse(emptyValues.get(invocation.getMethod.getReturnType))
}

/**
* Simple object to act as an 'enum' of DefaultAnswers
*/
object DefaultAnswers {
val ReturnsDefaults: DefaultAnswer = org.mockito.ReturnsDefaults
val ReturnsDeepStubs: DefaultAnswer = org.mockito.ReturnsDeepStubs
val CallsRealMethods: DefaultAnswer = org.mockito.CallsRealMethods
val ReturnsSmartNulls: DefaultAnswer = org.mockito.ReturnsSmartNulls
val ReturnsEmptyValues: DefaultAnswer = org.mockito.ReturnsEmptyValues
}
2 changes: 1 addition & 1 deletion core/src/main/scala/org/mockito/IdiomaticMockito.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ trait IdiomaticMockito extends MockCreator {

override def mock[T <: AnyRef: ClassTag: TypeTag](mockSettings: MockSettings): T = MockitoSugar.mock[T](mockSettings)

override def mock[T <: AnyRef: ClassTag: TypeTag](defaultAnswer: Answer[_]): T = MockitoSugar.mock[T](defaultAnswer)
override def mock[T <: AnyRef: ClassTag: TypeTag](defaultAnswer: DefaultAnswer): T = MockitoSugar.mock[T](defaultAnswer)

override def mock[T <: AnyRef: ClassTag: TypeTag](implicit defaultAnswer: DefaultAnswer): T =
MockitoSugar.mock[T]
Expand Down
20 changes: 11 additions & 9 deletions core/src/main/scala/org/mockito/MockitoAPI.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,20 @@ import scala.reflect.runtime.universe.TypeTag

private[mockito] trait MockCreator {
def mock[T <: AnyRef: ClassTag: TypeTag](implicit defaultAnswer: DefaultAnswer): T
def mock[T <: AnyRef: ClassTag: TypeTag](defaultAnswer: Answer[_]): T
def mock[T <: AnyRef: ClassTag: TypeTag](defaultAnswer: Answer[_]): T = mock[T](DefaultAnswer(defaultAnswer))
def mock[T <: AnyRef: ClassTag: TypeTag](defaultAnswer: DefaultAnswer): T
def mock[T <: AnyRef: ClassTag: TypeTag](mockSettings: MockSettings): T
def mock[T <: AnyRef: ClassTag: TypeTag](name: String)(implicit defaultAnswer: DefaultAnswer): T

def spy[T](realObj: T): T
def spyLambda[T <: AnyRef: ClassTag](realObj: T): T

/**
* Delegates to <code>Mockito.withSettings()</code>, it's only here to expose the full Mockito API
*/
def withSettings(implicit defaultAnswer: DefaultAnswer): MockSettings =
Mockito.withSettings().defaultAnswer(defaultAnswer)

}

//noinspection MutatorLikeMethodIsParameterless
Expand Down Expand Up @@ -115,8 +123,8 @@ private[mockito] trait MockitoEnhancer extends MockCreator {
* <code>verify(aMock).iHaveSomeDefaultArguments("I'm not gonna pass the second argument", "default value")</code>
* as the value for the second parameter would have been null...
*/
override def mock[T <: AnyRef: ClassTag: TypeTag](defaultAnswer: Answer[_]): T =
mock(withSettings(defaultAnswer.lift))
override def mock[T <: AnyRef: ClassTag: TypeTag](defaultAnswer: DefaultAnswer): T =
mock(withSettings(defaultAnswer))

/**
* Delegates to <code>Mockito.mock(type: Class[T], mockSettings: MockSettings)</code>
Expand Down Expand Up @@ -208,12 +216,6 @@ private[mockito] trait MockitoEnhancer extends MockCreator {
*/
def mockingDetails(toInspect: AnyRef): MockingDetails = Mockito.mockingDetails(toInspect)

/**
* Delegates to <code>Mockito.withSettings()</code>, it's only here to expose the full Mockito API
*/
def withSettings(implicit defaultAnswer: DefaultAnswer): MockSettings =
Mockito.withSettings().defaultAnswer(defaultAnswer)

/**
* Delegates to <code>Mockito.verifyNoMoreInteractions(Object... mocks)</code>, but ignores the default stubs that
* deal with default argument values
Expand Down
88 changes: 64 additions & 24 deletions core/src/main/scala/org/mockito/MockitoScalaSession.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package org.mockito

import org.mockito.MockitoScalaSession.UnexpectedInvocationsMockListener
import org.mockito.exceptions.misusing.UnexpectedInvocationException
import org.mockito.MockitoScalaSession.{MockitoScalaSessionListener, UnexpectedInvocations}
import org.mockito.exceptions.misusing.{UnexpectedInvocationException, UnnecessaryStubbingException}
import org.mockito.internal.stubbing.StubbedInvocationMatcher
import org.mockito.invocation.{DescribedInvocation, Invocation, Location}
import org.mockito.listeners.MockCreationListener
import org.mockito.mock.MockCreationSettings
Expand All @@ -13,7 +14,7 @@ import scala.collection.mutable
import scala.collection.JavaConverters._

class MockitoScalaSession(name: String, strictness: Strictness, logger: MockitoSessionLogger) {
private val listener = new UnexpectedInvocationsMockListener
private val listener = new MockitoScalaSessionListener
private val mockitoSession = Mockito.mockitoSession().name(name).logger(logger).strictness(strictness).startMocking()

Mockito.framework().addListener(listener)
Expand All @@ -22,16 +23,17 @@ class MockitoScalaSession(name: String, strictness: Strictness, logger: MockitoS
try {
t.fold {
mockitoSession.finishMocking()
listener.reportUnStubbedCalls().reportUnexpectedInvocations()
listener.reportIssues().foreach(_.report())
} {
case e: NullPointerException =>
mockitoSession.finishMocking(e)
val unStubbedCalls = listener.reportUnStubbedCalls()
if (unStubbedCalls.nonEmpty)
throw new UnexpectedInvocationException(s"""A NullPointerException was thrown, check if maybe related to
|$unStubbedCalls""".stripMargin,
e)
else throw e
listener.reportIssues().foreach {
case unStubbedCalls: UnexpectedInvocations if unStubbedCalls.nonEmpty =>
throw new UnexpectedInvocationException(s"""A NullPointerException was thrown, check if maybe related to
|$unStubbedCalls""".stripMargin,
e)
case _ => throw e
}
case other =>
mockitoSession.finishMocking(other)
throw other
Expand Down Expand Up @@ -63,7 +65,11 @@ object MockitoScalaSession {
override def getLocation: Location = SyntheticLocation
}

case class UnexpectedInvocations(invocations: Set[Invocation]) {
trait Reporter {
def report(): Unit
}

case class UnexpectedInvocations(invocations: Set[Invocation]) extends Reporter {
def nonEmpty: Boolean = invocations.nonEmpty

override def toString: String =
Expand All @@ -80,25 +86,59 @@ object MockitoScalaSession {
|Please make sure you aren't missing any stubbing or that your code actually does what you want""".stripMargin
} else "No unexpected invocations found"

def reportUnexpectedInvocations(): Unit =
if (nonEmpty) throw new UnexpectedInvocationException(toString)
def report(): Unit = if (nonEmpty) throw new UnexpectedInvocationException(toString)
}

class UnexpectedInvocationsMockListener extends MockCreationListener {
def reportUnStubbedCalls(): UnexpectedInvocations =
UnexpectedInvocations(
mocks
.map(MockitoSugar.mockingDetails)
.flatMap(_.getInvocations.asScala)
.filter(_.stubInfo() == null)
.filterNot(_.isVerified)
.filterNot(_.getMethod.getName.contains("$default$"))
.toSet
case class UnusedStubbings(stubbings: Set[StubbedInvocationMatcher]) extends Reporter {
def nonEmpty: Boolean = stubbings.nonEmpty

override def toString: String =
if (nonEmpty) {
val locations = stubbings.zipWithIndex
.map {
case (stubbing, idx) => s"${idx + 1}. $stubbing ${stubbing.getLocation}"
}
.mkString("\n")
s"""Unnecessary stubbings detected.
|
|Clean & maintainable test code requires zero unnecessary code.
|Following stubbings are unnecessary (click to navigate to relevant line of code):
|$locations
|Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.""".stripMargin
} else "No unexpected invocations found"

def report(): Unit = if (nonEmpty) throw new UnnecessaryStubbingException(toString)
}

class MockitoScalaSessionListener extends MockCreationListener {
def reportIssues(): Seq[Reporter] = {
val mockDetails = mocks.toSet.map(MockitoSugar.mockingDetails)

val stubbings = mockDetails
.flatMap(_.getStubbings.asScala)
.collect {
case s: StubbedInvocationMatcher => s
}

val invocations = mockDetails.flatMap(_.getInvocations.asScala)

val unexpectedInvocations = invocations
.filterNot(_.isVerified)
.filterNot(_.getMethod.getName.contains("$default$"))
.filterNot(i => stubbings.exists(_.matches(i)))

val unusedStubbings = stubbings.filterNot(sm => invocations.exists(sm.matches)).filter(!_.wasUsed())

Seq(
UnexpectedInvocations(unexpectedInvocations),
UnusedStubbings(unusedStubbings)
)
}

private val mocks = mutable.Set.empty[AnyRef]

override def onMockCreated(mock: AnyRef, settings: MockCreationSettings[_]): Unit = mocks += mock
override def onMockCreated(mock: AnyRef, settings: MockCreationSettings[_]): Unit =
if (!settings.isLenient) mocks += mock
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package org.mockito.integrations.scalatest
import java.util.concurrent.ConcurrentHashMap

import org.mockito.{DefaultAnswer, MockCreator, MockitoSugar, MockSettings}
import org.mockito.stubbing.Answer
import org.scalatest.{Outcome, TestSuite}

import scala.collection.JavaConverters._
Expand Down Expand Up @@ -37,7 +36,7 @@ trait ResetMocksAfterEachTest extends TestSuite with MockCreator { self: MockCre
abstract override def mock[T <: AnyRef: ClassTag: TypeTag](implicit defaultAnswer: DefaultAnswer): T =
addMock(super.mock[T])

abstract override def mock[T <: AnyRef: ClassTag: TypeTag](defaultAnswer: Answer[_]): T =
abstract override def mock[T <: AnyRef: ClassTag: TypeTag](defaultAnswer: DefaultAnswer): T =
addMock(super.mock[T](defaultAnswer))

abstract override def mock[T <: AnyRef: ClassTag: TypeTag](mockSettings: MockSettings): T =
Expand Down
6 changes: 0 additions & 6 deletions core/src/main/scala/org/mockito/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,4 @@ package object mockito {
i.getArgument[P9](9),
i.getArgument[P10](10)
))

implicit class AnswerOps[T](val a: Answer[T]) extends AnyVal {
def lift: DefaultAnswer = new DefaultAnswer {
override def apply(invocation: InvocationOnMock): Option[Any] = Option(a.answer(invocation))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class MockitoSugarTest_211 extends WordSpec with MockitoSugar with scalatest.Mat

aMock.traitMethod() shouldBe 69

verify(aMock).traitMethod(0, null)
verify(aMock).traitMethod(0, "")
}
}
}
14 changes: 5 additions & 9 deletions core/src/test/scala/org/mockito/DefaultAnswerTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,11 @@ class DefaultAnswerTest

smartNull should not be null

val throwable: SmartNullPointerException = the[SmartNullPointerException] thrownBy {
val throwable = the[SmartNullPointerException] thrownBy {
smartNull.callMeMaybe()
}

throwable.getMessage shouldBe
s"""You have a NullPointerException because this method call was *not* stubbed correctly:
|[foo.userClass(42);] on the Mock [$aMock]""".stripMargin
throwable.getMessage should include("You have a NullPointerException here:")
}

"return a smart standard monad" in {
Expand All @@ -113,9 +111,7 @@ class DefaultAnswerTest
smartNull.isEmpty
}

throwable.getMessage shouldBe
s"""You have a NullPointerException because this method call was *not* stubbed correctly:
|[foo.returnsList();] on the Mock [$aMock]""".stripMargin
throwable.getMessage should include("You have a NullPointerException here:")
}

"return a default value for primitives" in {
Expand All @@ -138,7 +134,7 @@ class DefaultAnswerTest

"ReturnsEmptyValues" should {
"return a default value for primitives" in {
val primitives = mock[Primitives](ReturnsEmptyValues)
val primitives = mock[Primitives](DefaultAnswers.ReturnsEmptyValues)

primitives.barByte shouldBe 0.toByte
primitives.barBoolean shouldBe false
Expand All @@ -151,7 +147,7 @@ class DefaultAnswerTest
}

"return the empty values for known classes" in {
val aMock = mock[KnownTypes](ReturnsEmptyValues)
val aMock = mock[KnownTypes](DefaultAnswers.ReturnsEmptyValues)

aMock.returnsOption shouldBe None
aMock.returnsList shouldBe List.empty
Expand Down
Loading