WireMock

정현승·2025년 5월 1일
0

테스트

목록 보기
3/3
post-thumbnail

1. 개요

개발하다 보면 WebClient를 사용해 외부 API를 호출하는 일이 매우 빈번하다. 하지만 각 엔드포인트마다 요청 및 응답 스펙이 다르고, 서버와 클라이언트의 예외 처리 방식 또한 제각각이다.

나는 WebClient에서 손쉽게 정상&비정상 응답, 혹은 재시도 정책 등에 대한 테스트를 작성하고 싶었지만, 각 상황에 맞는 응답을 발생시키기가 어려워서 내가 구성한 호출부 로직이 제대로 되었는지 확인할 길이 없었다.

이번 글에서는 WireMock을 사용해서 각 상황에 맞는 응답이 내려올 때 어떻게 호출부에서 처리되고 있는지 확인해보고자 한다.

kotest와 함께 사용하고 싶었지만 docs를 확인해보니 추가적인 의존성이 필요하여, 일단 빠르게 테스트를 작성하면서 동작원리를 파악하는 게 우선이었기에 이번에는 Junit5를 사용하였다.

2. WireMock

  • WireMock은 특정 path로의 호출을 모킹하여 유연하게 응답값을 조작해 내려주는 도구이다.
  • 따라서 통합테스트 진행시 외부 호출부를 모킹한다거나, 특정 외부 호출에 대한 성공, 예외, 재시도를 확인하기에 좋다.
  • 따라서 나는 WireMock을 사용해서 각 상황을 확인하려고 한다.

3. 활용 방안

@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을 사용해 테스트를 작성했다.
    • 200 OK
      • 응답값이 유효하다면 통과
      • 응답값이 유효하지 않다면 예외 발생
    • 그외 FAILED
      • 1번까지 retry
      • 2번 연속 실패시 알림훅 발송
  • CountDownLatchWebClientsubscribe가 비동기 호출이기 때문에, 테스트 환경에서 동기적으로 결과를 확인하기 위해 사용하였다.

3.1. 성공 테스트

@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)))
    }
}
  • 먼저 성공하는 케이스다.
  • 아무런 예외가 발생하지 않고 정상 응답됨을 확인할 수 있다.

3.2. 실패 테스트

@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)))
    }
}
  • 이 경우에는 서버에서 400이나 500 예외가 아니라 “FAIL” 메시지를 응답값에 포함시킨 경우다.
  • 따라서 클라이언트에서 이를 처리해야 하고, subscribe 성공 블록 내부에서 예외를 던지고 있기 때문에 예외가 정상적으로 발생할 것이라고 생각했다.

  • 하지만 예상한 것과는 달리 예외는 발생하지 않았다.
  • 대신 아래의 테스트케이스가 통과하는 것을 확인할 수 있었다.
    @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의 예외 블록에서 처리됨을 확인했다.

3.3. 예외 후 정상 응답

@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)을 설정했기 때문에, 첫 번째 응답이 예외가 발생하더라도 한 번 더 요청을 하는 것을 확인할 수 있다.
  • 두 번 째 요청에서는 정상적으로 응답되므로 예외가 발생하지 않음을 확인할 수 있다.

3.4. 예외 후 예외 응답

@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)))
    }
}
  • 이를통해 재시도 한 번 이후에도 예외가 발생한다면 클라이언트측에서는 알림 훅을 발생시킨다는 것을 테스트를 통해 확인할 수 있었다.

4. 코루틴으로 핸들링

  • subscribe를 사용하였기에 해당 부분은 비동기적으로 처리된다. 따라서 CountDownLatch는 주로 동시성 테스트에서 비동기 작업의 완료를 기다리기 위해 테스트 환경에서 제한적으로 사용하였다.
  • 하지만 subscribe를 사용해 WebFlux의 기술로 비동기를 핸들링하는 것보다, 코틀린을 사용한 이상 코루틴을 사용해 비동기를 핸들링하면 더 효과적이다. 조금 더 가독성이 좋고, 동기 코드를 작성하는 것 같은 효과가 있다.
  • WebFlux에서는 awaitSingle 등으로 Reactor Mono를 코루틴으로 자연스럽게 통합해준다.

4.1. 호출부 수정

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를 사용하는 것 자체가 아무리 테스트 환경이라도 마음에 걸렸고, 조금 더 나은 방법을 찾아보다가 애초에 코루틴을 사용했으면 손쉽게 해결될 문제였다는 것을 알았다.
  • 물론 실무에서는 이미 코루틴을 많이 사용하고 있었지만,, 그래도 이참에 Reactive 기술과 코루틴 기술간의 전환을 확인해보고 싶었다.

4.2. 테스트 수정

@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 블록을 사용해서 메인 스레드가 테스트가 끝날때까지 기다려주기만 하면 된다.

5. 후기

  • WireMock 테스트 도구를 사용해, WebClient를 활용한 하나의 호출부가 서버의 응답에 따라 어떻게 동작할지 파악하기에 매우 좋았다.
  • 실무에서는 이미 만들어진 코드를 그대로 가져와 사용할 일이 많다. 가져와서 사용하면서도 이게 제대로 동작을 하는 건지 의심스러웠는데, 이번 기회에 각 상황에서 어떻게 WebClient가 동작하는지 알게 되어서 좋았다.

6. 참고

0개의 댓글