2

I want to create a Mock Library class that implements InvocationHandler interface from Java Reflection.

This is the template I have created:

import java.lang.reflect.*;
import java.util.*;

class MyMock implements InvocationHandler {
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            // todo
        }
        
        public MyMock when(String method, Object[] args) {
            // todo
        }
        
        public void thenReturn(Object val) {
            // todo
        }
}

The when and thenReturn methods are chained methods.

Then when method registers the given mock parameters.

thenReturn method registers the expected return values for the given mock parameters.

Also, I want to throw java.lang.IllegalArgumentException if the proxied interface calls methods or uses parameters that are not registered.

This is a sample interface:

interface CalcInterface {
    int add(int a, int b);
    String add(String a, String b);
    String getValue();
}

Here we have two overloaded add methods.

This is a program to test the mock class I wanted to implement.

class TestApplication {     
        public static void main(String[] args) {
            MyMock m = new MyMock();
            CalcInterface ref = (CalcInterface) Proxy.newProxyInstance(MyMock.class.getClassLoader(), new Class[]{CalcInterface.class}, m);
            
            m.when("add", new Object[]{1,2}).thenReturn(3);
            m.when("add", new Object[]{"x","y"}).thenReturn("xy");
            
            System.out.println(ref.add(1,2)); // prints 3
            System.out.println(ref.add("x","y")); // prints "xy"
        }
}

This is the code which I have implemented so far to check the methods in CalcInterface:

class MyMock implements InvocationHandler {
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            int n = args.length;
            if(n == 2 && method.getName().equals("add")) {
                Object o1 = args[0], o2 = args[1];
                if((o1 instanceof String) && (o2 instanceof String)) {
                    String s1 = (String) o1, s2 = (String) o2;
                    return s1+ s2;
                } else if((o1 instanceof Integer) && (o2 instanceof Integer)) {
                    int s1 = (Integer) o1, s2 = (Integer) o2;
                    return s1+ s2;
                }
            }
            throw new IllegalArgumentException();
        }
        
        public MyMock when(String method, Object[] args) {
            return this;
        }
        
        public void thenReturn(Object val) {
        
        }
}

Here I am checking only for methods with the name add and having 2 arguments, with their type as String or Integer.

But I wanted to create this MyMock class in a general fashion, supporting different interfaces not just CalcInterface, and also supporting different methods not just the add method I implemented here.

1
  • Why does thenReturn not return anything? Commented Oct 10, 2022 at 22:18

2 Answers 2

5
+25

You have to separate the builder logic from the object to build. The method when has to return something which remembers the arguments, so that the invocation of thenReturn still knows the context.

For example

public class MyMock implements InvocationHandler {
    record Key(String name, List<?> arguments) {
        Key { // stream().toList() creates an immutable list allowing null
            arguments = arguments.stream().toList();
        }
        Key(String name, Object... arg) {
            this(name, arg == null? List.of(): Arrays.stream(arg).toList());
        }
    }
    final Map<Key, Function<Object[], Object>> rules = new HashMap<>();

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        var rule = rules.get(new Key(method.getName(), args));
        if(rule == null) throw new IllegalStateException("No matching rule");
        return rule.apply(args);
    }
    public record Rule(MyMock mock, Key key) {
        public void thenReturn(Object val) {
            var existing = mock.rules.putIfAbsent(key, arg -> val);
            if(existing != null) throw new IllegalStateException("Rule already exist");
        }
        public void then(Function<Object[], Object> f) {
            var existing = mock.rules.putIfAbsent(key, Objects.requireNonNull(f));
            if(existing != null) throw new IllegalStateException("Rule already exist");
        }
    }
    public Rule when(String method, Object... args) {
        Key key = new Key(method, args);
        if(rules.containsKey(key)) throw new IllegalStateException("Rule already exist");
        return new Rule(this, key);
    }
}

This is already capable of executing your example literally, but also supports something like

MyMock m = new MyMock();
CalcInterface ref = (CalcInterface) Proxy.newProxyInstance(
        CalcInterface.class.getClassLoader(), new Class[]{CalcInterface.class}, m);

m.when("add", 1,2).thenReturn(3);
m.when("add", "x","y").thenReturn("xy");
AtomicInteger count = new AtomicInteger();
m.when("getValue").then(arg -> "getValue invoked " + count.incrementAndGet() + " times");

System.out.println(ref.add(1,2)); // prints 3
System.out.println(ref.add("x","y")); // prints "xy"
System.out.println(ref.getValue()); // prints getValue invoked 1 times
System.out.println(ref.getValue()); // prints getValue invoked 2 times

Note that when you want to add support for rules beyond simple value matching, a hash lookup will not work anymore. In that case you have to resort to a data structure you have to search linearly for a match.

The example above uses newer Java features like record classes but it shouldn’t be too hard to rewrite it for previous Java versions if required.


It’s also possible to redesign this code to use the real builder pattern, i.e. to use a builder to describe the configuration prior to creating the actual handler/mock instance. This allows the handler/mock to use an immutable state:

public class MyMock2 {
    public static Builder builder() {
        return new Builder();
    }
    public interface Rule {
        Builder thenReturn(Object val);
        Builder then(Function<Object[], Object> f);
    }
    public static class Builder {
        final Map<Key, Function<Object[], Object>> rules = new HashMap<>();

        public Rule when(String method, Object... args) {
            Key key = new Key(method, args);
            if(rules.containsKey(key))
                throw new IllegalStateException("Rule already exist");
            return new RuleImpl(this, key);
        }
        public <T> T build(Class<T> type) {
            Map<Key, Function<Object[], Object>> rules = Map.copyOf(this.rules);
            return type.cast(Proxy.newProxyInstance(type.getClassLoader(),
                new Class[]{ type }, (proxy, method, args) -> {
                   var rule = rules.get(new Key(method.getName(), args));
                   if(rule == null) throw new IllegalStateException("No matching rule");
                   return rule.apply(args);
                }));

        }
    }
    record RuleImpl(MyMock2.Builder builder, Key key) implements Rule {
        public Builder thenReturn(Object val) {
            var existing = builder.rules.putIfAbsent(key, arg -> val);
            if(existing != null) throw new IllegalStateException("Rule already exist");
            return builder;
        }
        public Builder then(Function<Object[], Object> f) {
            var existing = builder.rules.putIfAbsent(key, Objects.requireNonNull(f));
            if(existing != null) throw new IllegalStateException("Rule already exist");
            return builder;
        }
    }
    record Key(String name, List<?> arguments) {
        Key { // stream().toList() createns an immutable list allowing null
            arguments = arguments.stream().toList();
        }
        Key(String name, Object... arg) {
            this(name, arg == null? List.of(): Arrays.stream(arg).toList());
        }
    }
}

which can be used like

AtomicInteger count = new AtomicInteger();
CalcInterface ref = MyMock2.builder()
        .when("add", 1,2).thenReturn(3)
        .when("add", "x","y").thenReturn("xy")
        .when("getValue")
            .then(arg -> "getValue invoked " + count.incrementAndGet() + " times")
        .build(CalcInterface.class);

System.out.println(ref.add(1,2)); // prints 3
System.out.println(ref.add("x","y")); // prints "xy"
System.out.println(ref.getValue()); // prints getValue invoked 1 times
System.out.println(ref.getValue()); // prints getValue invoked 2 times
Sign up to request clarification or add additional context in comments.

4 Comments

Can you please help me with how to do this in Java 8, I am not able to understand how to convert this in Java 8 version.
Get familiar with what records are and you’ll understand, how the longer form of an ordinary class would look like. If you’re using IntelliJ, you can let the software do the transformation.
I updated my post with the code I tried using Java 8 based on your answer, I am not able to find where I made the mistake, I am getting an exception with my converted code now.
In your constructor of Key you have the loop for (Object e : arguments) { this.arguments.add(e); } which has no effect as arguments is the name of the field whereas the parameter you want to iterate is arg (note that good IDEs like Eclipse will warn you about this). But you can simplify the entire constructor to Key(String name, Object... arg) { this.arguments = arg == null? Collections.emptyList(): Arrays.asList(arg); this.name = name; }
1

InvocationHandler is used to invoke a method signature against an object instance (proxy) that's instantiated in memory (or a class, if you're tapping a static method), not to support methods that aren't in the supplied proxy's class at all, which is how you've implemented it here. That said, you can probably achieve what you're trying to do by holding the method signature you're trying to mock (as well as the value you want to return when the args match) in private variables.

This may work, with the proviso that I haven't done Java in a couple of years, so I may be a bit rusty:

class MyMock implements InvocationHandler {
    private String methodName = null;
    private Object[] supportedArgs = null;
    private Object returnValue = null;

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        // If we don't know when this mock is supposed to be used, it's useless
        assert this.methodName != null: "when(method, args) hasn't been called against the mock yet!";

        // Note that both args and supportedArgs will be null if the method signature has no params
        if (method.getName().equals(this.methodName) && this.supportedArgs == args) {
            return this.returnValue;
        }
        try {
            return method.invoke(proxy, args);
        }
        catch (IllegalAccessException | InvocationTargetException innerException){
            // The proxy didn't support the method either, so let's throw an IllegalArgumentException
            throw new IllegalArgumentException("The supplied method signature isn't implemented in the proxy.");
        }
    }

    public MyMock when(String method, Object[] args) {
        this.methodName = method;
        this.supportedArgs = args;
        return this;
    }

    public void thenReturn(Object val) {
        this.returnValue = val;
    }
}

2 Comments

If you wanted to get fancier, you could add a generic field for the class of the kind of object you're trying to mock, then check that against the class of the proxy and make sure one is assignable from the other. That might prevent some same-method-name-entirely-different-object-type problems.
I am getting exception as Exception in thread "main" java.lang.IllegalArgumentException: The supplied method signature isn't implemented in the proxy. when I run the main method in TestApplication after using this code.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.