fmonniot / scala3mock

Mocking framework for Scala 3
https://francois.monniot.eu/scala3mock
Other
19 stars 1 forks source link

Mocking methods with default parameters fails #53

Closed NPCRUS closed 1 week ago

NPCRUS commented 2 weeks ago

Cheers @fmonniot. First of all - thank you for great work on this library, it was a bliss to find it. Now to the point: I think mocking methods with default parameters are currently broken with following error: value mocks cannot be accessed as a member of (Service2Mock.this : Service2Mock) from class Service2. [error] | private value mocks can only be accessed from class Service2Mock. I've made a smallest reproducible example of this issue:

import eu.monniot.scala3mock.ScalaMocks.*

object Test {

  class Service {
    def test(a: Int): Int = ???
  }

  class Service2 {
    def test(a: Int = 0): Int = ???
  }

  withExpectations() {
    val mocked1 = mock[Service]
    val mocked2 = mockWithDebuggingOutput[Service2]

    true
  }
}

here is the debugging output together with error:

Mocking type ???.Test.Service2
Generated code:
Tree Code: {
  class Service2Mock extends ???.Test.Service2 with eu.monniot.scala3mock.context.Mock {
    private val mocks: scala.collection.immutable.Map[scala.Predef.String, eu.monniot.scala3mock.functions.MockFunction] = scala.Predef.Map.from[java.lang.String, eu.monniot.scala3mock.functions.MockFunction](new scala.Tuple2[java.lang.String, eu.monniot.scala3mock.functions.MockFunction]("test", new eu.monniot.scala3mock.functions.MockFunction1[scala.Int, scala.Int](contextual$1, "<Test.scala#L21> Service2.test")(eu.monniot.scala3mock.Default.given_Default_Int)), new scala.Tuple2[java.lang.String, eu.monniot.scala3mock.functions.MockFunction]("test$default$1-0", new eu.monniot.scala3mock.functions.MockFunction0[scala.Int @scala.annotation.unchecked.uncheckedVariance](contextual$1, "<Test.scala#L21> Service2.test$default$1")(eu.monniot.scala3mock.Default.given_Default_Int)), new scala.Tuple2[java.lang.String, eu.monniot.scala3mock.functions.MockFunction]("test$default$1-1", new eu.monniot.scala3mock.functions.MockFunction0[scala.Int @scala.annotation.unchecked.uncheckedVariance](contextual$1, "<Test.scala#L21> Service2.test$default$1")(eu.monniot.scala3mock.Default.given_Default_Int)))
    def accessMockFunction(name: java.lang.String): eu.monniot.scala3mock.functions.MockFunction = Service2Mock.this.mocks.apply(name)
    override def test(a: scala.Int): scala.Int = Service2Mock.this.mocks.apply("test").asInstanceOf[eu.monniot.scala3mock.functions.MockFunction1[scala.Int, scala.Int]].apply(a)
    override def test$default$1: scala.Int @scala.annotation.unchecked.uncheckedVariance = Service2Mock.this.mocks.apply("test$default$1-0").asInstanceOf[eu.monniot.scala3mock.functions.MockFunction0[scala.Int @scala.annotation.unchecked.uncheckedVariance]].apply()
    def test$default$1: scala.Int @scala.annotation.unchecked.uncheckedVariance = Service2Mock.this.mocks.apply("test$default$1-1").asInstanceOf[eu.monniot.scala3mock.functions.MockFunction0[scala.Int @scala.annotation.unchecked.uncheckedVariance]].apply()
  }

  (new Service2Mock(): ???.Test.Service2 & eu.monniot.scala3mock.context.Mock)
}
[error] -- [E173] Reference Error: ???/Test.scala:22:18 
[error] 22 |    val mocked2 = mockWithDebuggingOutput[Service2]
[error]    |                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[error]    |value mocks cannot be accessed as a member of (Service2Mock.this : Service2Mock) from class Service2.
[error]    |  private value mocks can only be accessed from class Service2Mock.
[error]    |----------------------------------------------------------------------------
[error]    |Inline stack trace
[error]    |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
[error]    |This location contains code that was inlined from Test.scala:22

If you don't have time, let me know - I'm willing to take a stab at this issue myself

fmonniot commented 2 weeks ago

Hello there,

Ooh, that's a fun one. Indeed I never had to mock a method with a default value and so it's not implemented. It's interesting to see how Scala hack around the fact that default values do not exist in Java land.

Even more interesting is how this is used by the compiler. If we print the AST of a trait with a default method (for example with PrintAst[TestDefaultParameters]) we see the following:

@scala.annotation.internal.SourceFile("core/src/test/scala/fixtures/TestDefaultParameters.scala") trait TestDefaultParameters() extends java.lang.Object {
  def foo(bar: scala.Int): scala.Predef.String
  def foo$default$1: scala.Int @scala.annotation.unchecked.uncheckedVariance = 9
}

So it seems like the compilere store the default parameters in methods alongside the main one and use a methodName$default$position scheme to do so. We can also see how the compiler uses those synthetic methods with

PrintAst {
    val t = new TestDefaultParameters {}

    t.foo()
}

which prints

{
  val t: fixtures.TestDefaultParameters = {
    final class $anon() extends fixtures.TestDefaultParameters

    (new $anon(): fixtures.TestDefaultParameters)
  }
  t.foo(t.foo$default$1)
}

So a priori detecting if a method is generated by the compiler or not should be enough. In those cases we simply ignore them and let the compiler do its magic. Lemme see if I can find something.

fmonniot commented 2 weeks ago

I have merged a PR that, I believe, should solve your issue. It's available as a snapshot (version 0.6.1+9-5f0d9d1c-SNAPSHOT) if you want to give it a shot and let me know if that does the trick.

NPCRUS commented 2 weeks ago

@fmonniot thanks for such a promt response and fix! I'm not sure where can I get the snapshot from(or how): i get this in sbt: [error] not found: https://repo1.maven.org/maven2/eu/monniot/scala3mock_3/0.6.1+9-5f0d9d1c-SNAPSHOT/scala3mock_3-0.6.1+9-5f0d9d1c-SNAPSHOT.pom the maven repo also doesn't contain any snapshots

fmonniot commented 2 weeks ago

Ah yes, snapshots aren't publish to maven central.

You'll have to temporarily add resolvers += Resolver.sonatypeRepo("snapshots") to your project's resolvers.

NPCRUS commented 1 week ago
[error] (ssExtractDependencies) sbt.librarymanagement.ResolveException: Error downloading eu.monniot:scala3mock_3:0.6.1+9-5f0d9d1c-SNAPSHOT
[error]   Not found
[error]   Not found
[error]   not found: ???\.ivy2\local\eu.monniot\scala3mock_3\0.6.1+9-5f0d9d1c-SNAPSHOT\ivys\ivy.xml
[error]   not found: https://repo1.maven.org/maven2/eu/monniot/scala3mock_3/0.6.1+9-5f0d9d1c-SNAPSHOT/scala3mock_3-0.6.1+9-5f0d9d1c-SNAPSHOT.pom
[error]   not found: https://oss.sonatype.org/content/repositories/snapshot/eu/monniot/scala3mock_3/0.6.1+9-5f0d9d1c-SNAPSHOT/scala3mock_3-0.6.1+9-5f0d9d1c-SNAPSHOT.pom
[error] Total time: 3 s, completed Jun 23, 2024, 3:13:36 PM

although:

[info] downloading https://oss.sonatype.org/content/repositories/snapshot/eu/monniot/scala3mock_3/0.6.1+9-5f0d9d1c-SNAPSHOT/scala3mock_3-0.6.1+9-5f0d9d1c-SNAPSHOT.pom
[info] downloaded https://oss.sonatype.org/content/repositories/snapshot/eu/monniot/scala3mock_3/0.6.1+9-5f0d9d1c-SNAPSHOT/scala3mock_3-0.6.1+9-5f0d9d1c-SNAPSHOT.pom
[info] downloading https://oss.sonatype.org/content/repositories/snapshot/eu/monniot/scala3mock_3/0.6.1+9-5f0d9d1c-SNAPSHOT/scala3mock_3-0.6.1+9-5f0d9d1c-SNAPSHOT.pom.sha1
[info] downloaded https://oss.sonatype.org/content/repositories/snapshot/eu/monniot/scala3mock_3/0.6.1+9-5f0d9d1c-SNAPSHOT/scala3mock_3-0.6.1+9-5f0d9d1c-SNAPSHOT.pom.sha1
[info] downloading https://oss.sonatype.org/content/repositories/snapshot/eu/monniot/scala3mock_3/0.6.1+9-5f0d9d1c-SNAPSHOT/maven-metadata.xml
[info] downloaded https://oss.sonatype.org/content/repositories/snapshot/eu/monniot/scala3mock_3/0.6.1+9-5f0d9d1c-SNAPSHOT/maven-metadata.xml
[info] downloading https://oss.sonatype.org/content/repositories/snapshot/eu/monniot/scala3mock_3/0.6.1+9-5f0d9d1c-SNAPSHOT/maven-metadata.xml.sha1
[info] downloaded https://oss.sonatype.org/content/repositories/snapshot/eu/monniot/scala3mock_3/0.6.1+9-5f0d9d1c-SNAPSHOT/maven-metadata.xml.sha1

sorry, I don't really know how this snapshot extra resolver logic in sbt suppose to work this is my sbt file:

ThisBuild / version := "0.1.0-SNAPSHOT"

ThisBuild / scalaVersion := "3.4.2"

lazy val root = (project in file("."))
  .settings(
    name := "soundless-paygrond",
    resolvers += Resolver.sonatypeRepo("snapshot") ,
    libraryDependencies ++= Seq(
      "dev.soundness" % "wisteria-core" % "0.3.0",
      "dev.soundness" % "contingency-core" % "0.1.0",
      "eu.monniot" % "scala3mock" % "0.6.1+9-5f0d9d1c-SNAPSHOT"
    ),
  )
fmonniot commented 1 week ago

The SBT configuration should work, you are just missing a s at the end of snapshot.

NPCRUS commented 1 week ago

it's the same result with snapshots

fmonniot commented 1 week ago

Weird, I can use the snapshot without issue with scastie: https://scastie.scala-lang.org/ud4DIkrwTgq4zhr0HMgbgA. Maybe something else is going on?

NPCRUS commented 1 week ago

I love sbt, it was the %% in the first part of the dependency. I tested it, it works perfectly. Thank you very much again for promt reaction. I guess I'm gonna look out for the next release then!

NPCRUS commented 1 week ago

Hello again @fmonniot. Unfortunatelly I think we only scrapped the surface of this issue:

class Service {
    def test(account: Int, default: Int = 0): Int = ???
}

putting default parameter into second position and so forth returns this issue again. debug output:

Tree Code: {
  class ServiceMock extends de.spendit.commons.Test.Service with eu.monniot.scala3mock.context.Mock {
    private val mocks: scala.collection.immutable.Map[scala.Predef.String, eu.monniot.scala3mock.functions.MockFunction] = scala.Predef.Map.from[java.lang.String, eu.monniot.scala3mock.functions.MockFunction](new scala.Tuple2[java.lang.String, eu.monniot.scala3mock.functions.MockFunction]("create$default$2-0", new eu.monniot.scala3mock.functions.MockFunction0[scala.Int @scala.annotation.unchecked.uncheckedVariance](contextual$1, "<Test.scala#L19> Service.create$default$2")(eu.monniot.scala3mock.Default.given_Default_Int)), new scala.Tuple2[java.lang.String, eu.monniot.scala3mock.functions.MockFunction]("create$default$2-1", new eu.monniot.scala3mock.functions.MockFunction0[scala.Int @scala.annotation.unchecked.uncheckedVariance](contextual$1, "<Test.scala#L19> Service.create$default$2")(eu.monniot.scala3mock.Default.given_Default_Int)), new scala.Tuple2[java.lang.String, eu.monniot.scala3mock.functions.MockFunction]("create", new eu.monniot.scala3mock.functions.MockFunction2[scala.Int, scala.Int, scala.Int](contextual$1, "<Test.scala#L19> Service.create")(eu.monniot.scala3mock.Default.given_Default_Int)))
    def accessMockFunction(name: java.lang.String): eu.monniot.scala3mock.functions.MockFunction = ServiceMock.this.mocks.apply(name)
    def create$default$2: scala.Int @scala.annotation.unchecked.uncheckedVariance = ServiceMock.this.mocks.apply("create$default$2-0").asInstanceOf[eu.monniot.scala3mock.functions.MockFunction0[scala.Int @scala.annotation.unchecked.uncheckedVariance]].apply()
    override def create$default$2: scala.Int @scala.annotation.unchecked.uncheckedVariance = ServiceMock.this.mocks.apply("create$default$2-1").asInstanceOf[eu.monniot.scala3mock.functions.MockFunction0[scala.Int @scala.annotation.unchecked.uncheckedVariance]].apply()
    override def create(account: scala.Int, default: scala.Int): scala.Int = ServiceMock.this.mocks.apply("create").asInstanceOf[eu.monniot.scala3mock.functions.MockFunction2[scala.Int, scala.Int, scala.Int]].apply(account, default)
  }

  (new ServiceMock(): de.spendit.commons.Test.Service & eu.monniot.scala3mock.context.Mock)
}
fmonniot commented 1 week ago

Oh that's interesting. I misunderstood how the compiler assign the names. It's actually using the position of the parameter as the indice.

https://github.com/fmonniot/scala3mock/pull/57 should fix this. A snapshot containing this fix is available with the version 0.6.2+4-83ca10ea-SNAPSHOT.

fmonniot commented 1 week ago

I have published 0.6.3 if you want to move off the snapshot channel.