Repository: mockito/mockito
Issue: Calling getExceptionTypes() on concrete object that is used as interface doesn't return exception types from interface
Labels: please contribute

org.mockito.internal.stubbing.answers.InvocationInfo#isValidException is doing validation on the exception types that can be thrown from this method.

In my codebase, I'm programming against an interface, but in fact, a spy is created from an instance that is implementing the interface, that drops the exception types that that implementation does not throw. However, the code that is calling the interface is of course unaware of that fact. Also, that implementation is a mock implementation (a real mock implementation, not a Mockito mock()/spy() instance). 

A testcase here:
```
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.exceptions.base.MockitoException;
import org.mockito.junit.jupiter.MockitoExtension;

import java.io.IOException;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.spy;

@ExtendWith(MockitoExtension.class)
public class MockitoExceptionTypesIssue {
    @Test
    void exceptionTypeIoExceptionShouldBeMockableForObjectBeingSpyedOn() {
        final I x = spy(new C());
        final IOException ioException = new IOException("Oh my god!");
        final MockitoException e = assertThrows(
                MockitoException.class,
                () -> doThrow(ioException)
                        .when(x)
                        .m());
        assertEquals("\n" +
                "Checked exception is invalid for this method!\n" +
                "Invalid: " + ioException,
                e.getMessage()
        );
    }

    interface I {
        void m() throws IOException;
    }

    static class C implements I {
        public void m() {
            // no-op
        }
    }
}
```

Issue comments:
Hi @TimvdLippe !
I am a new open source contributor and would love to solve this issue, could you help me understand the issue better by providing some extra references ? 
Is this is issue open to contribute ?
isn't it expected behavior?
From the user perspective , it look like that method can throw super method exception but from code perspective it is limited to exception of object created.
As we are spying on the object and method in that object is just throwing the sub Exception ,the code knows that it will throw only sub Exception and not exception implemented by super method .

the same issue(?) will happen when mocking a class with interface /parent class with additional method exception . 

From the implementation perspective 
As we are mocking /spying on the object of sub class ,the mockito only knows about the method and class being mocked ,not the reference class/interface ,so not sure if anything can be done even if this is considered as a bug 


```
final I x = new C();
x.getClass() ; // it will return C ,not I 
```



Pull request review comments:
I am not 100% sure, but I think we should also recursively walk up the parent class hierarchy in case the direct parent doesn't define the method. E.g. I think the current regression tests still fails:

```java
public interface Foo {
    void bar() throws IOException; 
}
public abstract class Baz implements Foo {}
public class SubClass extends Baz {
    @Override
    public void bar() {
    }
}
```

Patch that resolved the issue:
diff --git a/src/main/java/org/mockito/internal/stubbing/answers/InvocationInfo.java b/src/main/java/org/mockito/internal/stubbing/answers/InvocationInfo.java
index 8bfffca2fe..c159906af0 100644
--- a/src/main/java/org/mockito/internal/stubbing/answers/InvocationInfo.java
+++ b/src/main/java/org/mockito/internal/stubbing/answers/InvocationInfo.java
@@ -6,6 +6,9 @@
 
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
 
 import org.mockito.internal.invocation.AbstractAwareMethod;
 import org.mockito.internal.util.MockUtil;
@@ -24,15 +27,52 @@ public InvocationInfo(InvocationOnMock theInvocation) {
         this.invocation = theInvocation;
     }
 
-    public boolean isValidException(Throwable throwable) {
-        Class<?>[] exceptions = method.getExceptionTypes();
-        Class<?> throwableClass = throwable.getClass();
-        for (Class<?> exception : exceptions) {
+    public boolean isValidException(final Throwable throwable) {
+        if (isValidException(method, throwable)) {
+            return true;
+        }
+
+        return isValidExceptionForParents(method.getDeclaringClass(), throwable);
+    }
+
+    private boolean isValidExceptionForParents(final Class<?> parent, final Throwable throwable) {
+        final List<Class<?>> ancestors = new ArrayList<>(Arrays.asList(parent.getInterfaces()));
+
+        if (parent.getSuperclass() != null) {
+            ancestors.add(parent.getSuperclass());
+        }
+
+        final boolean validException =
+                ancestors.stream()
+                        .anyMatch(ancestor -> isValidExceptionForClass(ancestor, throwable));
+
+        if (validException) {
+            return true;
+        }
+
+        return ancestors.stream()
+                .anyMatch(ancestor -> isValidExceptionForParents(ancestor, throwable));
+    }
+
+    private boolean isValidExceptionForClass(final Class<?> parent, final Throwable throwable) {
+        try {
+            final Method parentMethod =
+                    parent.getMethod(this.method.getName(), this.method.getParameterTypes());
+            return isValidException(parentMethod, throwable);
+        } catch (NoSuchMethodException e) {
+            // ignore interfaces that doesn't have such a method
+            return false;
+        }
+    }
+
+    private boolean isValidException(final Method method, final Throwable throwable) {
+        final Class<?>[] exceptions = method.getExceptionTypes();
+        final Class<?> throwableClass = throwable.getClass();
+        for (final Class<?> exception : exceptions) {
             if (exception.isAssignableFrom(throwableClass)) {
                 return true;
             }
         }
-
         return false;
     }
 


Test patch:
diff --git a/src/test/java/org/mockito/internal/invocation/InvocationBuilder.java b/src/test/java/org/mockito/internal/invocation/InvocationBuilder.java
index 281d41b3c3..9900db9d98 100644
--- a/src/test/java/org/mockito/internal/invocation/InvocationBuilder.java
+++ b/src/test/java/org/mockito/internal/invocation/InvocationBuilder.java
@@ -30,6 +30,7 @@ public class InvocationBuilder {
     private int sequenceNumber = 0;
     private Object[] args = new Object[] {};
     private Object mock = Mockito.mock(IMethods.class);
+    private Class<?> mockClass = IMethods.class;
     private Method method;
     private boolean verified;
     private List<Class<?>> argTypes;
@@ -57,7 +58,7 @@ public Invocation toInvocation() {
 
             try {
                 method =
-                        IMethods.class.getMethod(
+                        mockClass.getMethod(
                                 methodName, argTypes.toArray(new Class[argTypes.size()]));
             } catch (Exception e) {
                 throw new RuntimeException(
@@ -115,6 +116,12 @@ public InvocationBuilder mock(Object mock) {
         return this;
     }
 
+    public InvocationBuilder mockClass(Class<?> mockClass) {
+        this.mockClass = mockClass;
+        this.mock = mock(mockClass);
+        return this;
+    }
+
     public InvocationBuilder method(Method method) {
         this.method = method;
         return this;
diff --git a/src/test/java/org/mockito/internal/stubbing/answers/InvocationInfoExceptionTest.java b/src/test/java/org/mockito/internal/stubbing/answers/InvocationInfoExceptionTest.java
new file mode 100644
index 0000000000..21802c1f54
--- /dev/null
+++ b/src/test/java/org/mockito/internal/stubbing/answers/InvocationInfoExceptionTest.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2022 Mockito contributors
+ * This program is made available under the terms of the MIT License.
+ */
+package org.mockito.internal.stubbing.answers;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.mockito.internal.invocation.InvocationBuilder;
+import org.mockito.invocation.Invocation;
+
+import java.nio.charset.CharacterCodingException;
+import java.util.Arrays;
+import java.util.Collection;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@RunWith(Parameterized.class)
+public class InvocationInfoExceptionTest {
+
+    private final String methodName;
+
+    public InvocationInfoExceptionTest(final String methodName) {
+        this.methodName = methodName;
+    }
+
+    @Parameterized.Parameters
+    public static Collection<Object[]> data() {
+        return Arrays.asList(
+                new Object[][] {
+                    {"throwException"},
+                    {"parentThrowsException"},
+                    {"grandParentThrowsException"},
+                    {"interfaceThrowsException"},
+                    {"grandInterfaceThrowsException"},
+                    {"interfaceOfParentThrowsException"}
+                });
+    }
+
+    @Test
+    public void should_know_valid_throwables() throws Exception {
+        // when
+        final Invocation invocation =
+                new InvocationBuilder()
+                        .method(methodName)
+                        .mockClass(CurrentClass.class)
+                        .toInvocation();
+        final InvocationInfo info = new InvocationInfo(invocation);
+
+        // then
+        assertThat(info.isValidException(new Exception())).isFalse();
+        assertThat(info.isValidException(new CharacterCodingException())).isTrue();
+    }
+
+    private abstract static class GrandParent {
+        public abstract void grandParentThrowsException() throws CharacterCodingException;
+    }
+
+    private interface InterfaceOfParent {
+        abstract void interfaceOfParentThrowsException() throws CharacterCodingException;
+    }
+
+    private abstract static class Parent extends GrandParent implements InterfaceOfParent {
+        public abstract void parentThrowsException() throws CharacterCodingException;
+    }
+
+    private interface GrandInterface {
+        void grandInterfaceThrowsException() throws CharacterCodingException;
+    }
+
+    private interface Interface extends GrandInterface {
+        void interfaceThrowsException() throws CharacterCodingException;
+    }
+
+    private static class CurrentClass extends Parent implements Interface {
+
+        public void throwException() throws CharacterCodingException {}
+
+        @Override
+        public void grandParentThrowsException() {}
+
+        @Override
+        public void parentThrowsException() {}
+
+        @Override
+        public void grandInterfaceThrowsException() {}
+
+        @Override
+        public void interfaceThrowsException() {}
+
+        @Override
+        public void interfaceOfParentThrowsException() {}
+    }
+}
diff --git a/src/test/java/org/mockito/internal/stubbing/answers/InvocationInfoTest.java b/src/test/java/org/mockito/internal/stubbing/answers/InvocationInfoTest.java
index 3c9d96983d..fea0ec36eb 100644
--- a/src/test/java/org/mockito/internal/stubbing/answers/InvocationInfoTest.java
+++ b/src/test/java/org/mockito/internal/stubbing/answers/InvocationInfoTest.java
@@ -9,26 +9,13 @@
 import static org.mockitoutil.TestBase.getLastInvocation;
 
 import java.lang.reflect.Method;
-import java.nio.charset.CharacterCodingException;
 
 import org.junit.Test;
 import org.mockito.internal.invocation.InvocationBuilder;
-import org.mockito.invocation.Invocation;
 import org.mockitousage.IMethods;
 
 public class InvocationInfoTest {
 
-    @Test
-    public void should_know_valid_throwables() throws Exception {
-        // when
-        Invocation invocation = new InvocationBuilder().method("canThrowException").toInvocation();
-        InvocationInfo info = new InvocationInfo(invocation);
-
-        // then
-        assertThat(info.isValidException(new Exception())).isFalse();
-        assertThat(info.isValidException(new CharacterCodingException())).isTrue();
-    }
-
     @Test
     public void should_know_valid_return_types() throws Exception {
         assertThat(