mockito-scala icon indicating copy to clipboard operation
mockito-scala copied to clipboard

by-name parameters incorrectly evaluated when mocking

Open bpatters opened this issue 2 years ago • 1 comments

I've provided a very simple sbt project that reproduces this problem. by-name-repro.zip

In the reproduction project, similar to my actual scenario but simplified, the scenario is as follows: I have a class we can call DataManager that I want to test. It's methods all follow a similar pattern of:

class DataManager {

  /**
    * Perform admin operations if the user is allowed to perform them
    * @param data  admin data
    * @param securityPolicy the security policy to check users access
    * @param userRole the current users role
    * @return
    */
  def doAdminStuff(
    data: String
  )(
    implicit securityPolicy: SecurityPolicy,
    userRole:                UserRole
  ): Future[Int] =
    securityPolicy.withRole(UserRole.ADMIN) {
      println("Performing admin operation")
      Future(1)
    }
}

The implicit Security policy is what I'm mocking in my tests and then testing a normal instance of the Data Manager The security policy uses curried operations of the following form:

  /**
    * Run the specified operation only if the expectedRole is equal to the users role
    * @param expectedRole the required role to execute the operation
    * @param operation the operation to perform
    * @param userRole the users actual role
    * @return the result of the operation or throws an exception if unauthorized
    */
  def withRole(expectedRole: UserRole)(operation: => Future[Int])(implicit userRole: UserRole) =
    if (expectedRole == userRole)
      operation
    else
      throw new Exception(s"must have $expectedRole to perform this operation")

The operation should only ever be evaluated if the security check passes.

The following tests will fail because the operation is evaluated even though the security policy's mocked answer is executed correctly:

  test("Admin operation should not be called with user role") {
    implicit val securityPolicy: SecurityPolicy = mock[SecurityPolicy]
    val dataManager = new DataManager()

    // by-name parameter evaluation are implemented by wrapping in a Function0
    { (role: UserRole, operation: Function0[Future[Int]], userRole: UserRole) =>
      throw new Exception("Access denied")
    } willBe answered by securityPolicy.withRole(eqTo(UserRole.ADMIN))(any[Future[Int]])(
      any[UserRole]
    )
    intercept[Exception] {
      implicit val userRole: UserRole = UserRole.USER
      Await.result(dataManager.doAdminStuff("test"), Duration.Inf)
    }
    assert(dataManager.adminOperationCount == 0)
  }

However if I wrap the DataManager instance in a spy then the same test will pass as the operation isn't executed:

  test("using spy, Admin operation should not be called with user") {
    implicit val securityPolicy: SecurityPolicy = mock[SecurityPolicy]
    val dataManager = spy(new DataManager())

   // by-name parameter evaluation are implemented by wrapping in a Function0
    { (role: UserRole, operation: Function0[Future[Int]], userRole: UserRole) =>
      throw new Exception("Access denied")
    } willBe answered by securityPolicy.withRole(eqTo(UserRole.ADMIN))(any[Future[Int]])(
      any[UserRole]
    )
    doCallRealMethod().when(dataManager).doAdminStuff(anyString)(any[SecurityPolicy], any[UserRole])
    doCallRealMethod().when(dataManager).adminOperationCount

    intercept[Exception] {
      implicit val userRole: UserRole = UserRole.USER
      Await.result(dataManager.doAdminStuff("test"), Duration.Inf)
    }
    assert(dataManager.adminOperationCount == 0)
  }

bpatters avatar Apr 21 '22 09:04 bpatters

Sorry, lost track of this, will try to check it over the weekend

ultrasecreth avatar May 11 '22 14:05 ultrasecreth