結論
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)
}
}
原因
- プロダクトコードの以下の部分でexception(例外オブジェクト全体)がwarnに渡される
log.warn("error has occurred!!!", exception)
- Logbackが例外の詳細な情報を取得するために getCause() メソッドを内部的に繰り返し呼び出す
- exceptionはrelaxed mock化された例外クラス(HttpRequestMethodNotSupportedException)であり、
getCause()
が呼ばれた際の振る舞いが明確に定義されていない - mockK側が
getCause()
が呼ばれた際に、新しい例外のモックが生成される - 新しく作成された例外モックの
getCause()
が呼ばれてまた新しい例外モックが生成される - 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や例外のクラスツリーを理解してもっと原因をはっきりさせたいな。