개발하다 보면 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
가 동작하는지 알게 되어서 좋았다.