가치있는 테스트를 위한 전략과 구현
개발자로서 우리는 비즈니스를 구현할 때는 물론이고 리팩터링을 하거나 외부 라이브러리 및 프레임워크 버전을 최신화하는 등 많은 순간에서 테스트의 필요성을 느낍니다. 하지만 테스트에는 많은 장점이 있음에도 불구하고 테스트에 대한 이상과 현실의 괴리는 상당합니다. 현실에서는 테스트 코드 자체가 없는 경우가 다반사일 뿐만 아니라, 테스트가 오히려 장애물이 되곤 하죠. 오늘은 토스에서 테스트 전략을 수립하고 구현한 과정을 자세히 살펴볼게요.
1. 가치 있는 테스트 코드에 대하여
테스트에 대한 이상과 현실
신규 비즈니스를 개발하는 과정에서는 테스트도 새롭게 작성해야 합니다. 이때 새로운 테스트는 출시될 기능에 문제가 없는지를 확인하고, 동시에 기존의 테스트는 회귀 테스트로서 동작합니다. 회귀 테스트(Regression Test)란 기존에 작동하던 기능에 문제가 없는지를 검증하는 테스트로, 사전에 작성된 테스트들은 거의 회귀 테스트의 개념을 갖습니다.
리팩터링을 하거나 외부 도구의 버전을 최신화하는 등 비즈니스 외적인 변경을 하는 경우에 테스트는 변경에 문제가 없는지 피드백을 줍니다. 또한 테스트는 예제 코드이자 문서 자료의 역할을 해줄 수 있으며, 단순 디버깅과 달리 공유된 컨텍스트를 제공하여 문제를 빠르게 재현하고 해결할 수 있게 돕죠. 그 외에도 시스템의 첫 고객으로서 설계를 개선해줄 뿐만 아니라 특정 도구의 학습을 도와주기도 합니다. 물론 검증 대상인 비즈니스 자체가 변경되면 그 가치가 퇴색될 수도 있지만, 그럼에도 우리는 테스트의 필요성과 그 가치를 잘 알고 있습니다.
하지만 현실에서 이러한 테스트의 가치는 이상적으로만 보입니다. 현실에서는 테스트 코드 자체가 없는 경우가 다반사일 뿐만 아니라 산발적 코드로 인해 파악도 어렵고 문서 자료로의 역할을 기대하기 어렵습니다. 또한 실패하면 안 되는 상황에서도 실패하여 생산성이 저하되며, 테스트 관리 비용은 오히려 개발의 병목점이 되곤 합니다. 이렇듯 테스트가 현실에서 올바른 가치를 지니지 못하는 여러 가지 이유들이 존재하는데, 크게 보면 다음과 같습니다.
- 작성해야 하는 테스트의 절대적인 양이 많음
- 구현에 강결합되어 깨지기 쉬움
- 테스트 코드 자체가 이해하기 어렵게 작성되어 있음
- 테스트가 느리게 실행되며 간헐적으로 실패함
- 기타 등등
먼저 우리가 기본적으로 가져갈 수 있는 계층만 4계층(controller, usecase, domain, persistence)인데, 각 계층 별로 성공/실패 케이스를 모두 고려하면 작성해야 하는 절대적인 테스트 수 자체가 많아 부담스럽죠. 또한 프로덕션 코드와 강결합된 깨지기 쉬운 테스트(Fragile Test)라면 테스트가 실패하면 안 되는 상황에서도 실패하여 우리를 괴롭힙니다. 그리고 많은 준비 동작(stubbing)으로 인해 테스트는 복잡도가 높아서, 그 의도와 역할을 파악하기 어려우며, 느리고 비결정적인 특성들로 인해 시간을 허비하기도 합니다.
위와 같은 상황들로 인해 결국 우리는 테스트의 가치를 누리지 못하고 기피하게 되는 것입니다.
좋은 테스트와 그렇지 않은 테스트
오늘날 테스트 자체에 대한 인식은 상당히 높아져서 테스트 코드를 대체로 작성하고 있지만, 그것이 “좋은 테스트”인지는 또 다른 문제입니다. “좋은 테스트”는 일반 테스트와 다른데, 잘 알려진 대로 좋은 테스트는 먼저 FIRST 규칙을 따라야 합니다.
- Fast: 테스트는 빠르게 동작하여 자주 돌릴 수 있어야 함
- Independent: 각각의 테스트는 독립적이며 서로 의존해서는 안 됨
- Repeatable: 어느 환경에서도 반복 가능해야 함
- Self-Validating: 테스트는 성공 또는 실패로 bool 값으로 결과를 내어 자체적으로 검증되어야 함
- Timely: 테스트는 적시에 즉, 테스트하려는 실제 코드를 구현하기 직전에 구현해야 함
또한 완전성과 간결성과 같은 특징들은 테스트를 명확하게 하여, 좋은 테스트가 되는 데 일조합니다. "완전한 테스트(complete test)"란 읽는 사람이 결과에 도달하기까지 필요한 모든 정보를 본문에 담고 있는 테스트를 말하며, "간결한 테스트(concise test)"란 코드가 복잡하거나 산만하지 않고, 불필요한 정보는 포함하지 않는 테스트를 의미합니다.
그 외에도 좋은 테스트를 위한 많은 특성들이 존재하지만, 결국 좋은 테스트는 비즈니스적으로 가치 있는 테스트(Valuable Test)여야 합니다. 비즈니스에 대한 검증 또는 문서화 등과 같은 가치를 제공하는 테스트를 작성해야지, 커버리지 향상 등의 목적으로 필요하지도 않은 테스트에 많은 시간을 소비하는 것은 의미가 없습니다.
가치 있는 테스트 코드 작성 전략
위와 같은 상황들을 개선하고, 가치 있는 테스트 코드를 작성하기 위해 저희 팀은 다음과 같은 전략을 수립했어요.
- 작성 가치를 고려하여 선택적으로 작성하기
- 최대한 실용적으로 작성하기
- 확실한 목적에 따라 작성하기
먼저 작성될 테스트에 대한 가치 판단을 하고, 그 가치가 상대적으로 덜하다면 과감하게 작성하지 않기로 했습니다. 대표적으로 서비스의 핵심 기능은 작성할 가치가 있습니다. 왜냐하면 일반적으로 약 20%에 해당하는 시스템의 핵심 기능이 사실상 주된 사용 및 변경 지점이며, 잠재적 버그 발생 가능성과 코드 읽는 횟수 등이 가장 높아 테스트의 도움을 크게 받을 수 있기 때문입니다.
반대 예시로 이벤트 특성으로 인해 제품 수명이 짧고 수동 테스트로 대체하는 것이 효율적인 경우에는 회귀적 관점에서 존재 가치가 적으므로 작성을 포기할 수 있습니다. 작성 가치를 현명하게 고려하면 가치 있는 20%의 테스트로 80% 이상의 신뢰성을 얻을 수 있습니다. 테스트 코드에 대한 관리 비용까지 고려하여 모든 코드를 대상으로 테스트를 작성하는 것이 아닌, 존재 가치가 높은 테스트들을 추려 부분적으로 작성하기로 했습니다.
그다음으로 실용적으로 테스트를 작성하고자 했습니다. 테스트 시나리오는 기하급수적으로 늘어날 수 있고 이를 계층 별로 작성하려면 양 자체가 상당할 것입니다. 테스트 역시 관리 대상이므로 시스템과 함께 지속적으로 성장하려면 먼저 절대적인 양 자체를 최소화할 필요가 있습니다. 만약 하나의 테스트로 모든 계층을 커버하면 부담을 최소화할 수 있을 것이고, 문서화의 역할까지 해준다면 일석이조라고 생각했습니다. 따라서 적지만 많은 계층을 커버할 수 있는 통합 테스트를 작성하기로 했어요.
마지막으로 확실한 목적에 따라 테스트를 구분하여 작성 가치와 의도를 분명히 하고자 했고, 현재까지는 크게 유스케이스 테스트, 도메인 정책 테스트, 직렬화/역직렬화 테스트 등으로 세분화했습니다. 예를 들어 직렬화/역직렬화 테스트는 실패할 경우 캐시 버저닝 필요성에 대한 피드백을 받고자 한 것이죠.
그러면 이어서 세분화된 테스트 별로 작성 방법을 살펴보겠습니다.
2. 도메인 정책 테스트 코드 작성하기
도메인 정책 테스트는 특정한 도메인 정책이 올바른지 빠르게 검증합니다. 테스트가 비즈니스 정책을 검증하므로 문서화의 역할까지 할 수 있습니다.
단위 테스트로 작성하기
여기서 도메인 정책은 도메인 객체 내에 표현되는 비즈니스 정책입니다. 예를 들어 “프로모션 참여 대상은 18세 이하”라는 정책은 다음과 같이 도메인 엔티티 내에 표현될 수 있습니다.
data class Member(
val name: String,
val age: Int,
) {
fun canParticipatePromotion(): Boolean {
return age <= MAX_PROMOTION_AGE
}
companion object {
const val MAX_PROMOTION_AGE = 18
}
}
해당 정책들을 검증하는 경우에는 불필요한 구성들이 없도록 단위 테스트로 작성했습니다. 참고로 테스트 대상을 SUT(System-Under-Test)라고도 부르며, 경계 값을 중심으로 테스트하면 더욱 좋은 테스트를 작성할 수 있습니다.
class MemberTest : FunSpec({
context("canParticipatePromotion") {
test("프로모션 참가는 18세 이하까지 가능함") {
listOf(12, 17, 18).forEach {
val member = Member(
name = "MangKyu",
age = it,
)
member.canParticipatePromotion() shouldBe true
}
}
test("프로모션 참가는 19세 이상부터 불가능함") {
listOf(19, 20, 30).forEach {
val member = Member(
name = "MangKyu",
age = it,
)
member.canParticipatePromotion() shouldBe false
}
}
}
})
테스트 대역이 아닌 실제 객체 사용하기
SUT와 협력이 필요한 객체가 존재할 수 있는데, 이를 협력자(Collaborator)라고 불러요. 협력자의 경우 실제 객체를 사용할 수도 있지만, 테스트 대역을 사용할 수도 있습니다.
Gerard Meszaros는 영화를 찍을 때 위험한 장면에서 대역 배우들이 연기해주는 것에서 착안하여, 테스트 목적으로 실제 구현 대신 사용되는 가짜 객체를 통칭하는 테스트 대역(Test Double)이라는 용어를 만들었고, 이를 다섯 가지로 세분화했습니다.
- Dummy Object
- 객체는 전달되지만 실제로 사용되지는 않음
- 일반적으로 매개변수 목록을 채우는 데만 사용됨
- Spy
- 상호작용 객체들이 어떻게 호출되었는지에 따라 일부 정보를 기록함
- 실제 객체의 동작을 변경하지 않으면서도 호출 여부를 추적할 수 있음
- Stub
- 사전에 함수가 반환할 값을 지정하여 호출 시에 사전에 준비된 답변을 제공하는 객체
- 의존하는 객체로부터 어떤 값을 받아야 하는 경우 적합함
- Mock
- 멤버 함수에 대한 호출을 기록하여 어떻게 상호작용하는지를 검증하는 객체
- 부수 효과를 일으키는 객체를 테스트하는데 적합함
- Fake
- 실제 구현과 비슷하게 동작하지만 프로덕션에는 적합하지 않은 객체
- 대표적으로 인메모리 기반의 데이터베이스가 해당함
협력자로서 실제 객체를 사용하고자 하는 집단을 고전파(Classicist), 테스트 대역을 사용하고자 하는 집단을 런던파(London School 또는 Mockist)라고 합니다. 예를 들어 토스 포인트 지급 시에 포인트 지급 코드가 미성년자일 경우와 그렇지 않은 경우 달라지는 로직이 있다고 생각해볼게요.
data class TossPoint(
val amount: BigDecimal,
val targetMember: Member,
) {
fun getPointCode(): Long {
if (targetMember.isChild()) {
return 1523L
}
return 5823L
}
}
이때 실제 객체를 사용할 지 또는 테스트 대역을 사용할 지에 따라 테스트 작성을 다르게 가져갈 수 있습니다.
class TossPointTest : FunSpec({
// Classicist
test("대상이 child일 경우 pointCode는 1523") {
val tossPoint = TossPoint(
amount = BigDecimal.ONE,
targetMember = Member(
name = "MangKyu",
age = 19,
)
)
tossPoint.getPointCode() shouldBe 1523L
}
// Mockist
test("대상이 child가 아닐 경우 pointCode는 5823") {
val member = mockk<Member>()
every { member.isChild() } returns false
val tossPoint = TossPoint(
amount = BigDecimal.ONE,
targetMember = member
)
tossPoint.getPointCode() shouldBe 5823L
}
}
테스트 대역을 사용할 때 Mockito와 같은 모의 객체 프레임워크의 모의 객체를 사용할 수 있는데, 이를 활용하면 테스트 작성 속도를 높이고 복잡한 의존성 작성을 피할 수 있는 등의 장점이 있습니다.
하지만 SUT가 어떠한 객체와 협력하는지, 협력 객체의 어떤 메시지를 호출하는지, 심지어 응답은 어떻게 나와야 하는지 등의 세부 정보를 알게 됩니다. 즉, 구현과 강결합이 생기게 되어 리팩터링 등의 비즈니스 외적인 작업에서도 테스트가 실패하며, 지나치게 복잡해지는 준비 동작(stubbing)으로 인해 테스트가 문서화의 역할을 하지 못하는 등의 이유로 실질적인 도움을 얻기 어렵습니다.
이러한 경험들을 바탕으로 실제 객체를 활용하는 것이 더욱 유용할 것이라고 판단하여 모의 객체 프레임워크 사용을 최소화하고, 가능한 한 실제 객체를 활용하고자 했습니다. 실제로 구글에서도 과거에 모의 객체를 과도하게 활용하여 많은 문제들이 생겼고, 이러한 교훈을 바탕으로 현재는 가급적 실제 객체를 활용하자는 기조가 형성됐다고 합니다. 물론 테스트 대역과 모의 객체가 적합한 상황도 존재하는데, 어떠한 케이스인지 아래에서 살펴볼 예정입니다.
참고로 오늘날에 모의 객체 프레임워크의 테스트 대역을 사용하는 것을 “모킹합니다(mocking)”라고도 얘기합니다. 따라서 모킹이라는 것이 테스트 대역 중 하나인 목을 의미하는지 혹은 테스트 대역의 사용을 의미하는지 문맥에 맞게 해석할 필요가 있습니다.
test-fixtures 라이브러리
도메인 엔티티 생성을 위한 코드는 다른 모듈에서도 필요해질 수 있는데, 이때 test-fixtures 라이브러리를 활용할 수 있습니다. 자세한 내용은 다른 토스 테크 아티클을 참고해주세요.
3. 유스케이스 테스트 코드 작성하기
유스케이스 테스트는 인수 테스트로서 사용자의 여정이 올바르게 동작하는지를 검증합니다.
통합 테스트 및 인수 테스트로 작성하기
우리의 코드는 고객으로부터 비즈니스 가치를 창출하는 것이 목표이므로, 유스케이스에 대해 특정 사용자의 여정(user journey)이 의도한 대로 이루어지는지 보장하도록 인수 테스트를 필요로 하게 됐습니다. 인수 테스트(Acceptance Test)는 고객 관점에서 주어진 인수 조건에 따라 소프트웨어가 올바르게 동작하는지 검증하며, 작성된 테스트가 모두 통과하면 이론적으로 소프트웨어는 완전하며 고객이 수락할 준비가 된 것입니다. 인수 테스트는 사용자 여정을 따라가므로 테스트가 문서화의 역할까지 할 수 있습니다.
@AcceptanceTest(["acceptance/checkAccount.json"])
class CheckAccountControllerTest : FunSpec({
context("수취성명조회") {
test("1회 충전 한도를 초과한 경우") {
val 수취성명조회결과 = 수취성명조회(amount = 200001)
수취성명조회결과["resultCode"] shouldBe "DEPOSIT_LIMIT_EXCEEDED"
}
test("블랙리스트인 경우") {
val 수취성명조회결과 = 수취성명조회(userId = 97687966, accountNo = 11503235)
수취성명조회결과["resultCode"] shouldBe "VA_UNAVAILABLE"
}
test("성공") {
val 수취성명조회결과 = 수취성명조회(amount = 20000)
수취성명조회결과["resultCode"] shouldBe "OK"
}
}
extension(SpringExtension)
})
private fun 수취성명조회(
userId: Long = 1,
accountNo: Long = 1283912,
amount: Long = 10000L,
): Map<String, String> {
return RestAssured
.given().log().all().contentType(MediaType.APPLICATION_JSON_VALUE)
.header(TossHeaders.USER_ID, userId)
.`when`()
.body(
mapOf(
"accountNo" to accountNo,
"amount" to amount,
)
)
.post("/internal/check-account")
.then().log().all().extract()
.jsonPath().getMap("success")
}
인수 테스트는 테스트가 제품의 첫 번째 고객이라는 관점에서 테스트 현실성을 높이고자 통합 테스트로 작성되었으며 테스트 전용 로직은 최소화하고자 했습니다. 이를 위해 별도의 @AcceptanceTest
애노테이션을 만들었고, 네트워크 호출을 위해서는 fluent한 방식으로 코드를 작성할 수 있는 RestAssured를 활용했습니다.
또한 테스트는 API 명세 외에 세부 구현을 모르는 블랙박스 테스트로 작성되었고, 이를 통해 비즈니스 변경이 아니라면 테스트가 실패하지 않도록 했습니다. API 호출 시에 DTO 클래스가 아닌 Map을 사용하는 것도 블랙박스 테스트를 위해 의도된 부분입니다.
애노테이션 기반의 테스트 컨텍스트 추상화
스프링 부트는 설정보다 관례를(Convention Over Configuration) 선호하는 방식으로 설계되어 있습니다. 따라서 이러한 철학에 맞게 커스텀 애노테이션을 추가하여 테스트가 동작될 수 있도록 했습니다.
@Retention(AnnotationRetention.RUNTIME)
@ActiveProfiles("test")
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
classes = [MainApplication::class],
properties = ["spring.profiles.active=test"]
)
@AutoConfigureMetrics
@TestExecutionListeners(
value = [AcceptanceTestExecutionListener::class],
mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
@Import(LazyInitExcludeConfiguration::class)
annotation class AcceptanceTest(
@get:AliasFor("setUpScripts")
val value: Array<String> = [],
val setUpScripts: Array<String> = []
)
@Profile("test")
@Configuration
private class LazyInitExcludeConfiguration {
@Bean
fun lazyInitializationExcludeFilter(): LazyInitializationExcludeFilter {
return LazyInitializationExcludeFilter.forBeanTypes(
AESCipherSupport::class.java,
)
}
}
먼저 @ActiveProfiles("test")
를 통해 profile을 test로 지정하여 테스트 대역들이 사용될 수 있도록 했습니다. 앞서 설명하였듯 가급적이면 테스트의 현실성을 높이고자 실제 객체를 활용했지만, 일부 테스트 대역을 사용해야 하는 경우들도 있었습니다. 자세한 케이스들은 뒤에서 다시 살펴볼게요.
그 다음으로 스프링 컨테이너 기반의 통합 테스트 환경을 위해 @SpringBootTest
를 활용하고 있고, 메트릭 관련 구성을 위해 @AutoConfigureMetrics
를 추가했습니다.
또한 인수 테스트를 위한 테스트 전/후 처리가 필요하여 AcceptanceTestExecutionListener
라는 TestExecutionListener
를 구현했는데, 해당 구현과 setUpScripts
부분도 아래에서 다시 살펴볼 예정입니다.
마지막으로는 테스트 속도 최적화를 위해 빈이 필요한 순간에 lazy하게 생성될 수 있도록 spring.main.lazy-initialization=true
옵션을 활용했습니다. 이때 일부 빈들의 경우 Eager하게 생성되지 않아 테스트가 실패하는 문제가 있었고, Eager하게 생성되어야 하는 빈들을 명시적으로 지정하기 위해 LazyInitExcludeConfiguration
이 추가됐습니다.
커스텀 에노테이션을 통해 의도한 또 하나의 포인트는 테스트 컨텍스트의 재사용입니다. 각각의 테스트마다 스프링 컨테이너를 띄우면 테스트 속도가 느려지므로, 스프링은 컨텍스트를 캐싱하여 동일한 컨텍스트를 활용하는 테스트끼리 공유하도록 했습니다. 이를 통해 초기 1회 테스트 비용을 지불하면, 이후의 테스트들에서는 훨씬 빠른 실행속도로 처리될 수 있습니다. @AcceptanceTest
애노테이션은 이러한 테스트 컨텍스트의 재사용 의도가 포함되어 있는 것입니다.
참고로 테스트 컨텍스트는 고유한 매개변수 조합이 캐시 키로 활용됩니다. 다음의 기능들은 매개 변수 조합이 달라지게 하여 새로운 컨텍스트를 띄우게 되므로 주의가 필요합니다. 자세한 내용은 공식 문서에서 다루고 있습니다.
@ActiveProfiles
의 활용contextCustomizers
의 활용(@MockBean
이나@SpyBean
을 활용해도 동일함)@ContextConfiguration
의locations
,classes
등의 활용@TestPropertySource
의propertySourceDescriptors
,propertySourceProperties
활용- 기타 등등
테스트 데이터 셋업과 테스트 격리를 위한 TestExecutionListener 추가
테스트 독립성을 위해 각각의 테스트는 격리되어야 하며, 이를 위해 테스트 별로 데이터를 set-up(설정) 하고 tear-down(해제) 시켜야 합니다. 이를 위해 table 이름을 key로, entity 정보 들을 values로 담고 있는 JSON 포맷 파일을 AcceptanceTest
에서 지정하여 처리할 수 있도록 했습니다.
{
"charging_channel_transaction": [
{
"user_id": 1,
"tx_no": 1,
"tx_summary": "Pw/0EuV0TDxOQ1UPdY/t1A==",
"amount": 1404,
"tx_ts": "2022-03-22 21:02:27",
}
],
"charging_channel": [
{
"account_id": null,
"user_id": 1,
"expiration_date": "2033-01-01",
"reg_ts": "2023-08-21 23:07:42"
},
{
"account_id": 18389,
"user_id": 1,
"expiration_date": "2033-01-01",
"reg_ts": "2023-08-21 23:07:42"
}
]
}
이를 통해 테스트를 접한 사람이 필요한 정보를 쉽게 찾아갈 수 있으며, 테스트를 위한 정보들이 테스트 클래스 안에서 표현되는 완전한 테스트(complete test)에 보다 가까워지게 됐습니다.
초기에는 테스트 데이터 셋업을 위해 스프링의 @Sql
애노테이션을 활용했습니다.
@AcceptanceTest
@Sql(scripts = ["classpath:acceptance/checkAccount.sql"], config = SqlConfig(dataSource = "dataSource"))
class CheckAccountInternalControllerTest : FunSpec({
)}
하지만 SQL 파일을 통해 쿼리로 데이터를 관리하다 보니, 컬럼에 매핑되는 값을 식별하기 어려울 뿐만 아니라, 컬럼이 추가되는 경우에 대응이 어려웠습니다. 참고로 인텔리제이(IntelliJ) 2024.2 버전에서는 SQL 파일에서 어떤 컬럼에 대한 값인지 hint가 뜨도록 개선되었지만, 컬럼 추가 및 변경에 대한 대응은 여전히 번거로웠습니다.
따라서 JSON 포맷으로 데이터를 관리하는 것이 훨씬 유연하고 용이하다고 판단하였고, 이를 처리해주는 TestExecutionListener를 추가했습니다.
class AcceptanceTestExecutionListener : AbstractTestExecutionListener() {
override fun beforeTestClass(testContext: TestContext) {
val serverPort = testContext.applicationContext.environment
.getProperty("local.server.port", Int::class.java)
?: throw IllegalStateException("localServerPort cannot be null")
RestAssured.port = serverPort
}
override fun beforeTestMethod(testContext: TestContext) {
val jdbcTemplate = testContext.applicationContext.getBean(JdbcTemplate::class.java)
val objectMapper = testContext.applicationContext.getBean(ObjectMapper::class.java)
TestContextAnnotationUtils.getMergedRepeatableAnnotations(testContext.testClass, AcceptanceTest::class.java)
.map { it.setUpScripts }
.forEach { files ->
files.forEach { file ->
setUpDatabase(jdbcTemplate, objectMapper, file)
}
}
}
private fun setUpDatabase(jdbcTemplate: JdbcTemplate, objectMapper: ObjectMapper, filePath: String) {
val parsedJsonSql = objectMapper.readValue(
StreamUtils.copyToString(ClassPathResource(filePath).inputStream, Charset.defaultCharset()),
Map::class.java
) as Map<String, List<Map<String, String>>>
createInsertQueries(parsedJsonSql).forEach { query ->
jdbcTemplate.execute(query)
}
}
private fun createInsertQueries(parsedJsonSql: Map<String, List<Map<String, Any>>>): List<String> {
return parsedJsonSql.flatMap { (tableName, rows) ->
rows.map { row ->
val columns = row.keys.joinToString(", ")
val values = row.values.joinToString(", ") { value: Any? ->
formatValue(value)
}
"INSERT INTO $tableName ($columns) VALUES ($values);"
}
}
}
private fun formatValue(value: Any?): String {
return when {
value == null -> "NULL"
value is String && value.lowercase() == "now()" -> "now()"
else -> "'$value'"
}
}
override fun afterTestMethod(testContext: TestContext) {
val jdbcTemplate = testContext.applicationContext.getBean(JdbcTemplate::class.java)
val truncateQueries = getTruncateQueries(jdbcTemplate)
truncateTables(jdbcTemplate, truncateQueries)
}
private fun getTruncateQueries(jdbcTemplate: JdbcTemplate): List<String> {
return jdbcTemplate.queryForList(
"SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') AS q FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC'",
String::class.java
)
}
private fun truncateTables(jdbcTemplate: JdbcTemplate, truncateQueries: List<String>) {
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0")
truncateQueries.forEach { query -> jdbcTemplate.execute(query)}
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1")
}
}
테스트 격리성을 위해 테스트 메서드 실행 직전(beforeTestMethod
)에 JSON 파일을 읽어 INSERT 하고, 테스트 실행 직후(afterTestMethod
)에 사용자 테이블을 모두 TRUNCATE 하도록 했습니다. 참고로 테스트에서는 H2 데이터베이스를 사용하고 있어서, 위의 문법들은 모두 H2를 기반으로 되어 있습니다.
또한 위의 코드에서 보이듯이 beforeTestClass
단계에서는 테스트를 위해 사용되는 포트를 RestAssured에 설정해주는 부분도 존재합니다.
이렇게 추가된 AcceptanceTestExecutionListener
를 등록해주는 부분은 위에서 살펴본 AcceptanceTest
구현의 다음 부분입니다.
@TestExecutionListeners(
value = [AcceptanceTestExecutionListener::class],
mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
여기서 mergeMode는 스프링이 미리 만들어둔 TestExecutionListener에 대한 옵션입니다. 스프링은 DependencyInjectionTestExecutionListener
, TransactionalTestExecutionListener
등 범용적으로 활용되는 리스너들을 구현해두었습니다(자세한 구현은 공식 문서에서 볼 수 있습니다.). 만약 mergeMode를 MERGE_WITH_DEFAULTS
로 설정하지 않으면 value에 선언된 리스너 외에 아무 것도 등록되지 않고, 이로 인해 의존성 주입 등에 실패할 수 있습니다.
통합 테스트에서 테스트 대역의 활용
앞서 설명하였듯 테스트의 현실성과 충실성을 위해 가급적 실제 환경에서 실제 객체를 활용하고자 했지만 불가피하게 테스트 대역을 활용해야 하거나 테스트 대역이 더욱 적합한 순간들이 있었습니다. 따라서 다음과 같은 선택 기준을 세우게 되었는데, 이는 절대적인 기준이 아닌 판단 기준일 뿐이며, 팀 내에서도 상황에 맞게 유연하게 전략을 선택하고 있습니다.
- 토스 외부의 서비스인 경우
- Fake 객체를 구현하기
- 토스 내부의 서비스인 경우
- 비즈니스 외적인 부수효과가 존재하는 경우: Dummy 객체를 사용하기
- 데이터 확보가 어려운 경우: 모의 객체 프레임워크를 통해 Stub 사용하기
- 그 외 대부분의 경우: 실제 객체를 사용하기
외부 서비스의 경우 원하는 대로 조작하기가 불가능할 뿐만 아니라 심지어 테스트를 위한 환경도 없을 수 있습니다. 또한 불안정한 네트워크 통신으로 인해 테스트가 비결정적(indeterministic)이게 될 수도 있습니다. 이러한 상황에서는 원활한 테스트 작성 및 구현이 어려워 Fake 객체로 대체했습니다.
interface IsDirectIssueTargetPort {
fun isTarget(request: IsDirectIssueTargetRequest): Boolean
}
@Component
@Profile("!test")
internal class IsDirectIssueTargetAdapter(
private val telegramSendService: TelegramSendService,
) : IsDirectIssueTargetPort {
override fun isTarget(
request: IsDirectIssueTargetRequest,
): Boolean {
return TelegramDeserializer.parseObject(
telegramSendService.sendIssue(request),
IsDirectIssueTarget::class.java
).isSuccess()
}
@Component
@Profile("test")
internal class TestIsDirectIssueTargetAdapter : IsDirectIssueTargetPort {
override fun isTarget(request: IsDirectIssueTargetRequest): Boolean {
return request.userNo != -1L
}
}
그 다음으로 비즈니스 외적인 부수효과(알림 발송 등)가 발생하는 기능들에 대해서는 Dummy 객체를 활용했습니다. 매번 테스트가 실행될 때마다 푸시 또는 사내 메신저 알림이 발송됩니다면, 개발자의 집중력을 빼았고 최종적으로 테스트를 비활성화시킬 수도 있습니다. 따라서 이러한 경우에는 구현을 비어 두어 테스트에서는 동작하지 않도록 했습니다.
@Profile("!test")
@Component
class MessengerAdapter(
private val messengerProperties: MessengerProperties,
private val webClient: WebClient,
) : SendMessagePort {
override fun send(message: String) {
webClient.call<String>(
host = messengerProperties.host,
method = HttpMethod.POST,
uri = messengerProperties.alertUrl,
requestBody = SendMessageAlertPayload(message),
)
}
}
@Profile("test")
@Component
class TestMessengerAdapter : SendMessagePort {
override fun send(message: String) {
// Do nothing.
}
}
그 외에 토스 내부의 서비스 임에도 데이터 확보가 어려운 케이스들이 있었습니다. 예를 들어 가입 7일 이내의 사용자가 필요한 경우가 있었는데요. 복잡한 가입 절차로 인해 데이터 확보가 어려웠을 뿐만 아니라 시간이 지남에 따라 자연스럽게 가입 7일 이후가 되어 테스트가 비결정적이게 됐습니다. 이때 외부 서버에서 테스트를 위한 기능을 추가하거나 팀 내부적으로 모의 객체 프레임워크를 결합시키는 방법이 있었고, 다음의 이유로 후자의 방법을 선택했습니다.
- 사용자 서버는 전사 의존적인 서비스라, 문제 발생 시에 전면 장애로 이어질 수 있음
- 테스트를 위한 API를 호출하게 되면 테스트의 현실성이 떨어짐
따라서 다음과 같이 모의 객체 프레임워크를 통합하여 테스트를 작성했습니다.
@AcceptanceTest("acceptance/getIssueCardStatus.json")
@Import(CardAppControllerTestConfiguration::class)
class CardAppControllerTest(
private val loadMinorPort: LoadMinorPort,
) : FunSpec({
context("카드 발급 상태 조회") {
test("발급없이 가입 이후 7일이 지나면 null 반환") {
val minor = minor().copy(
regTimestamp = LocalDateTime.now().minusDays(8L)
)
every { loadMinorPort.loadByUserIdOrThrow(minor.userId) } returns minor
val 발급상태 = 발급상태조회(minor.userId)
발급상태 shouldBe null
}
test("발급없이 가입 이후 7일 전에는 스킴으로 랜딩") {
val minor = minor().copy(
regTimestamp = LocalDateTime.now().minusDays(7L).plusHours(1L)
)
every { loadMinorPort.loadByUserIdOrThrow(minor.userId) } returns minor
val 발급상태 = 발급상태조회(minor.userId)!!
발급상태["landingScheme"] shouldBe "toss-scheme"
}
}
extension(SpringExtension)
})
private fun 발급상태조회(userId: Long): Map<String, String>? {
return RestAssured
.given().log().all()
.header(TossLoggingUtil.USER_ID, userId)
.`when`()
.get("/api/cards/status")
.then().log().all().extract()
.jsonPath().getMap("success")
}
private class CardAppControllerTestConfiguration {
@Bean
@Primary
fun loadMinorPort(): LoadMinorPort {
return mockk(relaxed = true)
}
}
하지만 위와 같은 방법은 최대한 지양하고자 하였는데, 캐싱된 테스트 컨텍스트를 재활용하지 못해 테스트 속도가 느려지며, 모의 객체로 인해 테스트의 현실성이 떨어질 뿐만 아니라 구현과 강결합되기 때문입니다.
4. 직렬화/역직렬화 테스트 코드 작성하기
직렬화/역직렬화 테스트는 직렬화/역직렬화가 문제 없는지만을 빠르게 검증합니다.
단위 테스트로 작성하기
토스는 카나리 배포 전략을 취하고 있는데, 이를 위해 배포 중에 2가지 종류의 코드 버전이 존재하게 됩니다.
문제는 두 버전에서 동일한 인프라 스트럭처(캐시나 데이터베이스 등)를 사용함에 따른 문제가 생길 수 있다는 점입니다. 예를 들어 다음과 같은 Memeber 클래스가 V1 코드에 배포되었다고 할게요.
data class Member(
val userId: Long,
val name: String,
val age: Int,
)
그리고 V2 코드에서는 userId를 userNum로 변경하여 배포했다고 합시다.
data class Member(
val userNum: Long,
val name: String,
val age: Int,
)
문제는 두 서버가 동일한 인프라스트럭처를 사용하므로 데이터 호환성 문제가 발생할 수 있다는 것입니다.
위의 문제를 예방하려면 캐시 버저닝이 필요한데, “컴퓨터 과학에서 가장 어려운 일은 이름짓기와 캐시 무효화입니다”라는 얘기가 있을 정도로 캐시 관리는 어렵습니다. 이를 위해 팀 내에서 두 가지 규칙을 세웠습니다.
- 캐싱의 활용을 최소화할 것
- 호환성 검증 테스트를 작성할 것
어떠한 코드도 존재하는 순간 관리의 대상이므로, 불필요한 코드는 최소화하고자 했습니다. 실제로 불필요하게 캐시가 적용된 경우를 상당히 많이 접했었는데, 우리 팀의 경우 모니터링을 통해 캐시가 불가피해진 순간에만 캐시를 도입하고자 했습니다. 또한 불가피하게 캐시를 적용해야 합니다면, 반드시 테스트를 작성하고자 했고, 해당 테스트가 실패할 경우 “호환성을 고려하라”는 테스트 신호를 의식적으로 챙기고 있습니다.
승인 테스트로 작성하기
승인 테스트(Approval Test) 또는 골든 테스트(Golden Test)란 주어진 입력들에 대한 출력을 스냅샷으로 저장하고, 테스트 수행 결과가 동일한지를 검사하는 테스트입니다.
실제 프로덕션 환경에서 캐시 저장소의 값을 읽어 활용하는 것이, 일종의 스냅샷이 저장된 상태에서 호환성 유/무를 판단하는 것과 유사한 승인 테스트로 작성하게 되었고, ApprovalTests.Java 라이브러리를 활용하고 있습니다.
class ChargingChannelApprovalTest {
@Test
fun chargingChannelApprovalTest() {
val chargingChannel = ChargingChannel.create(
userId = -1L,
bankCode = 23,
expirationDate = null,
)
val result = JsonUtil.writeValueAsString(chargingChannel)
Approvals.verify(result)
}
}
그러면 txt 파일에 저장된 스냅샷과 테스트 실행 결과를 비교하여 테스트 성공/실패를 결정하게 됩니다.
5. 그 외 기타 테스트 작성하기
부분 기능에 대한 테스트
요구 사항을 구현할 때 기존의 테스트가 존재한다면 테스트에도 변경 사항을 반영하면 되지만, 테스트가 없는 상황 역시 존재할 수 있습니다. 이때 전체적인 테스트를 작성할 가치를 판단해보고, 그렇지 않다면 부분 기능 만을 테스트하도록 대체할 수도 있습니다. 예를 들어 계좌 개설에 대한 이벤트를 진행해야 된다고 가정할게요.
@Service
class CreateAccountUseCase(
private val loadTossUserPort: LoadTossUserPort,
private val accountEventPort: AccountEventPort,
private val smallSavingProfile: SmallSavingProfile,
) {
override fun execute(input: CreateAccountInput): CreateAccountOutput {
val user = loadTossUserPort.findUser(input.userId)
if (cannotCreateAccount(user)) {
throw RuntimeException("계좌를 생성할 수 없는 사용자입니다.")
}
val id = createAccount(user)
// 변경 전 코드
if (cannotParticipateEvent()) {
return CreateAccountOutput(id)
}
participateAccountEventPort.apply(user)
return CreateAccountOutput(id)
}
private fun cannotParticipateEvent(): Boolean {
val today = LocalDate.now()
return today.isBefore(EVENT_START_DATE) || today.isAfter(EVENT_END_DATE)
}
companion object {
val EVENT_START_DATE: LocalDate = LocalDate.of(2024, 9 ,14)
val EVENT_END_DATE: LocalDate = LocalDate.of(2024, 9 ,18)
}
}
이때 내부 로직이 복잡하여 처음부터 모든 경우에 대한 테스트를 작성하기에 시간이 빠듯할 수 있습니다. 이런 경우 다음과 같이 코드를 조작하여 부분 기능만 테스트 코드를 작성 할 수 있습니다.
@Service
class CreateAccountUseCase(
private val loadTossUserPort: LoadTossUserPort,
private val accountEventPort: AccountEventPort,
private val smallSavingProfile: SmallSavingProfile,
) {
fun execute(input: CreateAccountInput): CreateAccountOutput {
val user = loadTossUserPort.findUser(input.userId)
if (cannotCreateAccount(user)) {
throw RuntimeException("계좌를 생성할 수 없는 사용자입니다.")
}
val id = createAccount(user)
// 변경 후 코드
if (cannotParticipateEvent(LocalDate.now())) {
return CreateAccountOutput(id)
}
participateAccountEventPort.apply(user)
return CreateAccountOutput(id)
}
@VisibleForTesting
fun cannotParticipateEvent(today: LocalDate): Boolean {
return today !in EVENT_START_DATE..EVENT_END_DATE
}
companion object {
val EVENT_START_DATE: LocalDate = LocalDate.of(2024, 9 ,14)
val EVENT_END_DATE: LocalDate = LocalDate.of(2024, 9 ,18)
}
}
모든 코드를 이렇게 변경하면 문제가 될 수 있지만, 비즈니스 생명 주기가 상대적으로 짧고 국소적인 기능이므로 위와 같이 변경하여 부분 기능에 대한 테스트만을 작성할 수 있습니다. 이렇듯 상황에 따라 가치 판단을 하여 적합한 전략을 가져갈 수 있어야 합니다.
학습 테스트
비즈니스 로직이 아니라 특정 도구의 동작이나 기능을 학습하고 싶은 경우에는 학습 테스트를 작성합니다.
class ObjectMapperLearningTest {
private val objectMapper = ObjectMapper()
@Test
fun `JSON 문자열을 객체로 변환하기`() {
val json = """
{
"name": "MangKyu",
"email": "mangkyu@example.com"
}
""".trimIndent()
val user = objectMapper.readValue(json, User::class.java)
assertThat(user.name).isEqualTo("MangKyu")
assertThat(user.email).isEqualTo("mangkyu@example.com")
}
}
6. 현재 방식의 한계와 개선 방법
유스케이스 테스트가 갖는 한계
- 테스트 셋업의 어려움 유스케이스 테스트 입장에서는 모든 내부 로직이 블랙박스이므로 테스트가 매우 데이터 의존적입니다. 따라서 지속 가능하고 유지보수가 쉬운 테스트를 위해 양적 및 질적 측면에서 좋은 데이터셋을 준비시켜야 하는데, 그 과정이 매우 까다롭습니다. 왜냐하면 테스트 자체는 블랙박스이지만, 테스트 데이터 준비를 위해 개발자가 코드를 따라가며 여러 데이터들을 조합해야 하기 때문입니다. 해당 비용은 결코 적지 않고 매우 수고롭기 때문에 테스트가 주는 가치가 적다면 오히려 테스트 작성 비용으로 많은 시간을 소비하게 되며 테스트 관리 비용을 증가시킬 것입니다.
- 비결정적인 테스트로 인한 문제 유스케이스 테스트에서는 대부분 실제 객체를 활용하여 현실성을 높이고자 했습니다. 문제는 테스트마다 실제 네트워크 호출이 발생하여 이로 인해 테스트가 다소 느릴 수 있다는 점입니다. 실제로 네트워크 또는 타 서비스의 개발 환경 문제 등으로 인해 빌드가 실패한 경우도 있었습니다. 충실성은 높지만 비결정적인 테스트로 인해 빌드 시에 문제가 생길 수 있습니다. 이는 좋은 테스트가 갖춰야 하는 부분들(높은 신뢰성, 빠른 속도 등)이 부족하다고 볼 수 있고요. 따라서 밀폐성과 충실성 사이에서 시스템에 맞게 테스트의 균형점을 찾아야 됩니다.
- 블랙박스 테스트로 인한 거짓 양성(False Positive) 문제 블랙박스 형태의 유스케이스 테스트에서 반환 값이 없는 경우 거짓 양성(False positive, 실제 결과는 실패이지만 검사 결과를 성공으로 판단하는 오류) 문제가 발생할 수 있습니다. 예를 들어 앞서 살펴본 계좌 개설 로직은 반환값이 없었습니다. 이러한 코드에 대한 테스트의 경우, 우리는 마지막 라인까지 통과하기를 바랐지만 중간에 메서드 호출이 종료되어도 테스트는 성공으로 판단될 수 있습니다. 따라서 라인 커버리지를 통해 원하는 곳까지 테스트가 도달했는지 수동으로 확인하거나 또는 프로덕션 코드를 변경하여 응답 값을 추가하고 검증문을 넣을 수 있습니다. 개인적으로는 테스트가 제품의 첫 번째 고객이라는 관점으로 팀고 합의 이후 후자의 방법을 선택하여 테스트라는 고객을 위해 프로덕션 코드를 거부감 없이 변경했습니다.
직렬화/역직렬화 테스트가 갖는 한계
- 캐시 버저닝에 대한 검사가 되지 않는 문제
현재 작성되는 직렬화/역직렬화 테스트는 검증 대상이 부정확합니다. 테스트를 통해 보장되어야 하는 부분은
cacheName
+cacheKey
조합으로 조회된 엔티티가 직렬화/역직렬화 문제가 없는지를 검증해야 하며, 문제가 생겼다면 cache 버저닝 작업을 유도해야 합니다. 하지만 현재 검증하는 대상은 엔티티에 제한되어 있으므로, 추후에cacheName
+cacheKey
까지 테스트 범위에 포함시키고, 버저닝을 하면 테스트가 통과되도록 확장시켜야 됩니다.
마무리
위에 다 적지 못한 여러 가지 문제점들도 존재하지만, 충분히 가치를 느낄 수 있는 부분도 많습니다. 실제 적용을 하면서 다음의 케이스들에 대하여 테스트를 통한 이점을 누릴 수 있었습니다.
- 코드 리팩터링 시에 실제 잘못된 변경을 사전에 탐지함
- 애플리케이션 설정 등이 잘못 적용되어 서버 구동에 실패함
- 스프링 부트 버전업 시에 외부 라이브러리 호환성 문제를 런타임에 발견함
- 기타 등등
아무리 뛰어난 개발자라도 시스템의 모든 세부사항을 알 수 없고, 실수를 하게 됩니다. 테스트는 소프트웨어와 개발자 모두를 더 나아지게 하므로 좀 더 성숙한 개발자가 되기 위해 테스트를 작성할 필요가 있습니다.
그렇다고 모든 코드에 테스트를 작성하는 것은 비효율적이며, 모든 것을 테스트로 검증하는 것도 불가능합니다. 따라서 좋은 그리고 올바른 테스트 전략이 필수적이며, 이를 위해서는 상황에 맞는 합리적이고 실용적인 테스트 전략을 가져갈 필요가 있습니다. 은탄환은 없습니다(No Silver Bullets).