例外のテストでRelaxed mockを使ったら無限ループにハマった話

# 結論
mockKを使った例外のテストでRelaxed Mockを使ったら、log出力の処理でStackOverFlowが起きてしまった。
Relaxed Mockは気をつけて使ったほうがいいなぁという話。

# 実行環境
## ライブラリ
- spring MVC
- mockK（モックライブラリ）
- JUnit5（テストフレームワーク）
- slf4jとlogback（ロギングライブラリ）


## コード
### プロダクトコード

スローされた例外をキャッチして処理するクラス
```kotlin
@RestControllerAdvice
class GlobalExceptionHandler {

    private val log = LoggerFactory.getLogger(javaClass)

    @ExceptionHandler(value = [
        HttpRequestMethodNotSupportedException::class
    ])
    fun handleGlobalException(
        exception: Exception,
        request:HttpServletRequest
    ): ResponseEntity<Any>{
        val error = ApiError(
            message = exception.message ?: "",
            errorCode = "E_001"
        ).also { log.warn("error has occurred!!!", exception) } // ←ここで無限ループに、、

        return ResponseEntity(error, HttpStatus.NOT_FOUND)
    }
}
```

ApiErrorクラス
```kotlin
class ApiError (val message: String, val errorCode: String)
```

### テストコード
GlobalExceptionHandlerのテストコード
（キャッチする例外は代表でHttpMethodNotsupportedExceptionを選びました）
```kotlin
internal class GlobalExceptionHandlerTest {

    private lateinit var mockMethodNotSupported: HttpRequestMethodNotSupportedException

    private lateinit var sut: GlobalExceptionHandler

    private lateinit var mockRequest: HttpServletRequest

    @BeforeEach
    fun setUpAll(){
        mockMethodNotSupported = mockk()
        sut = GlobalExceptionHandler()
        mockRequest = mockk()
    }

    @Test
    fun `HttpMethodNotAllowedをキャッチして、適切に処理すること`() {
        // Arrange
        mockMethodNotSupported = mockk(relaxed = true)
        every { mockMethodNotSupported.message } returns "your method is not allowed!"


        val error = ApiError(
            message = "your method is not allowed!",
            errorCode = "E_001" // ←固定の適当なエラーコード
        )
        val expected = ResponseEntity(error, HttpStatus.NOT_FOUND)

        // Act
        val responseEntity = sut.handleGlobalException(mockMethodNotSupported, mockRequest)

        // Assert
        assertThat(responseEntity).usingRecursiveComparison().withStrictTypeChecking().isEqualTo(expected)
    }
}
```

# 原因
1. プロダクトコードの以下の部分でexception（例外オブジェクト全体）がwarnに渡される
 `log.warn("error has occurred!!!", exception)`
1. Logbackが例外の詳細な情報を取得するために getCause() メソッドを内部的に繰り返し呼び出す
1. exceptionはrelaxed mock化された例外クラス（HttpRequestMethodNotSupportedException）であり、`getCause()`が呼ばれた際の振る舞いが明確に定義されていない
1. mockK側が`getCause()`が呼ばれた際に、新しい例外のモックが生成される
1. 新しく作成された例外モックの`getCause()`が呼ばれてまた新しい例外モックが生成される
1. StackOverFlowを引き起こしてアプリが弾け飛ぶ

# 解決策
`getCause()`が呼ばれた時の振る舞いが明確に定義されていなかったのが原因なので、以下のコードをテスト関数に追加したら治りました！やったぜ。
```kotlin
    every { mockMethodNotSupported.message } returns "your method is not allowed!"
    every { mockMethodNotSupported.cause } returns null // ←これが追加したコード
```

# ちょっと深掘り
## 本当にLogbackって内部で`getCause()`を繰り返し呼ぶの？
```
at ch.qos.logback.classic.spi.ThrowableProxy.<init>(ThrowableProxy.java:78)
```
っていうのが無限に呼ばれていたので見に行ってみる。

```java
// ch.qos.logback.classic.spi.ThrowableProxyの一部
public ThrowableProxy(Throwable throwable, Set<Throwable> alreadyProcessedSet) {

        this.throwable = throwable;
        this.className = throwable.getClass().getName();
        this.message = throwable.getMessage();
        this.stackTraceElementProxyArray = ThrowableProxyUtil.steArrayToStepArray(throwable.getStackTrace());
        this.cyclic = false;

        alreadyProcessedSet.add(throwable);

        Throwable nested = throwable.getCause(); // ←getCause()はここで呼ばれている
        if (nested != null) {
            if (alreadyProcessedSet.contains(nested)) {
                this.cause = new ThrowableProxy(nested, true);
            } else {
                this.cause = new ThrowableProxy(nested, alreadyProcessedSet); // ←ここが78行目。同じコンストラクタが呼ばれている
                this.cause.commonFrames = ThrowableProxyUtil.findNumberOfCommonFrames(nested.getStackTrace(),
                        stackTraceElementProxyArray);
            }
        }
        // （以後省略）
```
確かに`getCause()`は呼んでたし、抜粋したコンストラクタを再び呼び出す処理もあった。
もしgetCause()で例外のモックを生成していたら、nestedがnullじゃなくなるのでループにハマりそう、、

## relaxed mockにしたときに`getCause()`の戻り値として例外のモックが返却されるの？
javaパッケージ内を覗いたらcauseプロパティはThrowable型（参照型）だった。
ので、適当なdata classを作成してその中のプロパティで他の型(nullable)を参照してみる。
そこでその型のオブジェクトが作成されてたらgetCauseでも同じことが起きてたんだと思える、、多分、、、

```kotlin
    // 適当に作成したdata class
    data class MockTest(val name:String, val otherClass: ReferredClass?)
    data class ReferredClass(val id:Int)
```

```kotlin
    // テストコード内に追加したコード
    test = mockk(relaxed = true)
    val testName = test.name
    val otherClass = test.otherClass
    println("test name is: $testName")
    println("this is created otherClass: $otherClass")
```

```text
    // printlnの結果
    test name is: 
    this is created otherClass: ReferredClass(child of #5#6)
```
ということでotherClassプロパティには参照している他のクラス(ReferredClass)のインスタンスが格納されているので、causeプロパティの時も同じことが起きていたと考えられる。

# まとめ
楽だからrelaxed mockをちょこちょこ使ってきてましたが、これからはちょっと気をつけていかないといけないですね。
基本的にはきちんとmock化して、プロパティの多いクラスはrelaxed mockを利用するといった使い分けが必要だと感じました、、！
あとはきちんとmockKや例外のクラスツリーを理解してもっと原因をはっきりさせたいな。