Why does Mockito need JVM bytecode generation?
2023-01-23
Mockito is a pretty popular Java mocking library. It lets you write code like this:
MyClass mockObject = mock(MyClass.class);
when(mockObject.myMethod(1)).thenReturn("one");
Which is pretty cool, even if it's a bit magic. It's not really that magical, conceptually - Mockito simply intercepts method calls and keeps track of which methods have been called globally, and with what arguments. The call to .thenReturn
effectively writes to global state, so that the next call to mockObject.myMethod(1)
will have the right behaviour.
My question is simple: Mockito uses bytecode generation libraries (cglib or bytebuddy) to construct the proxies - why do we need to go to those lengths? Can't we get by with something more mundane, meaning either in the standard library or higher level (where I consider JVM bytecode to be low level)?
Trying to implement Mockito.mock without anything fancy
The most magic method is mock, of course. That's what takes the class that we want to mock and makes an object which does all of the Mockito magic.
We could read through all of the code here: https://github.com/mockito/mockito/tree/f48d794ad14982a134fd14dd2aef03477b699dc6/src/main/java/org/mockito/internal/creation/bytebuddy and try to understand what it's doing line by line, but that's no fun. It seems like it'd be more fun to try to implement Mockito.mock
without all of this bytecode generation voodoo and see where we run into trouble.
We can use https://docs.oracle.com/javase/7/docs/api/java/lang/reflect/Proxy.html to do the heavy lifting.
Let's call our mocker class Mocker
, we need three methods: mock
, which will take a class and give us a mock object; when
, which will take any object and return a Mocker
that we can call some expectation-setting methods on; and thenReturn
, which allows us to set expectations.
when
is really just to make things read a bit nicer, and so we can match the Mockito example.
Mocker might look like:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.Map;
public class Mocker {
private static Map<CallKey, Object> callKeyToReturnValue = new HashMap<>();
public static <T> T mock(Class<T> classToMock) {
final InvocationHandler handler = new MockInvocationHandler(callKeyToReturnValue);
return (T) Proxy.newProxyInstance(
classToMock.getClassLoader(),
new Class[]{ classToMock },
handler);
}
public static Mocker when(Object mockReturnValue) {
return new Mocker();
}
public void thenReturn(Object value) {
final CallKey callKey = MockInvocationHandler.lastInvocation;
callKeyToReturnValue.put(callKey, value);
}
}
Where CallKey is defined as:
import java.util.Arrays;
import java.util.Objects;
public record CallKey(String methodName, Object[] args) {
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CallKey callKey = (CallKey) o;
return Objects.equals(methodName, callKey.methodName) && Arrays.equals(args, callKey.args);
}
@Override
public int hashCode() {
int result = Objects.hash(methodName);
result = 31 * result + Arrays.hashCode(args);
return result;
}
}
(Thank you, IntelliJ, for generating these methods for me!)
CallKey represents a method call as a method name and a list of arguments. This is how we set expectations - we just map CallKeys to the expected values. More complex behaviours would require more work - executing some action on an invocation, for example.
We need to override equals and hashCode because we want two arg arrays with different identity but equal elements to compare as equal.
MockInvocationHandler is where we fetch expected values:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.util.Map;
public record MockInvocationHandler(Map<CallKey, Object> callKeyToExpectedValue) implements InvocationHandler {
static CallKey lastInvocation;
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
final var callKey = new CallKey(method.getName(), args);
lastInvocation = callKey;
return callKeyToExpectedValue.get(callKey);
}
}
When we invoke a method on the mock object, we return the expected value if one exists, otherwise null.
Yes, I know, it would be great to just error out if there's no expectation set. That's what GoogleTest does (in C++), and I wish Mockito did that. Anyway, I went with returning null here because it's simpler. Sue me.
Let's try it out! We can define a simple class to use for our example:
public class MyClass {
public String myMethod(int i) {
return String.valueOf(i);
}
}
And let's write a simple main method:
public class Main {
public static void main(String[] args) {
// mock object
final MyClass mockObject = Mocker.mock(MyClass.class);
// set an expectation (different from the real implementation!)
Mocker.when(mockObject.myMethod(1)).thenReturn("one");
// should print "one"
System.out.println(mockObject.myMethod(1));
// should print null - we didn't set any expectation
System.out.println(mockObject.myMethod(2));
}
}
And let's run it!
Exception in thread "main" java.lang.IllegalArgumentException: MyClass is not an interface
at java.base/java.lang.reflect.Proxy$ProxyBuilder.validateProxyInterfaces(Proxy.java:706)
at java.base/java.lang.reflect.Proxy$ProxyBuilder.<init>(Proxy.java:648)
at java.base/java.lang.reflect.Proxy$ProxyBuilder.<init>(Proxy.java:656)
at java.base/java.lang.reflect.Proxy.lambda$getProxyConstructor$0(Proxy.java:429)
at java.base/jdk.internal.loader.AbstractClassLoaderValue$Memoizer.get(AbstractClassLoaderValue.java:329)
at java.base/jdk.internal.loader.AbstractClassLoaderValue.computeIfAbsent(AbstractClassLoaderValue.java:205)
at java.base/java.lang.reflect.Proxy.getProxyConstructor(Proxy.java:427)
at java.base/java.lang.reflect.Proxy.newProxyInstance(Proxy.java:1037)
at Mocker.mock(Mocker.java:11)
at Main.main(Main.java:10)
And there's the problem - java.lang.reflect.Proxy only works on interfaces, not classes. This isn't so bad, we can still test out our implementation by making MyClass an interface, then we get the expected output:
one
null
This sucks, we had to edit our code to be mockable. You don't have to do that with Mockito.
How do you mock a class, not an interface?
In our example, if we wanted to avoid Mockito, we'd have to make an interface MyInterface which is implemented by MyClass, and mock MyInterface - annoying! We really just want to have MyClass and mock it. The magic of Mockito is that you don't have to add all these extra interfaces to your code just for the purposes of testing.
Mockito does this by effectively re-implementing java.lang.reflect.Proxy, but making it work for classes as well interfaces.
If you skim over https://github.com/mockito/mockito/blob/f48d794ad14982a134fd14dd2aef03477b699dc6/src/main/java/org/mockito/internal/creation/bytebuddy/SubclassBytecodeGenerator.java#L126 you effectively see what you'd do if you were writing a mock class by hand, written as bytecode generating code using ByteBuddy.
Conclusions
Mockito needs ByteBuddy because Java lacks macros, meaning we need to resort to insane bytecode generation hacks just to generate proxies for classes. Maybe that's not the conclusion you were expecting, but them's the facts.
Clojure doesn't need this nonsense because it has macros and can generate code in a sane way.
Rust also has macros good enough for a great mocking library: https://docs.rs/mockall/latest/mockall/ and what would the equivalent of JVM bytecode generation be in a compile-to-native language, anyway? My first thought would be assembly, but that doesn't seem like it makes any sense.
In C++, there really isn't any mocking library as good as Mockito. This is because there are certain things a library can never do. gmock will never let you mock a class with non-virtual methods and use that polymorphically in place of real instances - it's just fundamentally impossible. The gmock cookbook suggests templatizing your code, which is terrible: https://github.com/google/googletest/blob/main/docs/gmock_cook_book.md#mocking-non-virtual-methods-mockingnonvirtualmethods.
Anyway, use Mockito, it's great! Ignore the bytecode wizard behind the curtain, he's on your side!