
개발하다 보면 WebClient를 사용해 외부 API를 호출하는 일이 매우 빈번하다. 하지만 각 엔드포인트마다 요청 및 응답 스펙이 다르고, 서버와 클라이언트의 예외 처리 방식 또한 제각각이다.
나는 WebClient에서 손쉽게 정상&비정상 응답, 혹은 재시도 정책 등에 대한 테스트를 작성하고 싶었지만, 각 상황에 맞는 응답을 발생시키기가 어려워서 내가 구성한 호출부 로직이 제대로 되었는지 확인할 길이 없었다.
이번 글에서는 WireMock을 사용해서 각 상황에 맞는 응답이 내려올 때 어떻게 호출부에서 처리되고 있는지 확인해보고자 한다.
kotest와 함께 사용하고 싶었지만 docs를 확인해보니 추가적인 의존성이 필요하여, 일단 빠르게 테스트를 작성하면서 동작원리를 파악하는 게 우선이었기에 이번에는 Junit5를 사용하였다.
WireMock은 특정 path로의 호출을 모킹하여 유연하게 응답값을 조작해 내려주는 도구이다.WireMock을 사용해서 각 상황을 확인하려고 한다.@ExtendWith(MockKExtension::class)
@AutoConfigureWireMock(port = 0) // running on random port
class SslSolutionAdapterWireMockTest {
private val logger = LoggerFactory.getLogger(javaClass)
@MockK
private lateinit var hookService: HookService
private lateinit var objectMapper: ObjectMapper
private lateinit var wireMockServer: WireMockServer
private lateinit var webClient: WebClient
@BeforeEach
fun setUp() {
objectMapper = ObjectMapper()
wireMockServer = WireMockServer()
wireMockServer.start()
webClient = WebClient.builder()
.baseUrl("http://localhost:${wireMockServer.port()}")
.clientConnector(
ReactorClientHttpConnector(
HttpClient.create()
.responseTimeout(Duration.ofSeconds(1))
)
)
.build()
}
@AfterEach
fun tearDown() {
wireMockServer.stop()
}
fun requestApi() {
val latch = CountDownLatch(1)
webClient.post()
.uri(URL)
.header(HttpHeaders.CONTENT_TYPE, MediaType.MULTIPART_FORM_DATA_VALUE)
.accept(MediaType.APPLICATION_JSON)
.bodyValue(testRequest)
.retrieve()
.bodyToMono<TestReesponse>()
.doOnRequest {
logger.info("TestRequest: ${testRequest}")
}
.retry(1)
.subscribe(
{ response ->
logger.info("TestResponse: $response")
if (!response.isValidStatus()) {
logger.info("유효하지 않은 상태")
throw InvalidStatusException()
}
latch.countDown()
},
{ error ->
logger.info("요청 실패: {}", error.message)
mono {
hookService.sendFailHook(HookType.SYNC_FAILED)
}.subscribe()
latch.countDown()
}
)
latch.await()
}
}
}
WebClient 호출부를 작성했다. 테스트를 작성하기 전에 해당 메서드의 동작방식은 다음과 같을 것이라고 예상했고, 이에따라 WireMock을 사용해 테스트를 작성했다.CountDownLatch는 WebClient의 subscribe가 비동기 호출이기 때문에, 테스트 환경에서 동기적으로 결과를 확인하기 위해 사용하였다.@Test
fun `200 OK`() {
// given
wireMockServer.stubFor(
post(urlEqualTo(URL))
.withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE))
.willReturn(
aResponse()
.withStatus(200)
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withBody(objectMapper.writeValueAsString(TestResponse("OK")))
)
)
// when, then
assertSoftly {
shouldNotThrowAny { requestApi() }
wireMockServer.verify(1, postRequestedFor(urlEqualTo(URL)))
}
}
@Test
fun `200 FAILED`() {
// given
wireMockServer.stubFor(
post(urlEqualTo(URL))
.withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE))
.willReturn(
aResponse()
.withStatus(200)
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withBody(objectMapper.writeValueAsString(TestResponse("FAIL")))
)
)
// when, then
assertSoftly {
shouldThrow<InvalidStatusException> { requestApi() }
wireMockServer.verify(1, postRequestedFor(urlEqualTo(URL)))
}
}

@Test
fun `200 FAILED`() {
// given
wireMockServer.stubFor(
post(urlEqualTo(URL))
.withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE))
.willReturn(
aResponse()
.withStatus(200)
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withBody(objectMapper.writeValueAsString(TestResponse("FAIL")))
)
)
coEvery {
hookService.sendFailHook(HookType.SYNC_FAILED)
} just Runs
// when, then
assertSoftly {
shouldNotThrowAny { requestApi() }
coVerify { hookService.sendFailHook(HookType.SYNC_FAILED) }
wireMockServer.verify(1, postRequestedFor(urlEqualTo(URL)))
}
}subscribe 응답 블록에서 예외를 던지면 상위로 예외가 전파될 것이라고 생각했는데, error 블록으로 들어가서 예외 핸들링이 되고 있었다.subscribe의 예외 블록에서 처리됨을 확인했다.@Test
fun `500 - 200`() {
// given
wireMockServer.stubFor(
post(urlEqualTo(URL))
.withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE))
.willReturn(
aResponse()
.withStatus(500)
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withBody(
"""
{"message": "Internal Server Error"}
""".trimIndent()
)
).willReturn(
aResponse()
.withStatus(200)
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withBody(objectMapper.writeValueAsString(TestResponse("OK")))
)
)
// when, then
assertSoftly {
shouldNotThrowAny { requestApi() }
coVerify { hookService.sendFailHook(HookType.SYNC_FAILED) }
wireMockServer.verify(2, postRequestedFor(urlEqualTo(URL)))
}
}
WebClient 호출부에서 retry(1)을 설정했기 때문에, 첫 번째 응답이 예외가 발생하더라도 한 번 더 요청을 하는 것을 확인할 수 있다.@Test
fun `500 - 500`() {
// given
wireMockServer.stubFor(
post(urlEqualTo(URL))
.withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE))
.willReturn(
aResponse()
.withStatus(500)
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withBody(
"""
{"message": "Internal Server Error"}
""".trimIndent()
)
).willReturn(
aResponse()
.withStatus(500)
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withBody(
"""
{"message": "Internal Server Error"}
""".trimIndent()
)
)
)
coEvery { hookService.sendFailHook(HookType.SYNC_FAILED) } just Runs
// when, then
assertSoftly {
shouldNotThrowAny { requestApi() }
coVerify { hookService.sendFailHook(HookType.SYNC_FAILED) }
wireMockServer.verify(2, postRequestedFor(urlEqualTo(URL)))
}
}
subscribe를 사용하였기에 해당 부분은 비동기적으로 처리된다. 따라서 CountDownLatch는 주로 동시성 테스트에서 비동기 작업의 완료를 기다리기 위해 테스트 환경에서 제한적으로 사용하였다.subscribe를 사용해 WebFlux의 기술로 비동기를 핸들링하는 것보다, 코틀린을 사용한 이상 코루틴을 사용해 비동기를 핸들링하면 더 효과적이다. 조금 더 가독성이 좋고, 동기 코드를 작성하는 것 같은 효과가 있다.awaitSingle 등으로 Reactor Mono를 코루틴으로 자연스럽게 통합해준다.suspend fun requestApi() {
try {
val response: TestResponse = webClient.post()
.uri(URL)
.header(HttpHeaders.CONTENT_TYPE, MediaType.MULTIPART_FORM_DATA_VALUE)
.accept(MediaType.APPLICATION_JSON)
.bodyValue(testRequest)
.retrieve()
.bodyToMono<TestResponse>()
.retry(1)
.awaitSingle()
if (!response.isValidStatus()) {
logger.info("유효하지 않은 상태")
throw InvalidStatusException()
}
} catch (error: Exception) {
logger.info("요청 실패: ${error.message}")
hookService.sendFailHook(HookType.SYNC_FAILED)
}
}
try-catch를 활용할 수 있음을 확인할 수 있다.WireMock 테스트와 직접적으로 연관은 업지만, 테스트를 하다보니 CountDownLatch를 사용하는 것 자체가 아무리 테스트 환경이라도 마음에 걸렸고, 조금 더 나은 방법을 찾아보다가 애초에 코루틴을 사용했으면 손쉽게 해결될 문제였다는 것을 알았다.@Test
fun `200 OK`() = runTest { // runBlocking 효과!
// given
wireMockServer.stubFor(
post(urlEqualTo(URL))
.withHeader(HttpHeaders.CONTENT_TYPE, containing(MediaType.MULTIPART_FORM_DATA_VALUE))
.willReturn(
aResponse()
.withStatus(200)
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withBody(objectMapper.writeValueAsString(TestResponse("OK")))
)
)
// when, then
assertSoftly {
shouldNotThrowAny { requestApi() }
wireMockServer.verify(1, postRequestedFor(urlEqualTo(URL)))
}
}
CountDownLatch 같은 비동기 제어 기술을 넣지 않아도 된다.runTest 블록을 사용해서 메인 스레드가 테스트가 끝날때까지 기다려주기만 하면 된다.WireMock 테스트 도구를 사용해, WebClient를 활용한 하나의 호출부가 서버의 응답에 따라 어떻게 동작할지 파악하기에 매우 좋았다.WebClient가 동작하는지 알게 되어서 좋았다.