kotest에서는 테스트가 실패하면 ErrorCollector
의 pushError
를 통해 실패한 테스트에 대한 에러 정보를 수집하고 있습니다. 수집한 에러를 기반으로 kotest는 AssertionError
객체를 생성고 그 과정에서 에러 메시지를 개발자가 디버깅하기 쉽도록 가공하여 제공하고 있습니다.
하지만 #4785 이슈에서는 assertSoftly
를 통해 테스트를 진행하는 경우 테스트 실패 시 표시되는 테스트의 위치가 잘못 나타는 문제가 제보되었고 이를 해결하고 기여하였던 과정을 기록해보려 합니다.
기존 코드 파악
// ---- ErrorCollector
internal fun List<Throwable>.toAssertionError(depth: Int, subject: Printed?): AssertionError? {
return when {
isEmpty() -> null
size == 1 && subject != null -> AssertionError("The following assertion for ${subject.value} failed:\n" + this[0].message)
size == 1 && subject == null -> AssertionError(this[0].message)
else -> MultiAssertionError(this, depth, subject)
}?.let {
stacktraces.cleanStackTrace(it)
}
}
// ---- MultiAssertionError
class MultiAssertionError(
errors: List<Throwable>,
depth: Int,
subject: Printed? = null,
) : AssertionError(createMessage(errors, depth, subject))
위의 코드는 AssertionError
객체를 만드는 기존 코드입니다.
- 에러의
size
가 1보다 많은 경우MultiAssertionError
객체를 생성할 때:
전달받은error
,depth
,subject
를 활용한createMessage
메서드를 사용하여 에러 메시지를 만들고 있습니다. - 에러의
size
가 1이고subject
가null
이 아닌 경우AssertionError
객체를 생성할 때:
단순히"The following assertion for ${subject.value} failed:\n" + this[0].message"
의 문자열을 통해 에러 메시지를 만들어내고 있습니다.
수정 방향
// ---- MultiAssertionError
internal fun createMessage(errors: List<Throwable>, depth: Int, subject: Printed?) = buildString {
append("The following ")
if (errors.size == 1) {
append("assertion")
} else {
append(errors.size).append(" assertions")
}
if (subject != null) {
append(" for ").append(subject.value)
}
append(" failed:\n")
for ((i, err) in errors.withIndex()) {
append(INDENT.repeat(depth)).append(i + 1).append(") ").append(err.message).append("\n")
stacktraces.throwableLocation(err)?.let {
append(INDENT.repeat(depth + 1)).append("at ").append(it).append("\n")
}
}
}
createMessage
메서드를 조금 더 자세히 보면 메시지를 만드는 과정에서 stacktraces.throwableLocation(err)
를 통해 에러의 위치를 에러 메시지에 추가하는 것을 확인할 수 있었습니다. 그리고 "The following assertion for ${subject.value} failed:\n" + this[0].message"
를 다시 보면 에러의 위치에 관한 처리를 하고 있지 않다는 것을 확인할 수 있었습니다.
이에 createMessage
를 MultiAssertionError
객체를 생성할 때만 사용하는 것이 아닌 AssertionError
객체를 생성할 때도 사용할 수 있도록 수정 방향을 잡을 수 있었습니다.
createMessage 메서드 분리
package io.kotest.assertions
import io.kotest.assertions.print.Printed
import io.kotest.mpp.stacktraces
private const val INDENT = " "
internal fun createMessage(errors: List<Throwable>, depth: Int, subject: Printed?) = buildString {
append("The following ")
if (errors.size == 1) {
append("assertion")
} else {
append(errors.size).append(" assertions")
}
if (subject != null) {
append(" for ").append(subject.value)
}
append(" failed:\n")
for ((i, err) in errors.withIndex()) {
append(INDENT.repeat(depth)).append(i + 1).append(") ").append(err.message).append("\n")
stacktraces.throwableLocation(err)?.let {
append(INDENT.repeat(depth + 1)).append("at ").append(it).append("\n")
}
}
}
이에 가장 먼저 수행한 것은 createMessage
메서드를 MultiAssertionError
안에서 별도의 파일로 분리하는 것이었습니다.
해당 작업은 기존 구현 코드를 별도의 파일로 분리하는 작업으로 간단히 수행할 수 있었습니다.
MultiAssertionError 정리
class MultiAssertionError : AssertionError {
// 기존 생성자
constructor(errors: List<Throwable>, depth: Int, subject: Printed? = null) : super(
createMessage(
errors,
depth,
subject
)
)
// 추가한 생성자
constructor(message: String) : super(message)
}
fun multiAssertionError(errors: List<Throwable>): Throwable {
val message = createMessage(errors, 0, null)
return failure(message, errors.firstOrNull { it.cause != null })
}
다음으로는 createMessage
메서드를 분리한 MultiAssertionError
를 정리하였습니다.
개인적으로 에러 객체를 만들 때 메시지를 내부에서 처리하기보다는 문자열 형태로 주입받는 것을 선호합니다.
그렇기에 이번 수정과정에서도 createMessage
메서드를 적용하여 넘겨받은 문자열 message
를 통해 객체를 생성할 수 있는 생성자를 추가하였고 error
, depth
, subject
의 여러 값을 받았던 기존의 생성자도 유지해 두었습니다.
ErrorCollector에 적용
internal fun List<Throwable>.toAssertionError(depth: Int, subject: Printed?): AssertionError? {
return when {
isEmpty() -> null
size == 1 && subject != null -> AssertionError(createMessage(this, depth, subject)) // createMessage 적용
size == 1 && subject == null -> AssertionError(this[0].message)
else -> MultiAssertionError(createMessage(this, depth, subject)) // createMessage 적용
}?.let {
stacktraces.cleanStackTrace(it)
}
}
마지막으로 ErrorCollector
에 분리한 createMessage
를 적용하여 MultiAssertionError
와 AssertionError
객체가 동일한 로직을 통해 에러메시지를 만들 수 있도록 수정하였습니다.
느낀 점
해당 이슈 제보자처럼 재현하기 쉬운 코드를 제공하는 것의 중요성을 느낄 수 있었습니다.
개발을 하며 사용하는 오픈소스의 코드를 디버깅하거나 지금처럼 오픈소스 기여를 시도해 볼 때 항상 엄청난 코드베이스와 테스트 코드에 압도당한 적이 많았습니다.
해당 이슈에서는 제보자가 제공한 코드를 통해 문자가 될 것이라 의심되는 부분을 빠르게 찾을 수 있었고 그것이 이렇게 첫 기여까지 이어질 수 있었던 가장 큰 이유였다 생각합니다.
통일성 있는 코드를 작성하는 것의 중요성을 느낄 수 있었습니다.
internal fun List<Throwable>.toAssertionError(depth: Int, subject: Printed?): AssertionError? {
return when {
isEmpty() -> null
size == 1 && subject != null -> AssertionError("The following assertion for ${subject.value} failed:\n" + this[0].message)
size == 1 && subject == null -> AssertionError(this[0].message)
else -> MultiAssertionError(this, depth, subject)
}?.let {
stacktraces.cleanStackTrace(it)
}
}
기존의 코드를 다시 한번 보면 MultiAssertionError
과 AssertionError
를 생성하는 과정에 전혀 통일성이 없습니다.
"동일한 AssertionError
타입의 에러를 만드는 것인데 생성과정이 왜 이렇게 다를까?"하는 생각을 할 수 있었다면 조금 더 빠르게 문제를 발견하거나, 통일성을 위해 수정하는 과정에서 문제를 사전에 찾을 수 있었지 않았을까? 하는 생각을 하였습니다.
사용하는 라이브러리를 재대로 파악하기 위해서는 기여하는 게 가장 효과가 좋을 것 같다는 생각을 하였습니다.
kotest를 기존에도 사용하고 있었지만 단지 많이 사용하는 메서드나 문서에 나온 내용 정도로만 그것을 파악하고 있었지 예외를 처리하는 방식에 대해서 까지는 알고 있지 못했습니다. 이번 이슈를 해결하기 위해 프로젝트를 클론 받고 디버깅해 보며 보다 kotest에 대해 잘 이해할 수 있었던 것 같습니다.
마냥 오픈소스 기여에 대한 동경만 가지고 있었는데 이번을 계기로 앞으로 더 많은 오픈소스 이슈에 기여하는 시발점이 되었으면 좋겠습니다 :)
'개발' 카테고리의 다른 글
네임드 락과 커밋 (0) | 2025.03.20 |
---|---|
동적인 락 순서에 의한 데드락 (0) | 2025.03.07 |
블로킹 큐와 프로듀서-컨슈머 패턴 (0) | 2025.03.06 |
SQS 리스너 구현기 (0) | 2025.01.16 |
도메인 이벤트 모듈 구성 (1) | 2025.01.16 |
kotest에서는 테스트가 실패하면 ErrorCollector
의 pushError
를 통해 실패한 테스트에 대한 에러 정보를 수집하고 있습니다. 수집한 에러를 기반으로 kotest는 AssertionError
객체를 생성고 그 과정에서 에러 메시지를 개발자가 디버깅하기 쉽도록 가공하여 제공하고 있습니다.
하지만 #4785 이슈에서는 assertSoftly
를 통해 테스트를 진행하는 경우 테스트 실패 시 표시되는 테스트의 위치가 잘못 나타는 문제가 제보되었고 이를 해결하고 기여하였던 과정을 기록해보려 합니다.
기존 코드 파악
// ---- ErrorCollector
internal fun List<Throwable>.toAssertionError(depth: Int, subject: Printed?): AssertionError? {
return when {
isEmpty() -> null
size == 1 && subject != null -> AssertionError("The following assertion for ${subject.value} failed:\n" + this[0].message)
size == 1 && subject == null -> AssertionError(this[0].message)
else -> MultiAssertionError(this, depth, subject)
}?.let {
stacktraces.cleanStackTrace(it)
}
}
// ---- MultiAssertionError
class MultiAssertionError(
errors: List<Throwable>,
depth: Int,
subject: Printed? = null,
) : AssertionError(createMessage(errors, depth, subject))
위의 코드는 AssertionError
객체를 만드는 기존 코드입니다.
- 에러의
size
가 1보다 많은 경우MultiAssertionError
객체를 생성할 때:
전달받은error
,depth
,subject
를 활용한createMessage
메서드를 사용하여 에러 메시지를 만들고 있습니다. - 에러의
size
가 1이고subject
가null
이 아닌 경우AssertionError
객체를 생성할 때:
단순히"The following assertion for ${subject.value} failed:\n" + this[0].message"
의 문자열을 통해 에러 메시지를 만들어내고 있습니다.
수정 방향
// ---- MultiAssertionError
internal fun createMessage(errors: List<Throwable>, depth: Int, subject: Printed?) = buildString {
append("The following ")
if (errors.size == 1) {
append("assertion")
} else {
append(errors.size).append(" assertions")
}
if (subject != null) {
append(" for ").append(subject.value)
}
append(" failed:\n")
for ((i, err) in errors.withIndex()) {
append(INDENT.repeat(depth)).append(i + 1).append(") ").append(err.message).append("\n")
stacktraces.throwableLocation(err)?.let {
append(INDENT.repeat(depth + 1)).append("at ").append(it).append("\n")
}
}
}
createMessage
메서드를 조금 더 자세히 보면 메시지를 만드는 과정에서 stacktraces.throwableLocation(err)
를 통해 에러의 위치를 에러 메시지에 추가하는 것을 확인할 수 있었습니다. 그리고 "The following assertion for ${subject.value} failed:\n" + this[0].message"
를 다시 보면 에러의 위치에 관한 처리를 하고 있지 않다는 것을 확인할 수 있었습니다.
이에 createMessage
를 MultiAssertionError
객체를 생성할 때만 사용하는 것이 아닌 AssertionError
객체를 생성할 때도 사용할 수 있도록 수정 방향을 잡을 수 있었습니다.
createMessage 메서드 분리
package io.kotest.assertions
import io.kotest.assertions.print.Printed
import io.kotest.mpp.stacktraces
private const val INDENT = " "
internal fun createMessage(errors: List<Throwable>, depth: Int, subject: Printed?) = buildString {
append("The following ")
if (errors.size == 1) {
append("assertion")
} else {
append(errors.size).append(" assertions")
}
if (subject != null) {
append(" for ").append(subject.value)
}
append(" failed:\n")
for ((i, err) in errors.withIndex()) {
append(INDENT.repeat(depth)).append(i + 1).append(") ").append(err.message).append("\n")
stacktraces.throwableLocation(err)?.let {
append(INDENT.repeat(depth + 1)).append("at ").append(it).append("\n")
}
}
}
이에 가장 먼저 수행한 것은 createMessage
메서드를 MultiAssertionError
안에서 별도의 파일로 분리하는 것이었습니다.
해당 작업은 기존 구현 코드를 별도의 파일로 분리하는 작업으로 간단히 수행할 수 있었습니다.
MultiAssertionError 정리
class MultiAssertionError : AssertionError {
// 기존 생성자
constructor(errors: List<Throwable>, depth: Int, subject: Printed? = null) : super(
createMessage(
errors,
depth,
subject
)
)
// 추가한 생성자
constructor(message: String) : super(message)
}
fun multiAssertionError(errors: List<Throwable>): Throwable {
val message = createMessage(errors, 0, null)
return failure(message, errors.firstOrNull { it.cause != null })
}
다음으로는 createMessage
메서드를 분리한 MultiAssertionError
를 정리하였습니다.
개인적으로 에러 객체를 만들 때 메시지를 내부에서 처리하기보다는 문자열 형태로 주입받는 것을 선호합니다.
그렇기에 이번 수정과정에서도 createMessage
메서드를 적용하여 넘겨받은 문자열 message
를 통해 객체를 생성할 수 있는 생성자를 추가하였고 error
, depth
, subject
의 여러 값을 받았던 기존의 생성자도 유지해 두었습니다.
ErrorCollector에 적용
internal fun List<Throwable>.toAssertionError(depth: Int, subject: Printed?): AssertionError? {
return when {
isEmpty() -> null
size == 1 && subject != null -> AssertionError(createMessage(this, depth, subject)) // createMessage 적용
size == 1 && subject == null -> AssertionError(this[0].message)
else -> MultiAssertionError(createMessage(this, depth, subject)) // createMessage 적용
}?.let {
stacktraces.cleanStackTrace(it)
}
}
마지막으로 ErrorCollector
에 분리한 createMessage
를 적용하여 MultiAssertionError
와 AssertionError
객체가 동일한 로직을 통해 에러메시지를 만들 수 있도록 수정하였습니다.
느낀 점
해당 이슈 제보자처럼 재현하기 쉬운 코드를 제공하는 것의 중요성을 느낄 수 있었습니다.
개발을 하며 사용하는 오픈소스의 코드를 디버깅하거나 지금처럼 오픈소스 기여를 시도해 볼 때 항상 엄청난 코드베이스와 테스트 코드에 압도당한 적이 많았습니다.
해당 이슈에서는 제보자가 제공한 코드를 통해 문자가 될 것이라 의심되는 부분을 빠르게 찾을 수 있었고 그것이 이렇게 첫 기여까지 이어질 수 있었던 가장 큰 이유였다 생각합니다.
통일성 있는 코드를 작성하는 것의 중요성을 느낄 수 있었습니다.
internal fun List<Throwable>.toAssertionError(depth: Int, subject: Printed?): AssertionError? {
return when {
isEmpty() -> null
size == 1 && subject != null -> AssertionError("The following assertion for ${subject.value} failed:\n" + this[0].message)
size == 1 && subject == null -> AssertionError(this[0].message)
else -> MultiAssertionError(this, depth, subject)
}?.let {
stacktraces.cleanStackTrace(it)
}
}
기존의 코드를 다시 한번 보면 MultiAssertionError
과 AssertionError
를 생성하는 과정에 전혀 통일성이 없습니다.
"동일한 AssertionError
타입의 에러를 만드는 것인데 생성과정이 왜 이렇게 다를까?"하는 생각을 할 수 있었다면 조금 더 빠르게 문제를 발견하거나, 통일성을 위해 수정하는 과정에서 문제를 사전에 찾을 수 있었지 않았을까? 하는 생각을 하였습니다.
사용하는 라이브러리를 재대로 파악하기 위해서는 기여하는 게 가장 효과가 좋을 것 같다는 생각을 하였습니다.
kotest를 기존에도 사용하고 있었지만 단지 많이 사용하는 메서드나 문서에 나온 내용 정도로만 그것을 파악하고 있었지 예외를 처리하는 방식에 대해서 까지는 알고 있지 못했습니다. 이번 이슈를 해결하기 위해 프로젝트를 클론 받고 디버깅해 보며 보다 kotest에 대해 잘 이해할 수 있었던 것 같습니다.
마냥 오픈소스 기여에 대한 동경만 가지고 있었는데 이번을 계기로 앞으로 더 많은 오픈소스 이슈에 기여하는 시발점이 되었으면 좋겠습니다 :)
'개발' 카테고리의 다른 글
네임드 락과 커밋 (0) | 2025.03.20 |
---|---|
동적인 락 순서에 의한 데드락 (0) | 2025.03.07 |
블로킹 큐와 프로듀서-컨슈머 패턴 (0) | 2025.03.06 |
SQS 리스너 구현기 (0) | 2025.01.16 |
도메인 이벤트 모듈 구성 (1) | 2025.01.16 |