mockitowrapperspy

How can Mockito.spy() return the same type while adding spying behavior?


I suspected the Mockito.spy() returns a wrapper around the copy of the parameter object, and the type of this wrapper is a generated sub type of the wrapped object. Then the spying functionality could be in the outer part of the wrapper, as generally in the case of a proxy object implementing some aspect.

However, it looks like not to be correct. The type of the wrapper is exactly the same as that of the wrapped object:

        List<String> list = new ArrayList<>();
        List<String> spyList = spy(list);

        assertThat(spyList.getClass()).isSameAs(list.getClass());

But then where is the spying functionality implemented? If spyList is an ArrayList then how will Mockito be notified about the different calls to it?


Solution

  • Mockito achieves this by using retransformation ("HotSwap"), rewriting the bytecode of already-loaded classes so Mockito can intercept the behavior even of final and system classes. This is an artifact of the new MockitoMockMaker introduced by default in version 5.0+. This renders obsolete some previous subclass-centric StackOverflow answers about Mockito internals, though those are still relevant on Android and anywhere else you can't count on instrumentation running.

    This alternative mock maker which uses a combination of both Java instrumentation API and sub-classing rather than creating a new class to represent a mock. This way, it becomes possible to mock final types and methods.

    The best place to read about the implementation is the org.mockito.internal.creation.bytebuddy.InlineDelegateByteBuddyMockMaker, rather than the org.mockito.internal.creation.bytebuddy.InlineByteBuddyMockMaker FQCN linked at the bottom of main Javadoc item 39. The last paragraph of that class's top-level Javadoc, emphasis mine:

    Note that inline mocks require a Java agent to be attached. Mockito will attempt an attachment of a Java agent upon loading the mock maker for creating inline mocks. Such runtime attachment is only possible when using a JVM that is part of a JDK or when using a Java 9 VM. When running on a non-JDK VM prior to Java 9, it is however possible to manually add the Byte Buddy Java agent jar using the -javaagent parameter upon starting the JVM. Furthermore, the inlining mock maker requires the VM to support class retransformation (also known as HotSwap). All major VM distributions such as HotSpot (OpenJDK), J9 (IBM/Websphere) or Zing (Azul) support this feature.

    It may also be helpful to skim ByteBuddy's Advice javadoc for context, as well as Mockito's MockMethodAdvice and MockMethodInterceptor to how Mockito achieves this magic at transformation-time and runtime respectively.


    To reproduce this, use the following code:

    import static org.mockito.Mockito.spy;
    
    import java.util.ArrayList;
    import java.util.List;
    import org.mockito.Mockito;
    
    public class MyClass {
        public static void main(String args[]) {
            List<String> list = new ArrayList<>();
            List<String> spyList = spy(list);
    
            System.out.println(list.getClass() + " " + list.getClass().hashCode());
            System.out.println(spyList.getClass() + " " + spyList.getClass().hashCode());
            System.out.println(list == spyList);
            System.out.println(list.getClass() == spyList.getClass());
            System.out.println(Mockito.mockingDetails(list).isMock());
            System.out.println(Mockito.mockingDetails(spyList).isMock());
        }
    }
    

    As on JDoodle, running in Mockito 4.11.0 (org.mockito:mockito-core:4.11.0), this outputs:

    class java.util.ArrayList 1321659788
    class org.mockito.codegen.ArrayList$MockitoMock$KpRNB6cF 257459516
    false
    false
    false
    true
    

    But on Mockito 5.10.0 (org.mockito:mockito-core:5.10.0), this outputs:

    class java.util.ArrayList 1321659788
    class java.util.ArrayList 1321659788
    false
    true
    false
    true