Skip to content

Commit 16dbf46

Browse files
authored
Some fixes for Scala 3 mock-method metadata (#641)
1 parent 1ca2fa4 commit 16dbf46

7 files changed

Lines changed: 153 additions & 35 deletions

File tree

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,30 @@ The library has independent developers, release cycle and versioning from core m
4040
## Partial unification
4141
If you're in Scala 2.12 you'll probably want to add the compiler flag `-Ypartial-unification`, if you don't you risk some compile errors when trying to stub complex types using the idiomatic syntax
4242

43+
## Notes for 2.1.0
44+
45+
### Scala 3: `mock[T]` wrapper methods must be `inline`
46+
47+
In Scala 3, `mock[T]` uses a compile-time macro to register by-name/vararg parameter metadata
48+
and value-class return types for `T`. This macro only works when `T` is concrete at the call site,
49+
which requires every method in the call chain to be `inline`.
50+
51+
If you wrap `mock[T]` in a helper, declare it `inline`:
52+
53+
```scala
54+
// CORRECT — T is concrete at every call site
55+
inline def lenientMock[T <: AnyRef: ClassTag]: T =
56+
mock[T](Mockito.withSettings().strictness(Strictness.LENIENT))
57+
58+
// WRONG — T is erased; by-name/vararg metadata will NOT be registered
59+
def lenientMock[T <: AnyRef: ClassTag]: T =
60+
mock[T](Mockito.withSettings().strictness(Strictness.LENIENT))
61+
```
62+
63+
Without `inline`, stubs on by-name or vararg methods will silently fail to match at runtime.
64+
4365
## Notes for 2.0.0
44-
We dropped support for Scala 2.11 and Java 8, as Mockito 5 dropped support for Java 8.
66+
We dropped support for Scala 2.11 and Java 8, as Mockito 5 dropped support for Java 8.
4567
Java 11 is now the minimum supported version.
4668

4769
## Notes for 1.13.6

common/src/main/scala-3/org/mockito/MockCreator.scala

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ import scala.reflect.ClassTag
1818
* - `inline` keeps the concrete `T` at call sites, allowing compile-time metadata extraction.
1919
* - `createMock` registers per-method metadata via [[org.mockito.internal.MockMethodMetadata.registerByNameAndVarArgInfo]] before delegating to runtime creation; this feeds
2020
* `ReflectionUtils`/`ScalaMockHandler` with by-name, vararg and return metadata.
21+
*
22+
* ==Wrapper methods must be `inline` (Scala 3)==
23+
*
24+
* All `mock[T]` overloads are `inline` so that `T` is concrete at the macro expansion point. If you wrap `mock[T]` in your own helper, that helper must also be `inline`:
25+
*
26+
* {{{
27+
* // CORRECT – T is known at every call site
28+
* inline def lenientMock[T <: AnyRef: ClassTag]: T =
29+
* mock[T](Mockito.withSettings().strictness(Strictness.LENIENT))
30+
*
31+
* // WRONG – T is erased; by-name / vararg metadata will NOT be registered
32+
* def lenientMock[T <: AnyRef: ClassTag]: T =
33+
* mock[T](Mockito.withSettings().strictness(Strictness.LENIENT))
34+
* }}}
35+
*
36+
* Without `inline`, the compile-time macro [[org.mockito.internal.MockMethodMetadata.registerByNameAndVarArgInfo]] sees `T` as an abstract type variable and produces no output. At
37+
* runtime the `ScalaMockHandler` will not find the method in its cache, so vararg arrays won't be expanded and stubs on vararg methods will silently fail to match.
2138
*/
2239
private[mockito] trait MockCreator extends MockCreatorRuntime {
2340

common/src/main/scala-3/org/mockito/ReflectionUtils.scala

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import org.mockito.internal.MockMethodMetadata
55
import org.mockito.internal.MockMetadataCache
66
import org.mockito.invocation.InvocationOnMock
77

8-
import java.lang.reflect.{ Method, TypeVariable }
8+
import java.lang.reflect.Method
99
import scala.reflect.ClassTag
1010

1111
object ReflectionUtils {
@@ -39,24 +39,15 @@ object ReflectionUtils {
3939
* Scala 3 strategy (in order):
4040
* 1. primitive fast-path (`returnType.isPrimitive`)
4141
* 2. compile-time metadata from [[MockMetadataCache.getReturnsValueClass]]
42-
* 3. conservative JVM fallback:
43-
* - generic `TypeVariable` return => `false` (erased/ambiguous)
44-
* - otherwise `classOf[AnyVal].isAssignableFrom(returnType)`
42+
* 3. `false` — if the method is not in the cache it means the macro did not classify it as a value-class return, so it is a plain reference type.
4543
*
46-
* This mirrors Scala 2 intent ("identify returns that require value-like handling") while replacing runtime Scala reflection with compile-time metadata plus conservative runtime
47-
* checks.
44+
* NOTE: `classOf[AnyVal].isAssignableFrom(returnType)` cannot be used as a fallback in Scala 3 because `classOf[AnyVal]` compiles to `java.lang.Object` on the JVM, making the
45+
* check return `true` for every reference type and causing null stubs to be replaced by smart-null proxies.
4846
*/
4947
private[mockito] def returnsValueClass(invocation: InvocationOnMock): Boolean =
5048
val method = invocation.method
5149
val returnType = method.getReturnType
52-
returnType.isPrimitive || MockMetadataCache
53-
.getReturnsValueClass(method)
54-
.getOrElse {
55-
method.getGenericReturnType match {
56-
case _: TypeVariable[?] => false
57-
case _ => classOf[AnyVal].isAssignableFrom(returnType)
58-
}
59-
}
50+
returnType.isPrimitive || MockMetadataCache.getReturnsValueClass(method).getOrElse(false)
6051

6152
/**
6253
* Extract extra interfaces from an intersection/refined type at compile time.

common/src/test/scala-3/org/mockito/ReflectionUtilsTest.scala

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.mockito
22

33
import org.mockito.internal.MockMetadataCache
4+
import org.mockito.invocation.InvocationOnMock
45
import org.scalatest.matchers.should.Matchers
56
import org.scalatest.wordspec.AnyWordSpec
67

@@ -30,6 +31,14 @@ private[mockito] trait RUT_WithByNameAndFunction0 {
3031
* NOTE: plain ScalaTest assertions — NOT property-based tests.
3132
*/
3233
class ReflectionUtilsTest extends AnyWordSpec with Matchers {
34+
35+
/** Create a minimal InvocationOnMock whose getMethod() returns the given method. */
36+
private def makeInvocation(m: java.lang.reflect.Method): InvocationOnMock = {
37+
val inv = Mockito.mock(classOf[InvocationOnMock])
38+
Mockito.when(inv.getMethod()).thenReturn(m)
39+
inv
40+
}
41+
3342
private val primitiveMethod = classOf[RUT_WithPrimitiveBacked].getMethod("id")
3443
private val referenceMethod = classOf[RUT_WithReferenceBacked].getMethod("id")
3544
private val typeMemberMethod = classOf[RUT_WithTypeMemberBound].getMethod("id")
@@ -79,6 +88,20 @@ class ReflectionUtilsTest extends AnyWordSpec with Matchers {
7988
"be false for generic AnyVal bound method returning type variable" in {
8089
MockMetadataCache.getReturnsValueClass(genericBoundMethod) shouldBe Some(false)
8190
}
91+
92+
"make ReflectionUtils.returnsValueClass return true via cache" in {
93+
// JVM return type of RUT_UserId is String (not primitive) — exercises the cache path.
94+
ReflectionUtils.returnsValueClass(makeInvocation(referenceMethod)) shouldBe true
95+
}
96+
97+
"make ReflectionUtils.returnsValueClass return true via primitive fast-path" in {
98+
// JVM return type of RUT_IntId is int (primitive) — isPrimitive fires before cache lookup.
99+
ReflectionUtils.returnsValueClass(makeInvocation(primitiveMethod)) shouldBe true
100+
}
101+
102+
"make ReflectionUtils.returnsValueClass return false for a method absent from the cache" in {
103+
ReflectionUtils.returnsValueClass(makeInvocation(stringReturnMethod)) shouldBe false
104+
}
82105
}
83106

84107
"MockMetadataCache returnType registration" should {

macro-common/src/main/scala-3/org/mockito/internal/MockMetadataCache.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import java.util.concurrent.ConcurrentHashMap
88
*
99
* Keys and contract:
1010
* - `byName`: keyed by declaring `Class[?]`, stores `Seq[(Method, Set[Int])]`:
11-
* - outer `Seq`: one entry per method on that class with metadata
12-
* - inner `Set[Int]`: zero-based parameter indices that are by-name or vararg for that method
13-
* (example: for `def foo(x: => Int, ys: String*, z: () => String)`, indices are `Set(0, 1)`; `z` is plain `Function0`, so excluded)
11+
* - outer `Seq`: one entry per method on that class with metadata
12+
* - inner `Set[Int]`: zero-based parameter indices that are by-name or vararg for that method (example: for `def foo(x: => Int, ys: String*, z: () => String)`, indices are
13+
* `Set(0, 1)`; `z` is plain `Function0`, so excluded)
1414
* - `returnsValueClass`: keyed by `Method`, stores whether the declared Scala return type extends `AnyVal`.
1515
* - `returnType`: keyed by `Method`, stores the concrete declared return class when JVM erasure gives `Object` but the Scala source type is more specific (e.g. abstract type
1616
* aliases, path-dependent types).

macro-common/src/main/scala-3/org/mockito/internal/MockMethodMetadata.scala

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ import scala.reflect.ClassTag
1616
*/
1717
object MockMethodMetadata {
1818

19-
/** Entry point called from Scala 3 `MockCreator` during mock creation. */
19+
/**
20+
* Entry point called from Scala 3 `MockCreator` during mock creation.
21+
*
22+
* `T` '''must be a concrete type''' at the call site — the macro inspects `T`'s methods at compile time. This is guaranteed when every method in the call chain from user code
23+
* down to this call is `inline`. If any intermediate method is not `inline`, `T` will be an abstract type variable and the macro will produce no output, leaving the runtime
24+
* cache empty for that mock type.
25+
*/
2026
inline def registerByNameAndVarArgInfo[T](using classTag: ClassTag[T]): Unit = ${ registerImpl[T]('classTag) }
2127

2228
/** Macro implementation that inspects `T` and emits cache-registration runtime code. */
@@ -68,9 +74,23 @@ object MockMethodMetadata {
6874
case _ => resultTypeOf(methodType)
6975
}
7076

77+
def isPrimitiveType(normalized: TypeRepr): Boolean =
78+
normalized =:= TypeRepr.of[Boolean] ||
79+
normalized =:= TypeRepr.of[Byte] ||
80+
normalized =:= TypeRepr.of[Short] ||
81+
normalized =:= TypeRepr.of[Int] ||
82+
normalized =:= TypeRepr.of[Long] ||
83+
normalized =:= TypeRepr.of[Float] ||
84+
normalized =:= TypeRepr.of[Double] ||
85+
normalized =:= TypeRepr.of[Char] ||
86+
normalized =:= TypeRepr.of[Unit]
87+
7188
def returnsValueClassFor(normalized: TypeRepr): Boolean = {
7289
val returnSym = normalized.typeSymbol
73-
returnSym.isClassDef && returnSym != defn.AnyValClass && (normalized <:< TypeRepr.of[AnyVal])
90+
returnSym.isClassDef &&
91+
returnSym != defn.AnyValClass &&
92+
!isPrimitiveType(normalized) &&
93+
(normalized <:< TypeRepr.of[AnyVal])
7494
}
7595

7696
def returnTypeClassFor(normalized: TypeRepr): Option[Expr[Class[?]]] = {
@@ -109,27 +129,38 @@ object MockMethodMetadata {
109129
// Phase 1: compile-time analysis of T and its inherited declarations.
110130
def collectMethodInfos(tpe: TypeRepr, typeSymbol: Symbol): List[MethodInfo] = {
111131
val allMethods = {
112-
val seen = mutable.Set.empty[String] // track by fullName to avoid duplicates
132+
// Deduplicate by symbol identity: the same symbol may appear in both `declarations`
133+
// and `baseClasses.flatMap(_.declarations)`, so we use the symbol itself as the key.
134+
// Using `fullName` would wrongly deduplicate overloaded methods (e.g. `request()`,
135+
// `request(String...)` and `request(MediaType...)` all share the same fullName).
136+
val seen = mutable.Set.empty[Symbol]
113137
(typeSymbol.declarations ++ tpe.baseClasses.flatMap(_.declarations))
114-
.filter(symbol => symbol.isDefDef && !symbol.isClassConstructor && seen.add(symbol.fullName))
138+
.filter(symbol => symbol.isDefDef && !symbol.isClassConstructor && seen.add(symbol))
115139
}
116140

117141
allMethods.flatMap { methodSym =>
118142
val methodType = tpe.memberType(methodSym)
119143
val isJavaMethod = methodSym.flags.is(Flags.JavaDefined)
120144
val params = collectParams(methodType, 0)
121145
val byNameOrVarArgFields = params.collect { case param if param.isByName || param.isVarArg => param.index }.toSet
146+
val inferredReturnType = resultTypeOf(methodType).dealias.simplified
122147
val normalizedReturnType = declaredOrResolvedReturnType(methodSym, methodType).dealias.simplified
123-
val jvmParamTypes = params.map(param => jvmParamTypeExpr(param, isJavaMethod))
124-
Some(
125-
MethodInfo(
126-
methodSym.name,
127-
jvmParamTypes,
128-
byNameOrVarArgFields,
129-
returnsValueClassFor(normalizedReturnType),
130-
returnTypeClassFor(normalizedReturnType)
148+
val returnsValueClass = returnsValueClassFor(normalizedReturnType)
149+
val returnTypeClassOpt =
150+
if (!(normalizedReturnType =:= inferredReturnType)) returnTypeClassFor(normalizedReturnType)
151+
else None
152+
val jvmParamTypes = params.map(param => jvmParamTypeExpr(param, isJavaMethod))
153+
if (byNameOrVarArgFields.nonEmpty || returnsValueClass || returnTypeClassOpt.nonEmpty)
154+
Some(
155+
MethodInfo(
156+
methodSym.name,
157+
jvmParamTypes,
158+
byNameOrVarArgFields,
159+
returnsValueClass,
160+
returnTypeClassOpt
161+
)
131162
)
132-
)
163+
else None
133164
}
134165
}
135166

@@ -172,10 +203,8 @@ object MockMethodMetadata {
172203
resolvedMethodInfos.collect { case (method, indices, _, _) if indices.nonEmpty => (method, indices) }
173204
if (byNameInfos.nonEmpty) MockMetadataCache.registerByName(clazz, byNameInfos)
174205

175-
if (resolvedMethodInfos.nonEmpty)
176-
MockMetadataCache.registerReturnsValueClass(
177-
resolvedMethodInfos.map { case (method, _, returnsValueClass, _) => (method, returnsValueClass) }
178-
)
206+
val returnsValueClassInfos = resolvedMethodInfos.collect { case (method, _, true, _) => (method, true) }
207+
if (returnsValueClassInfos.nonEmpty) MockMetadataCache.registerReturnsValueClass(returnsValueClassInfos)
179208

180209
val returnTypeInfos = resolvedMethodInfos.collect { case (method, _, _, Some(returnTypeClass)) =>
181210
(method, returnTypeClass)

macro-common/src/test/scala-3/org/mockito/internal/MockMethodMetadataTest.scala

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ private trait MMT_MetadataTarget {
1515
def valueId: MMT_ValueId
1616
}
1717

18+
/** Trait with overloaded methods that share the same `fullName` — tests dedup-by-symbol fix. */
19+
private trait MMT_Overloaded {
20+
def request(): String
21+
def request(path: String): String
22+
def request(paths: String*): String // vararg — must be registered even though earlier overloads exist
23+
def request(path: => String): String // by-name — same fullName, different symbol; must also be registered
24+
def primitive(): Int
25+
}
26+
1827
private trait MMT_ExtraA
1928
private trait MMT_ExtraB
2029
private trait MMT_ExtraC
@@ -38,6 +47,33 @@ class MockMethodMetadataTest extends AnyWordSpec with Matchers {
3847
}
3948
}
4049

50+
"registerByNameAndVarArgInfo for overloaded methods" should {
51+
"register vararg and by-name overloads independently when all share the same fullName" in {
52+
MockMethodMetadata.registerByNameAndVarArgInfo[MMT_Overloaded]
53+
54+
val varargMethod = classOf[MMT_Overloaded].getMethod("request", classOf[Seq[?]])
55+
val byNameMethod = classOf[MMT_Overloaded].getMethod("request", classOf[scala.Function0[?]])
56+
val byNameInfos = MockMetadataCache.getByName(classOf[MMT_Overloaded])
57+
byNameInfos should not be empty
58+
byNameInfos.get.toMap.get(varargMethod) shouldBe Some(Set(0))
59+
byNameInfos.get.toMap.get(byNameMethod) shouldBe Some(Set(0))
60+
}
61+
62+
"not classify primitive return types as value-class returns" in {
63+
MockMethodMetadata.registerByNameAndVarArgInfo[MMT_Overloaded]
64+
65+
val primitiveMethod = classOf[MMT_Overloaded].getMethod("primitive")
66+
MockMetadataCache.getReturnsValueClass(primitiveMethod) shouldBe None
67+
}
68+
69+
"not store false returnsValueClass entries — only true entries go into the cache" in {
70+
MockMethodMetadata.registerByNameAndVarArgInfo[MMT_MetadataTarget]
71+
72+
val plainMethod = classOf[MMT_MetadataTarget].getMethods.find(_.getName == "plainFunction0").get
73+
MockMetadataCache.getReturnsValueClass(plainMethod) shouldBe None
74+
}
75+
}
76+
4177
"extraInterfacesImpl" should {
4278
"exclude the primary runtime class and return only extras for intersections" in {
4379
type Intersection = MMT_ExtraA & MMT_ExtraB & MMT_ExtraC

0 commit comments

Comments
 (0)