spock icon indicating copy to clipboard operation
spock copied to clipboard

IllegalAccessException when mocking package-private Spring controller

Open natix643 opened this issue 7 years ago • 7 comments

Issue description

Hello, I'm trying to mock a Spring controller using the new @SpringBean annotation and then call the mapped method using TestRestTemplate (also tried MockMvc with same results). This works only if the controller java class is public, but the test fails with IllegalAccessException if I change the access to package-private.

Bellow is a minimal Spring Boot project (I can provide a repo with an easy-to-run version if needed). I also tried using different tool versions, such as JDK 11, Spring Boot 2.0, Groovy 2.4 or adding a cglib dependency instead of relying on the default Byte Buddy with no difference.

How to reproduce

main/java:

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

@RestController
/* public */ class HelloController { // works with public, fails without
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}

test/groovy

@SpringBootTest(webEnvironment = RANDOM_PORT)
class HelloControllerTest extends Specification {

    @SpringBean
    HelloController controller = Mock()

    @Autowired
    TestRestTemplate rest

    def "should mock it"() {
        given:
        controller.hello() >> "goodbye"

        expect:
        rest.getForObject("/hello", String) == "goodbye"
    }
}

Resulting exception:

java.lang.IllegalAccessException: Class org.spockframework.spring.mock.DelegatingInterceptor can not access a member of class mock.HelloController with modifiers "public"
	at sun.reflect.Reflection.ensureMemberAccess(Reflection.java:102) ~[na:1.8.0_144]
	at java.lang.reflect.AccessibleObject.slowCheckMemberAccess(AccessibleObject.java:296) ~[na:1.8.0_144]
	at java.lang.reflect.AccessibleObject.checkAccess(AccessibleObject.java:288) ~[na:1.8.0_144]
	at java.lang.reflect.Method.invoke(Method.java:491) ~[na:1.8.0_144]
	at org.spockframework.spring.mock.DelegatingInterceptor.intercept(DelegatingInterceptor.java:53) ~[spock-spring-1.2-groovy-2.5.jar:1.2]
	at org.spockframework.mock.runtime.ByteBuddyInterceptorAdapter.interceptNonAbstract(ByteBuddyInterceptorAdapter.java:35) ~[spock-core-1.2-groovy-2.5.jar:1.2]
	at mock.HelloController$SpockMock$280894713.hello(Unknown Source) ~[na:na]
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_144]
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_144]
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_144]
	at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_144]
	at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:215) ~[spring-web-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:142) ~[spring-web-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:102) ~[spring-webmvc-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895) ~[spring-webmvc-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:800) ~[spring-webmvc-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1038) ~[spring-webmvc-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:942) ~[spring-webmvc-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:998) ~[spring-webmvc-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:890) ~[spring-webmvc-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:634) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:875) ~[spring-webmvc-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:741) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) ~[tomcat-embed-websocket-9.0.12.jar:9.0.12]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:99) ~[spring-web-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) ~[spring-web-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:92) ~[spring-web-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) ~[spring-web-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.springframework.web.filter.HiddenHttpMethodFilter.doFilterInternal(HiddenHttpMethodFilter.java:93) ~[spring-web-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) ~[spring-web-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:200) ~[spring-web-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) ~[spring-web-5.1.2.RELEASE.jar:5.1.2.RELEASE]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:199) ~[tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:96) [tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:490) [tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:139) [tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92) [tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) [tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343) [tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:408) [tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:66) [tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:770) [tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1415) [tomcat-embed-core-9.0.12.jar:9.0.12]
	at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) [tomcat-embed-core-9.0.12.jar:9.0.12]
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) [na:1.8.0_144]
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) [na:1.8.0_144]
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) [tomcat-embed-core-9.0.12.jar:9.0.12]
	at java.lang.Thread.run(Thread.java:748) [na:1.8.0_144]

Additional Environment information

Java/JDK

jdk1.8.0_144

Build tool version

Gradle

4.10.2

Build-tool dependencies used

Gradle

plugins {
    id 'java'
    id 'groovy'
    id 'org.springframework.boot' version '2.1.0.RELEASE'
    id 'io.spring.dependency-management' version '1.0.6.RELEASE'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.spockframework:spock-spring:1.2-groovy-2.5'
}

natix643 avatar Nov 01 '18 18:11 natix643

Yes, this is problem with the way @SpringBeans work, i.e. you actually have two mocks, one in the Spring Context and the other you create in the Specification. The mock in the Spring Context will find the mock of the Specification and delegate all calls to it. This is where the package-private access violation happens.

As a workaround you can use the old DetachedMockFactory style as this doesn't use delegation and thus will not suffer from package private visibility issues. Albeit being a bit more clunky in the usage.

But as far as I understand this is only a temporary workaround, if you switch to the module system those package private boundaries will be stronger enforced and not mockable anymore. Correct me if I'm wrong @raphw.

leonard84 avatar Nov 02 '18 13:11 leonard84

Yes, for overriding a package-private method, the subclass must be defined by the same class loader. Just as with classes, packages are just considered equal if they are by the same class loader. If you cannot inject a class into a class loader, you cannot mock package-private types and methods.

raphw avatar Nov 03 '18 22:11 raphw

Could you overcome this by for example calling setAccessible(true) on the given class? Spring generally has no problem with reflectively calling package-private (or perhaps private) classes and methods, let alone Groovy. Not sure what role different classloaders have in this though.

Regarding the Java 9 module system, I don't think this is that much of an issue, because the current approach still boils down to declaring your modules as open (either to a specific framework module, or simply to the whole world), as there is no easy other way how to make these reflection-based frameworks work.

natix643 avatar Nov 05 '18 16:11 natix643

The problem is that the related class loader methods are always modularized since Java 9. At the same time, setting methods accessible has no effect on the byte code dispatch. Other than that, if a module is open, Byte Buddy already offers means to inject a class.

raphw avatar Nov 05 '18 17:11 raphw

I was wondering, shouldn't the line .transform(Transformer.ForMethod.withModifiers(SynchronizationState.PLAIN, Visibility.PUBLIC)) (org/spockframework/mock/runtime/ByteBuddyMockFactory.java:66) overwrite every method as public?

leonard84 avatar Nov 05 '18 22:11 leonard84

Yes, but that does not matter if the base method is package-private which determines the virtual dispatch scope.

raphw avatar Nov 06 '18 22:11 raphw

Workaround to mock package-scoped class

@WebMvcTest(controllers = Controller)
class ControllerSpec extends Specification {

  @TestConfiguration
  static class Config {
    @Bean
    Service service() {
      new DetachedMockFactory().Mock(Service)
    }
  }

  @Autowired
  Service service
}   

kSzajo avatar May 11 '23 12:05 kSzajo