Skip to content

Commit 2903f99

Browse files
committed
Upgrade Scala 3 LTS to 3.3.8 and fix ambiguous shouldReturn/mustReturn overloads
1 parent a528642 commit 2903f99

5 files changed

Lines changed: 279 additions & 10 deletions

File tree

build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import scala.language.postfixOps
22

33
val currentScalaVersion = "2.13.18"
4-
val scala3Version = "3.3.7"
4+
val scala3Version = "3.3.8"
55

66
inThisBuild(
77
Seq(

core/src/main/scala-3/org/mockito/IdiomaticStubbing.scala

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,9 @@ trait IdiomaticStubbing extends IdiomaticStubbingRuntime {
1515
val called: Called.type = Called
1616

1717
extension [T](inline stubbing: T) {
18-
transparent inline def shouldReturn: ReturnActions[T] = WhenMacro.shouldReturn[T](stubbing).asInstanceOf[ReturnActions[T]]
19-
transparent inline def mustReturn: ReturnActions[T] = WhenMacro.shouldReturn[T](stubbing).asInstanceOf[ReturnActions[T]]
20-
transparent inline def returns: ReturnActions[T] = WhenMacro.shouldReturn[T](stubbing).asInstanceOf[ReturnActions[T]]
21-
transparent inline infix def shouldReturn(value: T): ScalaOngoingStubbing[T] =
22-
WhenMacro.shouldReturn[T](stubbing).asInstanceOf[ReturnActions[T]](value)
23-
transparent inline infix def mustReturn(value: T): ScalaOngoingStubbing[T] =
24-
WhenMacro.shouldReturn[T](stubbing).asInstanceOf[ReturnActions[T]](value)
18+
transparent inline def shouldReturn[V](inline value: V, inline values: V*): ScalaOngoingStubbing[?] = WhenMacro.returnsValue[T, V](stubbing, value, values*)
19+
transparent inline def mustReturn[V](inline value: V, inline values: V*): ScalaOngoingStubbing[?] = WhenMacro.returnsValue[T, V](stubbing, value, values*)
20+
transparent inline def returns[V](inline value: V, inline values: V*): ScalaOngoingStubbing[?] = WhenMacro.returnsValue[T, V](stubbing, value, values*)
2521

2622
transparent inline def shouldCall(crm: RealMethod.type): ScalaOngoingStubbing[T] = WhenMacro.shouldCallRealMethod[T](stubbing)(using org.scalactic.Prettifier.default)
2723
transparent inline def mustCall(crm: RealMethod.type): ScalaOngoingStubbing[T] = WhenMacro.shouldCallRealMethod[T](stubbing)(using org.scalactic.Prettifier.default)
@@ -46,6 +42,26 @@ trait IdiomaticStubbing extends IdiomaticStubbingRuntime {
4642
transparent inline def doesNothing(): Unit = DoSomethingMacro.doesNothing(stubbing)
4743
}
4844

45+
// More specific overloads for methods that return a function: they give a placeholder-lambda value
46+
// (e.g. `shouldReturn(_.toString)`) its expected type, which the generic `[V]` overload cannot infer.
47+
extension [I, O](inline stubbing: I => O) {
48+
transparent inline def shouldReturn(value: I => O, values: (I => O)*): ScalaOngoingStubbing[?] = WhenMacro.returnsValue[I => O, I => O](stubbing, value, values*)
49+
transparent inline def mustReturn(value: I => O, values: (I => O)*): ScalaOngoingStubbing[?] = WhenMacro.returnsValue[I => O, I => O](stubbing, value, values*)
50+
transparent inline def returns(value: I => O, values: (I => O)*): ScalaOngoingStubbing[?] = WhenMacro.returnsValue[I => O, I => O](stubbing, value, values*)
51+
}
52+
53+
// `PartialFunction` is a subtype of `Function1`, so without this more specific overload the one above
54+
// would widen a `{ case ... }` value to a total function, elaborating it as a plain lambda that then
55+
// fails with a `ClassCastException` when the (partial-function-returning) mock is invoked.
56+
extension [I, O](inline stubbing: PartialFunction[I, O]) {
57+
transparent inline def shouldReturn(value: PartialFunction[I, O], values: PartialFunction[I, O]*): ScalaOngoingStubbing[?] =
58+
WhenMacro.returnsValue[PartialFunction[I, O], PartialFunction[I, O]](stubbing, value, values*)
59+
transparent inline def mustReturn(value: PartialFunction[I, O], values: PartialFunction[I, O]*): ScalaOngoingStubbing[?] =
60+
WhenMacro.returnsValue[PartialFunction[I, O], PartialFunction[I, O]](stubbing, value, values*)
61+
transparent inline def returns(value: PartialFunction[I, O], values: PartialFunction[I, O]*): ScalaOngoingStubbing[?] =
62+
WhenMacro.returnsValue[PartialFunction[I, O], PartialFunction[I, O]](stubbing, value, values*)
63+
}
64+
4965
extension [R](v: R) {
5066
def willBe(r: Returned.type): ReturnedBy[R] = ReturnedBy[R](v)
5167
}

macro/src/main/scala-3/org/mockito/WhenMacro.scala

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,118 @@ object WhenMacro {
4343
buildActionWrapper[T]("org.mockito.IdiomaticMockitoBaseRuntime.ReturnActions", buildScalaFirstStubbing[T](stubbing))
4444
}
4545

46+
/**
47+
* Value-aware stubbing used by `shouldReturn`/`mustReturn`/`returns`.
48+
*
49+
* Taking the returned value(s) as a separate type parameter `V` (rather than unifying with the stubbed type `T`) lets this macro decide, by inspecting the two types, how to
50+
* build the stubbing:
51+
* - `V` conforms to `T` (normal/sub-typed value): stub at `T`.
52+
* - `V` numerically widens to `T` (e.g. `Long` method stubbed with an `Int`): stub at `T`, coercing the value with `toLong`/`toDouble`/... so the runtime value has the
53+
* method's return type instead of being boxed as the narrower type.
54+
* - `T` is a polymorphic type whose parameter defaulted (e.g. `Either[String, Resp[Any]]`) while `V` is more specific: stub at `V`, so the value's type drives inference.
55+
* - otherwise the stub is a genuine type error and is rejected with a clear message.
56+
*/
57+
transparent inline def returnsValue[T, V](inline stubbing: T, inline value: V, inline values: V*): ScalaOngoingStubbing[?] =
58+
${ returnsValueMacro[T, V]('stubbing, 'value, 'values) }
59+
60+
// Scala weak conformance is not a linear order: the main chain is Byte < Short < Int < Long < Float <
61+
// Double, while Char widens directly to Int (and wider) but is unrelated to Byte/Short. Each key maps
62+
// to the set of types it may widen to, so e.g. Byte -> Char is correctly *not* a widening.
63+
private val numericWidening: Map[String, Set[String]] = {
64+
val chain = List("scala.Byte", "scala.Short", "scala.Int", "scala.Long", "scala.Float", "scala.Double")
65+
val onChain = chain.tails.collect { case from :: wider => from -> wider.toSet }.toMap
66+
onChain + ("scala.Char" -> Set("scala.Int", "scala.Long", "scala.Float", "scala.Double"))
67+
}
68+
69+
def returnsValueMacro[T: Type, V: Type](stubbing: Expr[T], value: Expr[V], values: Expr[Seq[V]])(using Quotes): Expr[ScalaOngoingStubbing[?]] = {
70+
import quotes.reflect.*
71+
val transformed = doTransformInvocation(stubbing)
72+
val tT = TypeRepr.of[T].widen.dealias
73+
val tV = TypeRepr.of[V].widen.dealias
74+
75+
val numericWiden = numericWidening.get(tV.typeSymbol.fullName).exists(_.contains(tT.typeSymbol.fullName))
76+
77+
// Numeric literal narrowing (per the spec, mirrored by plain Scala assignment and the Scala 2 DSL):
78+
// an `Int` *constant* whose value is in the target's range may narrow to Byte/Short/Char. `Expr#value`
79+
// returns `Some` exactly for compile-time constants (literals and constant-folded `final val`s), so
80+
// out-of-range literals and non-constant `Int` values are still rejected, just like the compiler.
81+
def intConst(e: Expr[V]): Option[Int] = if (tV =:= TypeRepr.of[Int]) e.asExprOf[Int].value else None
82+
def inTargetRange(n: Int): Boolean = tT.typeSymbol.fullName match {
83+
case "scala.Byte" => n >= Byte.MinValue && n <= Byte.MaxValue
84+
case "scala.Short" => n >= Short.MinValue && n <= Short.MaxValue
85+
case "scala.Char" => n >= Char.MinValue && n <= Char.MaxValue
86+
case _ => false
87+
}
88+
val literalNarrow =
89+
intConst(value).exists(inTargetRange) && Varargs.unapply(values).exists(_.forall(intConst(_).exists(inTargetRange)))
90+
91+
// Treats `Any`/`Nothing` positions in the target as wildcards, so a polymorphic method whose
92+
// type parameter defaulted to `Any` accepts a more specific value, while genuinely unrelated
93+
// types (e.g. `Int` vs `String`) are rejected.
94+
def compatible(t: TypeRepr, v: TypeRepr): Boolean =
95+
(v <:< t) || (t =:= TypeRepr.of[Any]) || (t =:= TypeRepr.of[Nothing]) || {
96+
(t, v) match {
97+
case (AppliedType(c1, a1), AppliedType(c2, a2)) if c1 =:= c2 && a1.length == a2.length =>
98+
a1.zip(a2).forall((x, y) => compatible(x, y))
99+
case _ => false
100+
}
101+
}
102+
103+
// A single quote is emitted per expansion (rather than unifying several `if`/`else` branches),
104+
// so `transparent inline` can refine the result to the precise `ScalaOngoingStubbing[targetType]`.
105+
val boxed: Map[String, TypeRepr] = Map(
106+
"scala.Byte" -> TypeRepr.of[java.lang.Byte],
107+
"scala.Short" -> TypeRepr.of[java.lang.Short],
108+
"scala.Char" -> TypeRepr.of[java.lang.Character],
109+
"scala.Int" -> TypeRepr.of[java.lang.Integer],
110+
"scala.Long" -> TypeRepr.of[java.lang.Long],
111+
"scala.Float" -> TypeRepr.of[java.lang.Float],
112+
"scala.Double" -> TypeRepr.of[java.lang.Double],
113+
"scala.Boolean" -> TypeRepr.of[java.lang.Boolean]
114+
)
115+
// `Int` stubbed on a method returning e.g. `java.lang.Long` (typically Java interop): a Scala numeric
116+
// that weakly conforms to the target's primitive is widened and boxed, matching the Scala 2 DSL.
117+
// `boxWidenPrim` is the primitive to widen the value to before boxing (the value's own type when its
118+
// box already conforms, e.g. for a supertype target like `Number`).
119+
val boxWidenPrim: Option[String] = {
120+
val widenTargets = tV.typeSymbol.fullName :: numericWidening.getOrElse(tV.typeSymbol.fullName, Set.empty).toList
121+
widenTargets.find(p => boxed.get(p).exists(_ <:< tT))
122+
}
123+
val boxWiden = boxWidenPrim.isDefined
124+
125+
val numericCoerce: Option[String] = Option.when(numericWiden || literalNarrow)("to" + tT.typeSymbol.name)
126+
// `Any`/`Nothing` (e.g. `returns ???`): the value can't be statically re-typed, so cast at runtime.
127+
val runtimeCast = tV =:= TypeRepr.of[Any]
128+
val (targetType, castStubbing): (TypeRepr, Boolean) =
129+
if (tV <:< tT || numericWiden || literalNarrow || boxWiden || runtimeCast) (tT, false)
130+
else if (compatible(tT, tV)) (tV, true)
131+
else report.errorAndAbort(s"Cannot stub a method returning ${tT.show} with a value of type ${tV.show}")
132+
133+
targetType.asType match {
134+
case '[tgt] =>
135+
val whenArg: Expr[tgt] = if (castStubbing) '{ $transformed.asInstanceOf[tgt] } else transformed.asExprOf[tgt]
136+
def coerce(e: Expr[V]): Expr[tgt] =
137+
numericCoerce match {
138+
case Some(n) => Select.unique(e.asTerm, n).asExprOf[tgt]
139+
case _ if boxWiden =>
140+
// Widen to the target's primitive (a no-op when it equals the value's type), then box via `Any`.
141+
val prim = boxWidenPrim.get
142+
val widened: Expr[Any] =
143+
if (prim == tV.typeSymbol.fullName) e.asExprOf[Any]
144+
else Select.unique(e.asTerm, "to" + prim.stripPrefix("scala.")).asExprOf[Any]
145+
'{ $widened.asInstanceOf[tgt] }
146+
case _ if runtimeCast => '{ $e.asInstanceOf[tgt] }
147+
case _ => e.asExprOf[tgt]
148+
}
149+
val plainPassThrough = numericCoerce.isEmpty && !boxWiden && !runtimeCast
150+
val first: Expr[tgt] = coerce(value)
151+
val rest: Expr[Seq[tgt]] =
152+
if (plainPassThrough) values.asExprOf[Seq[tgt]]
153+
else '{ $values.map(x => ${ coerce('x) }) }
154+
'{ new ScalaFirstStubbing[tgt](org.mockito.Mockito.when[tgt]($whenArg)).thenReturn($first, $rest*) }
155+
}
156+
}
157+
46158
inline def shouldThrow[T](inline stubbing: T): Any =
47159
${ shouldThrowMacro[T]('stubbing) }
48160

scalatest/src/test/scala/user/org/mockito/IdiomaticStubbingTest.scala

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@ trait PolymorphicClient {
1515
def request[A](path: String)(implicit codec: PolymorphicCodec[A]): Either[String, PolymorphicResponse[A]]
1616
}
1717

18+
trait NumericReturns {
19+
def getByte: Byte
20+
def getShort: Short
21+
def getChar: Char
22+
def getInt: Int
23+
def getLong: Long
24+
def getDouble: Double
25+
}
26+
27+
trait BoxedNumericReturns {
28+
def getBoxedInteger: java.lang.Integer
29+
def getBoxedLong: java.lang.Long
30+
def getBoxedDouble: java.lang.Double
31+
def getBoxedByte: java.lang.Byte
32+
}
33+
1834
class IdiomaticStubbingTest extends AnyWordSpec with Matchers with ArgumentMatchersSugar with IdiomaticMockitoTestSetup with IdiomaticStubbing {
1935

2036
forAll(scenarios) { (testDouble, orgDouble, foo) =>
@@ -192,6 +208,37 @@ class IdiomaticStubbingTest extends AnyWordSpec with Matchers with ArgumentMatch
192208
org.iReturnAFunction(3)(3) shouldBe "9"
193209
}
194210

211+
"stub a method that returns a partial function with a case-block literal" in {
212+
val org = orgDouble()
213+
214+
org.iReturnAPartialFunction(*) shouldReturn { case i => i.toString }
215+
216+
org.iReturnAPartialFunction(0)(42) shouldBe "42"
217+
}
218+
219+
"set consecutive return values when passed more than one value" in {
220+
val org = orgDouble()
221+
222+
org.doSomethingWithThisInt(*) shouldReturn (1, 2, 3)
223+
224+
org.doSomethingWithThisInt(0) shouldBe 1
225+
org.doSomethingWithThisInt(0) shouldBe 2
226+
org.doSomethingWithThisInt(0) shouldBe 3
227+
org.doSomethingWithThisInt(0) shouldBe 3
228+
}
229+
230+
"stub a tuple return value (via extra parens or the -> arrow, since the DSL takes consecutive values as varargs)" in {
231+
val org = orgDouble()
232+
233+
org.returnsATuple shouldReturn ((1, "mocked"))
234+
235+
org.returnsATuple shouldBe (1, "mocked")
236+
237+
org.returnsATuple shouldReturn 2 -> "mocked again"
238+
239+
org.returnsATuple shouldBe (2, "mocked again")
240+
}
241+
195242
"doStub a value class return value" in {
196243
val org = orgDouble()
197244

@@ -365,7 +412,7 @@ class IdiomaticStubbingTest extends AnyWordSpec with Matchers with ArgumentMatch
365412
}
366413

367414
"mock" should {
368-
"infer the type parameter for shouldReturn on a polymorphic method from the returned value" in {
415+
"infer the type parameter for `shouldReturn` on a polymorphic method from the returned value" in {
369416
implicit object StringCodec extends PolymorphicCodec[String]
370417

371418
def stubResponse[A](
@@ -382,7 +429,7 @@ class IdiomaticStubbingTest extends AnyWordSpec with Matchers with ArgumentMatch
382429
client.request[String]("path") shouldBe expected
383430
}
384431

385-
"infer the type parameter for mustReturn on a polymorphic method from the returned value" in {
432+
"infer the type parameter for `mustReturn` on a polymorphic method from the returned value" in {
386433
implicit object StringCodec extends PolymorphicCodec[String]
387434

388435
def stubResponse[A](
@@ -399,6 +446,96 @@ class IdiomaticStubbingTest extends AnyWordSpec with Matchers with ArgumentMatch
399446
client.request[String]("path") shouldBe expected
400447
}
401448

449+
"infer the type parameter for `returns` on a polymorphic method from the returned value" in {
450+
implicit object StringCodec extends PolymorphicCodec[String]
451+
452+
def stubResponse[A](
453+
client: PolymorphicClient,
454+
response: Either[String, PolymorphicResponse[A]]
455+
)(implicit codec: PolymorphicCodec[A]): org.mockito.stubbing.ScalaOngoingStubbing[Either[String, PolymorphicResponse[A]]] =
456+
client.request("path")(*) returns response
457+
458+
val client = mock[PolymorphicClient]
459+
val expected = Right(PolymorphicResponse("ok")): Either[String, PolymorphicResponse[String]]
460+
461+
stubResponse(client, expected)
462+
463+
client.request[String]("path") shouldBe expected
464+
}
465+
466+
"widen a narrower numeric value to the method's return type" in {
467+
val m = mock[NumericReturns]
468+
469+
m.getLong shouldReturn 1
470+
m.getDouble mustReturn 2
471+
m.getInt returns 3
472+
473+
m.getLong shouldBe 1L
474+
m.getDouble shouldBe 2.0
475+
m.getInt shouldBe 3
476+
}
477+
478+
"narrow an Int constant literal in range to a Byte/Short/Char return type" in {
479+
val m = mock[NumericReturns]
480+
481+
m.getByte shouldReturn 5
482+
m.getShort mustReturn 6
483+
m.getChar returns 65
484+
485+
m.getByte shouldBe 5.toByte
486+
m.getShort shouldBe 6.toShort
487+
m.getChar shouldBe 'A'
488+
}
489+
490+
"reject narrowing an out-of-range or non-constant value (as plain Scala does)" in {
491+
"val m = mock[NumericReturns]; m.getByte shouldReturn 5000" shouldNot typeCheck
492+
"val m = mock[NumericReturns]; val i = 5; m.getByte shouldReturn i" shouldNot typeCheck
493+
"val m = mock[NumericReturns]; m.getInt shouldReturn 5L" shouldNot typeCheck
494+
}
495+
496+
"widen a non-constant numeric value following Scala's weak conformance" in {
497+
val m = mock[NumericReturns]
498+
499+
val b: Byte = 1
500+
val ch: Char = 'A'
501+
m.getShort shouldReturn b // Byte widens to Short
502+
m.getInt mustReturn ch // Char widens to Int
503+
504+
m.getShort shouldBe 1.toShort
505+
m.getInt shouldBe 65
506+
}
507+
508+
"reject widening that Scala's weak conformance does not allow (e.g. Byte to Char)" in {
509+
"val m = mock[NumericReturns]; val b: Byte = 1; m.getChar shouldReturn b" shouldNot typeCheck
510+
"val m = mock[NumericReturns]; val s: Short = 1; m.getChar shouldReturn s" shouldNot typeCheck
511+
}
512+
513+
"widen and box a numeric value to a Java boxed return type" in {
514+
val m = mock[BoxedNumericReturns]
515+
516+
m.getBoxedInteger shouldReturn 5 // Int -> java.lang.Integer
517+
m.getBoxedLong mustReturn 6 // Int widened to Long, boxed
518+
m.getBoxedDouble returns 7 // Int widened to Double, boxed
519+
520+
m.getBoxedInteger shouldBe (5: java.lang.Integer)
521+
m.getBoxedLong shouldBe (6L: java.lang.Long)
522+
m.getBoxedDouble shouldBe (7.0: java.lang.Double)
523+
}
524+
525+
"widen and box a non-constant numeric value to a Java boxed return type" in {
526+
val m = mock[BoxedNumericReturns]
527+
528+
val i = 8
529+
m.getBoxedLong returns i // non-constant Int widened to Long, boxed
530+
531+
m.getBoxedLong shouldBe (8L: java.lang.Long)
532+
}
533+
534+
"reject boxed numeric stubbing that Scala does not allow (literal narrowing or narrowing)" in {
535+
"val m = mock[BoxedNumericReturns]; m.getBoxedByte shouldReturn 5" shouldNot typeCheck
536+
"val m = mock[BoxedNumericReturns]; m.getBoxedInteger shouldReturn 5L" shouldNot typeCheck
537+
}
538+
402539
"stub a map" in {
403540
val mocked = mock[Map[String, String]]
404541
mocked(*) returns "123"

scalatest/src/test/scala/user/org/mockito/TestModel.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,14 @@ class Org {
8282

8383
def returnBar: Bar = new Bar
8484

85+
def returnsATuple: (Int, String) = (-1, "not mocked")
86+
8587
def highOrderFunction(f: Int => String): String = "not mocked"
8688

8789
def iReturnAFunction(v: Int): Int => String = i => (i * v).toString
8890

91+
def iReturnAPartialFunction(v: Int): PartialFunction[Int, String] = { case i => (i * v).toString }
92+
8993
def iBlowUp(v: Int, v2: String): String = throw new IllegalArgumentException("I was called!")
9094

9195
def iHaveTypeParamsAndImplicits[A, B](a: A, b: B)(implicit v3: Implicit[A]): String = "not mocked"

0 commit comments

Comments
 (0)