시작하기 앞서
안녕하세요. 정산플랫폼팀 윤입니다. 정산팀에서는 수많은 Batch 애플리케이션으로 대량 처리를 진행하고 있습니다. 정산플랫폼팀의 대량 처리 노하우는 Batch Performance를 고려한 최선의 Reader, Batch Performance를 고려한 최선의 Aggregation에서 소개한 적 있습니다. 저희 팀에서는 처리 속도만큼 중요한 것은 데이터의 정합성입니다. Batch 애플리케이션 특성상 다양한 데이터를 조합하여 다양한 데이터 형식으로 가공 처리를 진행하게 됩니다. 이러한 이유로 정산플랫폼팀에서는 다양한 테스트 케이스를 커버할 수 있는 테스트 코드를 작성해야 했으며 그 경험과 노하우에 대해서 정리해 보았습니다.
본 포스팅에서는 계층 구분을 통해 효율적인 테스트 코드를 작성하는 방법에 대해서 다루며, 특히 어떤 계층을 어떤 관점으로 테스트 코드를 작성해야 하는지에 대해 자세한 가이드를 제시합니다. 또 서비스가 점차 복잡해짐에 따라서 점차 테스트 코드 작성이 어려워지는 문제를 해결하기 위한 다양한 접근 방법들에 대해서 전달하려고 합니다. 첫 번째 편에서는 효율적인 Mock Test에 대해서 이야기해 보겠습니다.
참고로 해당 포스팅은 스프링 프레임워크 기반에서 2,3년 정도 테스트 코드를 작성해 본 분들, 단위 테스트, Mock 테스트, 통합 테스트, 테스트 더블에 대한 기본적인 개념을 알고 있는 분들을 대상으로 합니다. 특정 라이브러리의 직접적인 사용법은 다루지 않습니다. 발표 영상이 궁금한 분들은 아래 링크에서 확인 부탁드립니다.
🔗 2023 Spring Camp 실무에서 적용하는 테스트 코드 작성 방법과 노하우
시작하며
서비스 초기 간단한 요구사항에 대한 테스트 코드를 쉽게 작성할 수 있습니다. 하지만 시간이 지남에 따라 서비스의 크기가 커져서 분리되기도 하며, 다른 서비스들과 상호작용을 하며 점차 복잡하게 변화합니다. 이에 따라 테스트 코드도 여러 외부 의존성을 가지게 되며 점차 복잡도가 증가하게 됩니다. 이런 복잡도를 계속 방치하게 되면 결국 개발자의 코드 생산성 저하로까지 이어지게 됩니다. 서비스 초기 단순한 테스트 코드 케이스로 시작해서 서비스가 점차 복잡해짐으로써 테스트 코드에서 발생한 문제점과 이를 해결하기 위해 시도했던 여러 가지 방법들과 노하우를 전달드리겠습니다.
기존 가맹점 등록
가맹점 관리 서비스 초기 가맹점 등록을 진행할 때 사업자 번호, 가맹점명을 직접 입력해서 저장하는 단순한 구조로 구성됐습니다.
등록 코드
@Service
class ShopRegistrationService(
private val shopRepository: ShopRepository
) {
fun register(
brn: String,
shopName: String
): Shop {
return shopRepository.save(
Shop(
brn = brn,
name = shopName
)
)
}
}
해당 구조를 코드로 구현하면 입력받은 사업자 번호, 가맹점명을 그대로 영속화하는 코드로 작성됩니다.
등록 테스트 코드
@Test
fun `Shop 등록 테스트 케이스`() {
//given
val brn = "000-00-0000"
val shopName = "주식회사 XXX"
//when
val shop = shopRegistrationService.register(brn, shopName)
//then
then(shop.name).isEqualTo(shopName)
then(shop.brn).isEqualTo(brn)
}
테스트 코드로 입력받은 값이 정상적으로 등록됐는지 확인하는 단순한 테스트 코드로 작성하게 되며, 서비스 초기에는 테스트 코드를 작성하는 것이 어렵지 않습니다.
신규 가맹점 등록 Flow
가맹점 관리 서비스의 규모가 점차 커지면서, 파트너에 대한 기능을 독립적인 애플리케이션으로 분리되었고, 사업자 번호만 입력하면 가맹점명을 알아서 등록하는 구조로 변경되었습니다.
등록 코드
@Service
class ShopRegistrationService(
private val shopRepository: ShopRepository,
// 파트너 서비스와 HTTP 통신을 담당하는 Client 객체
private val partnerClient: PartnerClient
) {
fun register(
brn: String,
): Shop {
val partner = partnerClient.getPartnetBy(brn)
return shopRepository.save(
Shop(
brn = brn,
name = partner.name
)
)
}
}
파트너 서비스와 HTTP 통신을 담당하는 Client 객체 PartnerClient를 통해서 가맹점명을 질의하여 등록하는 코드로 변경됩니다.
신규 가맹점 등록 Mock Server 기반 Test Code
@Test
fun `가맹점 등록 Mock HTTP Test`() {
//given
val brn = "000-00-0000"
val name = "주식회사 XXX"
// (1) HTTP 통신 Mocking CODE
mockServer
.expect(
requestTo("http://localhost:8080/api/v1/partner/${brn}")
)
.andExpect(method(HttpMethod.GET))
.andRespond(
withStatus(HttpStatus.OK)
.contentType(MediaType.APPLICATION_JSON)
.body(
"""
{
"brn": "${brn}",
"name": "${name}"
}
""".trimIndent()
)
)
//when
val shop = shopRegistrationService.register(brn)
//then
then(shop.name).isEqualTo(name)
then(shop.brn).isEqualTo(brn)
}
구현 코드가 변경됐으니 테스트 코드도 변경이 필요합니다. 해당 테스트 코드에서는 Mock Server를 통해서 PartnerClient의 HTTP 통신을 Mocking 하여 테스트 코드를 작성하게 됩니다. 가장 일반적인 방법이며 어렵지 않게 해당 기능에 대한 테스트 코드를 작성할 수 있습니다. 하지만 이런 테스트 코드를 계속 작성하다 보면 다음과 같은 어려움을 만나게 됩니다.
Mock Server 기반의 과도한 Mocking에 따른 생산성 저하
A Controller, A Service 객체에 직/간접적으로 PartnerClient 객체를 의존하고 있다고 가정해 봅시다. 그렇게 되면 기존 테스트 코드에 큰 변경이 발생합니다. PartnerClient를 직/간접적으로 의존하는 모든 테스트 코드에 Mock Server의 Mocking 작업이 필요해집니다. 서비스 초기 PartnerClient를 의존하는 코드가 A Controller, A Service 두 개 정도라면 크게 문제없이 테스트 코드를 작성할 수 있습니다.
하지만 그보다 의존관계가 많고 복잡하다면 어떻게 될까요? 또 현재는 의존 관계가 적지만 앞으로 늘어날 가능성이 높다면 어떻게 될까요? 요구사항이 변경되어 PartnerClient가 변경되면 어떻게 될까요? 그럴 때마다 PartnerClient를 직/간 접적으로 의존하는 모든 구간에 HTTP Mocking 관련 코드를 추가 및 변경해야 합니다. 위 예제 코드는 간단하지만 실제 요청 Request Body, Response Body가 몇십 줄에서 몇백 줄까지 넘어가는 코드들도 빈번하게 나오며 이렇게 Mock Server 기반으로 과도한 Mocking 테스트 코드를 작성하게 되면 결국 개발자의 코드 생산성 저하로 이어지게 됩니다.
@MockBean 기반의 객체 행위 Mocking을 통한 폭넓은 테스트 케이스 커버
Mock Server 기반의 과도한 Mocking 문제 해결 방법으로 생각한 것은 @MockBean을 활용하는 것입니다. @MockBean을 활용하면 Mock Server의 HTTP Mocking을 하는 것이 아닌 @MockBean으로 Mock 객체를 주입받고 행위 자체를 Mocking 하여 보다 쉽게 Mocking을 할 수 있습니다.
@MockBean
// mock 객체를 @MockBean 통해 의존성 주입받음
private lateinit var partnerClient: PartnerClient
@Test
fun `register mock bean test`() {
//given
val brn = "000-00-0000"
val name = "주식회사 XXX"
// Mockito 기반으로 객체 행위를 Mocking, HTTP 통신 Mocking 보다 비교적 간단하게 구성이 가능하다.
given(mockPartnerClientService.getPartner(brn))
.willReturn(PartnerResponse(brn, name))
//when
val shop = shopRegistrationService.register(brn)
//then
then(shop.name).isEqualTo(name)
then(shop.brn).isEqualTo(brn)
}
partnerClient 객체를 @MockBean 통해서 의존성 주입을 받아 실제 응답받을 값을 Mocking 함으로써 HTTP Mocking을 대체합니다. 이렇게 Mocking을 하면 HTTP Mocking에 비해서 비교적 쉽게 여러 가지 테스트 케이스의 코드 작성이 가능하게 되며 최종적으로 폭넓은 테스트 케이스를 커버할 수 있게 됩니다.
물론 MockBean을 사용해도 관련 의존성이 있는 테스트 코드에 Mocking을 해야 한다는 사실은 변하지 않지만, 비교적 간단하게 Mocking을 구성할 수 있어 효율적인 방법이 될 수 있습니다.
@MockBean 사용 시 Application Context 초기화 문제
해당 클래스의 테스트 코드만 실행하는 경우는 문제가 없지만 전체를 대상으로 테스트 코드를 실행하면 문제가 발생할 수도 있습니다. 전체 테스트 코드 빌드 시에는 MockBean을 사용하게 되면 정의에 따라 Application Context가 재정의되며 이 결과 Application Context를 재사용하지 못하고 Application Context의 초기화 작업을 다시 진행하게 됩니다. @MockBean 관련 테스트 코드를 작성하면 할수록 Application Context 초기화 이슈가 발생하며 @MockBean와 정비례하게 Application Context를 초기화하는 시간이 증가하게 되며 테스트 빌드에 큰 악영향을 줍니다. 해결 방법이 없는 건은 아니었지만 멀티 모듈 환경 및 다른 여러 가지 등을 고려해 봤을 때 @MockBean를 사용하지 않는 방식으로 해결 방법을 찾아야 했습니다.
@TestConfiguration 기반으로 Application Context 초기화 해결
@MockBean으로 주입받을 객체를 교체하기 때문에 Application Context가 재사용하지 못한다면 해당 Mock 객체 자체를 실제 Bean으로 등록해 실제 Bean처럼 사용하면 해결될 것 같았습니다.
@TestConfiguration
class ClientTestConfiguration {
@Bean
@Primary
fun mockPartnerClient() = mock(PartnerClient::class.java)!!
}
Mockito의 mock 함수를 통해 PartnerClient 객체를 Bean으로 등록합니다. 이때 실제 Bean과 겹칠 수도 있으니 Primary을 통해서 우선순위를 높이고, 해당 설정은 Test에서만 사용하기 때문에 test 디렉터리에 위치시키며 @TestConfiguration으로 설정합니다.
class ShopRegistrationServiceMockBeanTest(
private val shopRegistrationService: ShopRegistrationService,
// @MockBean에서 일반 Bean으로 변경 되었으니 생성자 주입으로 변경
private val partnerClient: PartnerClient
) : TestSupport() {
@Test
fun `register mock bean test`() {
//given
val brn = "000-00-0000"
val name = "주식회사 XXX"
given(mockPartnerClient.getPartnerBy(brn))
.willReturn(PartnerResponse(brn, name))
//when
val shop = shopRegistrationService.register(brn)
//then
then(shop.name).isEqualTo(name)
then(shop.brn).isEqualTo(brn)
}
}
변경된 코드를 살펴보겠습니다. 기존 @MockBean을 통해 주입했지만 이제 실제 Bean이기 때문에 생성자 주입으로 대체합니다. 여기서 주입받은 mockPartnerClient 객체는 TestConfiguration을 통해 등록된 Bean입니다. 이렇게 실제 Bean으로 등록하여 Application Context 초기화 이슈를 해결할 수 있습니다.
@TestConfiguration 기반 Mock Bean Test Code 멀티 모듈 적용
카카오페이 정산플랫폼팀에서는 대부분의 프로젝트가 멀티 모듈로 구성되어 있기 때문에 멀티 모듈 환경에서 원활하게 동작하는지가 중요했습니다.
다음과 같이 멀티 모듈로 구성돼 있다고 가정해 보겠습니다. HTTP Client 모듈에 PartnerClient 및 HTTP 통신 관련 코드들이 해당 모듈에 구성되어 있습니다.
위에서 작성한 ClientTestConfiguration 관련 코드는 HTTP Client 모듈의 test 디렉터리에 위치합니다. 해당 설정으로 PartnerClient 모듈의 Mock 객체를 Bean으로 사용이 가능합니다.
Service A 모듈에서 HTTP Client 모듈을 의존하여 가맹점 등록 로직을 작성해야 하기 때문에 의존성을 추가합니다.
이제 Service A 모듈에서 가맹점 등록 관련 테스트 코드를 작성하기 위해서는 HTTP Client 모듈의 test 디렉터리에 있는 ClientTestConfiguration 객체가 필요합니다.
하지만 Service A 모듈의 test 디렉터리에서는 HTTP Client 모듈의 테스트 디렉터리에 접근할 수 없기 때문에 HTTP Client의 관련 의존성 테스트 코드를 작성할 수 없습니다. 물론 Service A 모듈에서 ClientTestConfiguration를 직접 정의하면 사용이 가능하지만 이렇게 구성하면 Service B, API App, Batch App 모듈에서도 계속 동일한 중복 코드가 발생하게 됩니다. 또 다른 문제를 만나게 되었고 이 문제를 해결하기 위한 방법을 찾아야 했습니다.
java-test-fixtures 기반 테스트 의존성 제공
java-test-fixtures 플러그인으로 멀티 모듈 환경에서의 테스트 의존성을 제공 가능하도록 하여 문제를 해결했습니다.
java-test-fixtures 플러그인을 사용하면 testFixtures 디렉터리가 생성되며, 기존 test 디렉터리의 ClientTestConfiguration 코드는 testFixtures 디렉터리로 이동합니다.
Service A 모듈에서는 testFixtures을 통해서 테스트에 사용할 http-client 모듈에 의존성을 추가하면 해당 모듈의 testFixtures 디렉터리에 있는 ClientTestConfiguration 객체를 Service A 모듈의 test 디렉터리에서 사용할 수 있으며, main 디렉터리에서는 사용할 수 없습니다.
java-test-fixtures을 통해서 복잡하게 테스트 의존성을 제공해야 할까요?
HTTP Client 모듈의 main 디렉터리에 ClientConfiguration을 위치시킴으로써 테스트 의존성을 제공하여 쉽게 해결이 가능합니다. main 디렉터리에 있으니 당연히 Service A의 test 디렉터리에서 접근이 가능합니다. 하지만 문제가 있습니다. main 디렉터리에서도 접근이 가능하다는 것입니다. 테스트를 위한 Mock 객체임에도 main 디렉터리에서 접근이 가능하다는 것은 매우 큰 문제이며 테스트 코드를 작성하면서 다음과 같은 의문을 가져야 합니다.
“테스트를 쉽게 하기 위해, 운영 코드 설계를 변경하는 것이 옳은가?”
테스트 코드 작성은 중요하지만 테스트 코드를 편리하게 작성하기 위해 운영 코드의 설계를 변경하는 것은 바람직하지 않습니다. 만약 저 Bean이 실수로라도 실제 운영 환경에 올라가게 되는 경우 장애로 이어지며, 테스트를 쉽게 하기 위한 목적으로 실제 구현 코드 영역을 지속적으로 침범한다면 애플리케이션에 악영향을 미칠 수밖에 없습니다. 그렇기 때문에 테스트 의존성을 실제 구현 코드의 영역과 명확하게 분리하는 것이 복잡하고 불편해 보여도 충분한 가치가 있다고 생각합니다.
Mock Test 방법 정리
방식 | 장점 | 단점 |
---|---|---|
Mock Server Test | HTTP 통신을 실제 진행하여 서비스 환경과 가장 근접한 테스트 | HTTP 통신 Mocking을 의존하는 모든 구간에 Mocking 필요 |
@MockBean | HTTP Mocking에 비해 비교적 간단하게 Mocking 가능 | Application Context를 재사용 못해 테스트 빌드 속도 저하 |
@TestConfiguration | 간단하게 Mocking 가능, Application Context 이슈 해결 | 멀티 모듈 환경에서 @TestConfiguration 사용 어려움 |
java-test-fixtures | 멀티 모듈에서 환경에서 적합 | 멀티 모듈이 아닌 경우 필요성 낮음 |
테스트할 수 없는 영역 대처 자세
Mock Test를 중심으로 얘기했지만, 제가 전달하고자 하는 메시지 중 하나는 테스트할 수 없는 영역 대처 자세에 대한 것입니다. HTTP 통신처럼 꼭 제어할 수 없어 Mock을 사용하는 환경에 국한된 것은 아닙니다.
테스트 코드 작성이 불가능한 이유는 매우 다양하다.
Redis를 도입하게 되어 각각의 환경을 구성했지만 테스트 환경을 구축하지 못했다고 가정해 보겠습니다. 테스트 환경의 인프라스트럭처 구성이 되어 있지 못해서 테스트 코드 작성이 불가능합니다. 이처럼 테스트 코드를 작성하기 불가능하거나 어려운 이유는 매우 다양합니다. 해당 예시처럼 Redis 테스트 환경 구축에 대한 지식이 아직 없어 못하는 경우도 있을 수 있으며, 그 밖에 다양한 이유로 테스트 코드 작성이 불가능한 이유는 필연적으로 발생하며 그것에 대처하는 방향성에 대해서 말씀드리고 싶었습니다.
Black Box 영역을 격리해야 하는 이유
xxx 이유로 테스트 코드 작성이 어려운 영역을 Black Box 영역이라 지칭하겠습니다. 이 Black Box 영역의 가장 큰 문제점은 이 영역이 전이된다는 것입니다.
Black Box 영역을 직/간접적으로 의존하는 모든 구간들이 Black Box로 전이됩니다. 이렇게 전이되다 보면 모든 영역이 테스트 불가능한 Black Box가 됩니다.
Black Box 영역이 전이되지 않게 격리해야 합니다. 설령 그 영역 자체는 테스트를 못하는 한이 있더라도 그 Black Box가 전이되는 것을 막아야 합니다. 즉, Black Box 영역을 테스트 못하더라도 다른 객체는 여전히 테스트를 진행할 수 있는 환경을 구성해야 합니다. 비단 Mock 관련에 한정된 것은 아닙니다. 이러한 설명을 가장 매끄럽게 할 수 있는 것이 Mock이라는 상황인 것이고, 전달하고자 하는 핵심 메시지는 테스트가 어렵거나, 불가능한 영역이 전이되지 않게 격리시키는 것입니다. 이렇게 격리함으로써 다른 영역은 테스트가 가능한 영역으로 남게 됩니다. 격리하는 방법은 다양하게 있으며, 해당 프로젝트에 알맞은 적절한 방법을 적용해서 사용하면 됩니다.
마치며
카카오페이 정산플랫폼팀은 테스트 코드 작성의 생산성을 올리고, 유지 보수하기 좋은 구조를 갖도록 지속적인 관심과 노력을 기울여왔습니다. 그중 Mock 관련 테스트 코드 작성에 대해 효율적이고 쉽게 작성하는 방법을 소개 드렸습니다. 테스트 코드는 상대적으로 운영 코드에 비해 관심도가 낮을 수 있습니다. 하지만 이렇게 방치되면 결국 운영 코드의 안전성과 효율성이 떨어질 수밖에 없습니다. 운영 코드가 변경됨에 따라 그에 따른 테스트 코드도 해당 구조에 알맞게 변경돼야 하며 그런 관심을 지속적으로 갖고 관리의 영역으로 바라봐야 합니다. 다음 포스팅에서는 지속적으로 잘 관리되는 테스트 코드로부터 어떤 피드백을 받을 수 있으며, 전해 받은 기반의 피드백으로 운영 코드를 어떻게 리팩토링할 수 있는지에 대해서 다루어 보겠습니다.