diff --git a/java/google/registry/util/BUILD b/java/google/registry/util/BUILD index f625b86b5..0ecef15cd 100644 --- a/java/google/registry/util/BUILD +++ b/java/google/registry/util/BUILD @@ -12,6 +12,7 @@ java_library( "@com_google_appengine_api_1_0_sdk", "@com_google_code_findbugs_jsr305", "@com_google_dagger", + "@com_google_errorprone_error_prone_annotations", "@com_google_guava", "@com_google_re2j", "@com_ibm_icu_icu4j", diff --git a/java/google/registry/util/ComparingInvocationHandler.java b/java/google/registry/util/ComparingInvocationHandler.java new file mode 100644 index 000000000..dff3810fd --- /dev/null +++ b/java/google/registry/util/ComparingInvocationHandler.java @@ -0,0 +1,206 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.util; + +import com.google.common.reflect.Reflection; +import com.google.errorprone.annotations.ForOverride; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * Abstract InvocationHandler comparing two implementations of some interface. + * + *

Given an interface, and two instances of that interface (the "original" instance we know + * works, and a "second" instance we wish to test), creates an InvocationHandler that acts like an + * exact proxy to the "original" instance. + * + *

In addition, it will log any differences in return values or thrown exception between the + * "original" and "second" instances. + * + *

This can be used to create an exact proxy to the original instance that can be placed in any + * code, while live testing the second instance. + */ +public abstract class ComparingInvocationHandler implements InvocationHandler { + + private final T actualImplementation; + private final T secondImplementation; + private final Class interfaceClass; + + /** + * Creates a new InvocationHandler for the given interface. + * + * @param interfaceClass the interface we want to create. + * @param actualImplementation the resulting proxy will be an exact proxy to this object + * @param secondImplementation Only used to log difference compared to actualImplementation. + * Otherwise has no effect on the resulting proxy's behavior. + */ + public ComparingInvocationHandler( + Class interfaceClass, T actualImplementation, T secondImplementation) { + this.actualImplementation = actualImplementation; + this.secondImplementation = secondImplementation; + this.interfaceClass = interfaceClass; + } + + /** + * Returns the proxy to the actualImplementation. + * + *

The return value is a drop-in replacement to the actualImplementation, but will log any + * difference with the secondImplementation during normal execution. + */ + public final T makeProxy() { + return Reflection.newProxy(interfaceClass, this); + } + + /** + * Called when there was a difference between the implementations. + * + * @param method the method where the difference was found + * @param message human readable description of the difference found + */ + @ForOverride + protected abstract void log(Method method, String message); + + /** + * Implements toString for specific types. + * + *

By default objects are logged using their .toString. If .toString isn't implemented for + * some relevant classes (or if we want to use a different version), override this method with + * the desired implementation. + * + * @param method the method whose return value is given + * @param object the object returned by a call to method + */ + @ForOverride + protected String toString( + @SuppressWarnings("unused") Method method, + @Nullable Object object) { + return String.valueOf(object); + } + + /** + * Implements equals for specific types. + * + *

By default objects are compared using their .equals. If .equals isn't implemented for some + * relevant classes (or if we want change what is considered "not equal"), override this method + * with the desired implementation. + * + * @param method the method whose return value is given + * @param actual the object returned by a call to method for the "actual" implementation + * @param second the object returned by a call to method for the "second" implementation + */ + @ForOverride + protected boolean equals( + @SuppressWarnings("unused") Method method, + @Nullable Object actual, + @Nullable Object second) { + return Objects.equals(actual, second); + } + + /** + * Implements equals for thrown exceptions. + * + *

By default this returns 'true' for any input: all we check by default is that both + * implementations threw something. Override if you need to actually compare both throwables. + * + * @param method the method whose return value is given + * @param actual the exception thrown by a call to method for the "actual" implementation + * @param second the exception thrown by a call to method for the "second" implementation + */ + @ForOverride + protected boolean exceptionEquals( + @SuppressWarnings("unused") Method method, + Throwable actual, + Throwable second) { + return true; + } + + /** + * Implements toString for thrown exceptions. + * + *

By default exceptions are logged using their .toString. If more data is needed (part of + * stack trace for example), override this method with the desired implementation. + * + * @param method the method whose return value is given + * @param exception the exception thrown by a call to method + */ + @ForOverride + protected String exceptionToString( + @SuppressWarnings("unused") Method method, + Throwable exception) { + return exception.toString(); + } + + @Override + public final Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + Object actualResult = null; + Throwable actualException = null; + try { + actualResult = method.invoke(actualImplementation, args); + } catch (InvocationTargetException e) { + actualException = e.getCause(); + } + + Object secondResult = null; + Throwable secondException = null; + try { + secondResult = method.invoke(secondImplementation, args); + } catch (InvocationTargetException e) { + secondException = e.getCause(); + } + + // First compare the two implementations' result, and log any differences: + if (actualException != null && secondException != null) { + if (!exceptionEquals(method, actualException, secondException)) { + log( + method, + String.format( + "Both implementations threw, but got different exceptions! '%s' vs '%s'", + exceptionToString(method, actualException), + exceptionToString(method, secondException))); + } + } else if (actualException != null) { + log( + method, + String.format( + "Only actual implementation threw exception: %s", + exceptionToString(method, actualException))); + } else if (secondException != null) { + log( + method, + String.format( + "Only second implementation threw exception: %s", + exceptionToString(method, secondException))); + } else { + // Neither threw exceptions - we compare the results + if (!equals(method, actualResult, secondResult)) { + log( + method, + String.format( + "Got different results! '%s' vs '%s'", + toString(method, actualResult), + toString(method, secondResult))); + } + } + + // Now reproduce the actual implementation's behavior: + if (actualException != null) { + throw actualException; + } + return actualResult; + } +} diff --git a/javatests/google/registry/util/ComparingInvocationHandlerTest.java b/javatests/google/registry/util/ComparingInvocationHandlerTest.java new file mode 100644 index 000000000..027435d6f --- /dev/null +++ b/javatests/google/registry/util/ComparingInvocationHandlerTest.java @@ -0,0 +1,236 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.util; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.util.ArrayList; + +import javax.annotation.Nullable; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +/** Unit tests for {@link ComparingInvocationHandler}. */ +@RunWith(MockitoJUnitRunner.class) +public class ComparingInvocationHandlerTest { + + static class Dummy { + } + + static interface MyInterface { + String func(int a, String b); + Dummy func(); + } + + static class MyException extends RuntimeException { + MyException(String msg) { + super(msg); + } + } + + static class MyOtherException extends RuntimeException { + MyOtherException(String msg) { + super(msg); + } + } + + static final ArrayList log = new ArrayList<>(); + static final class MyInterfaceComparingInvocationHandler + extends ComparingInvocationHandler { + + private boolean dummyEqualsResult = true; + private boolean exceptionEqualsResult = true; + + + MyInterfaceComparingInvocationHandler(MyInterface actual, MyInterface second) { + super(MyInterface.class, actual, second); + } + + MyInterfaceComparingInvocationHandler setExeptionsEquals(boolean result) { + this.exceptionEqualsResult = result; + return this; + } + + MyInterfaceComparingInvocationHandler setDummyEquals(boolean result) { + this.dummyEqualsResult = result; + return this; + } + + @Override protected void log(Method method, String message) { + log.add(String.format("%s: %s", method.getName(), message)); + } + + @Override protected boolean equals(Method method, @Nullable Object a, @Nullable Object b) { + if (method.getReturnType().equals(Dummy.class)) { + return dummyEqualsResult; + } + return super.equals(method, a, b); + } + + @Override protected String toString(Method method, @Nullable Object a) { + if (method.getReturnType().equals(Dummy.class)) { + return "dummy"; + } + return super.toString(method, a); + } + + @Override protected boolean exceptionEquals(Method method, Throwable a, Throwable b) { + return exceptionEqualsResult && super.exceptionEquals(method, a, b); + } + + @Override protected String exceptionToString(Method method, Throwable a) { + return String.format("testException(%s)", super.exceptionToString(method, a)); + } + } + + private static final String ACTUAL_RESULT = "actual result"; + private static final String SECOND_RESULT = "second result"; + + @Mock MyInterface myActualMock; + @Mock MyInterface mySecondMock; + private MyInterfaceComparingInvocationHandler invocationHandler; + + @Before public void setUp() { + log.clear(); + invocationHandler = new MyInterfaceComparingInvocationHandler(myActualMock, mySecondMock); + } + + @Test public void test_actualThrows_logDifference() { + MyInterface comparator = invocationHandler.makeProxy(); + MyException myException = new MyException("message"); + when(myActualMock.func(3, "str")).thenThrow(myException); + when(mySecondMock.func(3, "str")).thenReturn(SECOND_RESULT); + + try { + comparator.func(3, "str"); + fail("Should have thrown MyException"); + } catch (MyException expected) { + } + + assertThat(log).containsExactly(String.format( + "func: Only actual implementation threw exception: testException(%s)", + myException.toString())); + } + + @Test public void test_secondThrows_logDifference() { + MyInterface comparator = invocationHandler.makeProxy(); + MyOtherException myOtherException = new MyOtherException("message"); + when(myActualMock.func(3, "str")).thenReturn(ACTUAL_RESULT); + when(mySecondMock.func(3, "str")).thenThrow(myOtherException); + + assertThat(comparator.func(3, "str")).isEqualTo(ACTUAL_RESULT); + + assertThat(log).containsExactly(String.format( + "func: Only second implementation threw exception: testException(%s)", + myOtherException.toString())); + } + + @Test public void test_bothThrowEqual_noLog() { + MyInterface comparator = invocationHandler + .setExeptionsEquals(true) + .makeProxy(); + MyException myException = new MyException("actual message"); + MyOtherException myOtherException = new MyOtherException("second message"); + when(myActualMock.func(3, "str")).thenThrow(myException); + when(mySecondMock.func(3, "str")).thenThrow(myOtherException); + + try { + comparator.func(3, "str"); + fail("Should have thrown MyException"); + } catch (MyException expected) { + } + + assertThat(log).isEmpty(); + } + + @Test public void test_bothThrowDifferent_logDifference() { + MyInterface comparator = invocationHandler + .setExeptionsEquals(false) + .makeProxy(); + MyException myException = new MyException("actual message"); + MyOtherException myOtherException = new MyOtherException("second message"); + when(myActualMock.func(3, "str")).thenThrow(myException); + when(mySecondMock.func(3, "str")).thenThrow(myOtherException); + + try { + comparator.func(3, "str"); + fail("Should have thrown MyException"); + } catch (MyException expected) { + } + + assertThat(log).containsExactly(String.format( + "func: Both implementations threw, but got different exceptions! " + + "'testException(%s)' vs 'testException(%s)'", + myException.toString(), + myOtherException.toString())); + } + + @Test public void test_bothReturnSame_noLog() { + MyInterface comparator = invocationHandler.makeProxy(); + when(myActualMock.func(3, "str")).thenReturn(ACTUAL_RESULT); + when(mySecondMock.func(3, "str")).thenReturn(ACTUAL_RESULT); + + assertThat(comparator.func(3, "str")).isEqualTo(ACTUAL_RESULT); + + assertThat(log).isEmpty(); + } + + @Test public void test_bothReturnDifferent_logDifference() { + MyInterface comparator = invocationHandler.makeProxy(); + when(myActualMock.func(3, "str")).thenReturn(ACTUAL_RESULT); + when(mySecondMock.func(3, "str")).thenReturn(SECOND_RESULT); + + assertThat(comparator.func(3, "str")).isEqualTo(ACTUAL_RESULT); + + assertThat(log).containsExactly( + "func: Got different results! " + + "'actual result' vs " + + "'second result'"); + } + + @Test public void test_usesOverriddenMethods_noDifference() { + MyInterface comparator = invocationHandler + .setDummyEquals(true) + .makeProxy(); + when(myActualMock.func()).thenReturn(new Dummy()); + when(mySecondMock.func()).thenReturn(new Dummy()); + + comparator.func(); + + assertThat(log).isEmpty(); + } + + @Test public void test_usesOverriddenMethods_logDifference() { + MyInterface comparator = invocationHandler + .setDummyEquals(false) + .makeProxy(); + when(myActualMock.func()).thenReturn(new Dummy()); + when(mySecondMock.func()).thenReturn(new Dummy()); + + comparator.func(); + + assertThat(log).containsExactly( + "func: Got different results! " + + "'dummy' vs " + + "'dummy'"); + } +}