0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

結論

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

実行環境

ライブラリ

  • spring MVC
  • mockK(モックライブラリ)
  • JUnit5(テストフレームワーク)
  • slf4jとlogback(ロギングライブラリ)

コード

プロダクトコード

スローされた例外をキャッチして処理するクラス

@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クラス

class ApiError (val message: String, val errorCode: String)

テストコード

GlobalExceptionHandlerのテストコード
(キャッチする例外は代表でHttpMethodNotsupportedExceptionを選びました)

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)
  2. Logbackが例外の詳細な情報を取得するために getCause() メソッドを内部的に繰り返し呼び出す
  3. exceptionはrelaxed mock化された例外クラス(HttpRequestMethodNotSupportedException)であり、getCause()が呼ばれた際の振る舞いが明確に定義されていない
  4. mockK側がgetCause()が呼ばれた際に、新しい例外のモックが生成される
  5. 新しく作成された例外モックのgetCause()が呼ばれてまた新しい例外モックが生成される
  6. StackOverFlowを引き起こしてアプリが弾け飛ぶ

解決策

getCause()が呼ばれた時の振る舞いが明確に定義されていなかったのが原因なので、以下のコードをテスト関数に追加したら治りました!やったぜ。

    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)

っていうのが無限に呼ばれていたので見に行ってみる。

// 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でも同じことが起きてたんだと思える、、多分、、、

    // 適当に作成したdata class
    data class MockTest(val name:String, val otherClass: ReferredClass?)
    data class ReferredClass(val id:Int)
    // テストコード内に追加したコード
    test = mockk(relaxed = true)
    val testName = test.name
    val otherClass = test.otherClass
    println("test name is: $testName")
    println("this is created otherClass: $otherClass")
    // printlnの結果
    test name is: 
    this is created otherClass: ReferredClass(child of #5#6)

ということでotherClassプロパティには参照している他のクラス(ReferredClass)のインスタンスが格納されているので、causeプロパティの時も同じことが起きていたと考えられる。

まとめ

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?