spring boot에서 integration test를 작성할 때 RestTemplate이나 WebClient를 이용한 http call에 대한 테스트를 지원하는 여러가지 툴이 있는데,

그중에 WireMock에 대한 사용법을 공유하고자 한다.

 

전체 코드는 아래에서 볼 수 있습니다.
https://github.com/pooi/wiremock-example

 

GitHub - pooi/wiremock-example

Contribute to pooi/wiremock-example development by creating an account on GitHub.

github.com


WireMock이란?

https://wiremock.org

WireMock은 지정한 로컬 포트에 mocking 서버를 띄워 해당 포트로 호출하는 http request에 대해 mocking 해서 원하는 응답 값을 내려주도록 도와주는 툴이다.

 

method, path, query params, headers를 모킹 조건으로 설정할 수 있으며,

 

모킹 조합에 맞는 request가 들어왔을때 적절한 응답 값 (200, 404, 500 등)과 body를 리턴하도록 세팅할 수 있다.

 

 


WireMock 시작하기

아래처럼 wiremock dependency를 gradle에 추가해준다.

testImplementation("com.github.tomakehurst:wiremock-standalone:2.27.2")

아래와 같이 기본적인 Specification을 작성한다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class WebClientSpec extends Specification {

    @Shared
    ObjectMapper objectMapper = new ObjectMapper()

    @ClassRule
    @Shared
    WireMockRule server = new WireMockRule(10001)

    @Autowired
    TestClient testClient

    def setupSpec() {
        server.start()
    }

    def cleanup() {
        assert !server.findUnmatchedRequests().requests

        server.resetAll()
    }

    def cleanupSpec() {
        server.stop()
    }

    ...
}
  • testClient는 본 프로젝트에서 WebClient를 통해 http request를 전송하는 역할을 한다.
  • new WireMockRule(10001)을 통해 10001번 포트에 mock 서버를 띄운다.
  • setupSpec에서 모든 테스트 시작전에 wire mock server를 시작시킨다.
  • cleanupSpec에서는 모든 테스트가 완료된 후 wire mock server를 종료시킨다.
  • cleanup에서는 assert !server.findUnmatchedRequests().requests를 통해 매 테스트가 끝난 후 미처 확인하지 못한 request가 남아있는지 체크한다. (spock에서 0 * _의 역할과 같다.)

 


WireMock 사용하기

단순 Path에 대한 Mocking 및 검증

def "get"() {
    given:
    def expectedResult = [
        test: "123"
    ]
    server.addStubMapping(
        get(urlPathEqualTo("/main"))
            .willReturn(okJson(objectMapper.writeValueAsString(expectedResult)))
            .build()
    )

    when:
    def result = CoroutineTestUtils.executeSuspendFun {
        testClient.callWithResponse(Map, HttpMethod.GET, "/main", null, null, it)
    } as Map<String, String>

    then:
    result == expectedResult
    server.verify(1, getRequestedFor(urlPathEqualTo("/main")))
    0 * _
}

testClient가 GET /main을 호출했을때 expectedResult가 정상적으로 리턴되는지 확인하는 테스트이다.

 

given:에서 http request에 대한 mocking을 선언한다.

  • server.addStubMapping에서 get(...)을 통해 GET 메서드에 대한 mocking임을 설정한다.
  • urlPathEqualTo를 통해 /main path에 대한 mocking임을 설정한다.
  • willReturn을 통해 http request가 위 조건에 매칭 될 경우 지정한 response를 반환하도록 설정한다.

urlPathEqualTo와 urlEqualTo의 차이

urlPathEqualTo는 말 그대로 path에 대한 일치만 확인하고,
urlEqualTo는 path + query param까지 일치하는지 확인한다.
/main?test=test1일 경우 urlPathEqualTo/main만 확인하고, urlEqualTo/main?test=test1인지 확인한다.

 

then:에서 결과를 검증한다.

  • result == expectedResult http request를 통해 반환된 결과가 기대했던 결과와 일치한 지 확인한다.
    • wire mock은 mocking과 일치한 request가 들어왔으면 willReturn에 선언한 response를 반환하지만, 단 하나라도 조건과 일치하지 않으면 stubbing에러와 함께 willReturn에 선언한 response를 반환하지 않는다.
  • server.verify(1, getRequestedFor(urlPathEqualTo("/main")))를 통해 해당 모킹 서버에 /main path로 요청이 1번 왔는지 검증한다.
    • getRequestedFor, postRequestedFor, deleteRequestedFor 등 http method 별로 검증하는 방법을 제공한다.

 

Query Params에 대한 Mocking 및 검증

query param을 mocking 조건에 추가하도 싶다면 아래 3가지 방법을 선택하면 된다.

urlEqualTo 사용하기

def "get with query params - 1"() {
    given:
    def expectedResult = [
        test: "123"
    ]
    def queryParams = [
        "test": ["test1"]
    ]
    server.addStubMapping(
        get(urlEqualTo("/main?test=test1"))
            .willReturn(okJson(objectMapper.writeValueAsString(expectedResult)))
            .build()
    )

    when:
    def result = CoroutineTestUtils.executeSuspendFun {
        testClient.callWithResponse(Map, HttpMethod.GET, "/main", queryParams, null, it)
    } as Map<String, String>

    then:
    result == expectedResult
    server.verify(1, getRequestedFor(urlEqualTo("/main?test=test1")))
    0 * _
}

urlPathEqualTo가 아닌 urlEqualTo를 사용하면 query param에 대한 조건까지 모킹에 사용 가능하다.

이제 wire mock server는 query param에 대한 내용까지 일치해야지만 지정한 response를 반환할 것이다.

 

다만, url에 대한 하드 코딩 방식의 조건이기 때문에 query param의 key가 여러 개일 경우 순서를 보장할 수 없을 수 있다는 단점이 존재한다.

따라서 아래의 방식으로 좀 더 정형화된 mocking이 가능하다.

 

withQueryParam 사용하기

def "get with query params - 2"() {
    given:
    def expectedResult = [
        test: "123"
    ]
    def queryParams = [
        "test": ["test1"]
    ]
    server.addStubMapping(
        get(urlPathEqualTo("/main"))
            .withQueryParam("test", equalTo("test1"))
            .willReturn(okJson(objectMapper.writeValueAsString(expectedResult)))
            .build()
    )

    when:
    def result = CoroutineTestUtils.executeSuspendFun {
        testClient.callWithResponse(Map, HttpMethod.GET, "/main", queryParams, null, it)
    } as Map<String, String>

    then:
    result == expectedResult
    server.verify(
        1,
        getRequestedFor(urlPathEqualTo("/main"))
            .withQueryParam("test", equalTo("test1"))
    )
    0 * _
}

urlPathEqualTo로 path에 대해서만 체크하고, query param은 .withQueryParam("test", equalTo("test1"))를 통해 체크하는 방식이다.

여러 key에 대한 조건을 줄 때는 withQueryParam을 여러번 사용하면 된다.

 

하지만, 이 방식도 단점이 존재한다. 아시다시피 query param의 value는 리스트를 지원한다.

만약 query param이 "test": ["test1", "test2", "test3"] 이렇고, value 리스트에 대한 순서를 보장할 수 없는 경우라면

위와 같은 방식은 또 한 번 한계를 나타낸다. 따라서 아래와 같은 방식으로 또 한번 보완할 수 있다.

 

andMatching 사용하기

def "get with query params - 3"() {
    given:
    def expectedResult = [
        test: "123"
    ]
    def queryParams = [
        "test": ["test1", "test2", "test3"]
    ]
    def mappingBuilder = get(urlPathEqualTo("/main"))
        .willReturn(okJson(objectMapper.writeValueAsString(expectedResult)))

    queryParams.entrySet().each {
        mappingBuilder.andMatching {request ->
            MatchResult.of(
                Matchers.containsInAnyOrder(it.value as String[])
                    .matches(request.queryParameter(it.getKey()).values())
            )
        }
    }
    server.addStubMapping(mappingBuilder.build())
    ...
}

andMatchingMatchers.containsInAnyOrder를 통해 순서와 관계없이 query param의 key와 value list가 모두 일치하는지 체크할 수 있다.

 

Headers에 대한 Mocking 및 검증

def "get with headers"() {
    given:
    def expectedResult = [
        test: "123"
    ]
    def headers = [
        "test": ["test1"]
    ]
    server.addStubMapping(
        get(urlPathEqualTo("/main"))
            .withHeader("test", equalTo("test1"))
            .willReturn(okJson(objectMapper.writeValueAsString(expectedResult)))
            .build()
    )

    when:
    def result = CoroutineTestUtils.executeSuspendFun {
        testClient.callWithResponse(Map, HttpMethod.GET, "/main", null, headers, it)
    } as Map<String, String>

    then:
    result == expectedResult
    server.verify(
        1,
        getRequestedFor(urlPathEqualTo("/main"))
            .withHeader("test", equalTo("test1"))
    )
    0 * _
}

header에 대한 검증은 query param과 유사하게 .withHeader를 이용하면 된다.

 

Request Body에 대한 Mocking 및 검증

def "post with body"() {
    given:
    def body = [
        "example1": "test1",
        "example2": "test2"
    ]
    server.addStubMapping(
        post(urlPathEqualTo("/main"))
            .withRequestBody(equalToJson(toJson(
                [
                    "example1": "test1"
                ]
            ), true, true))
            .build()
    )

    when:
    CoroutineTestUtils.executeSuspendFun {
        testClient.callWithBody(HttpMethod.POST, "/main", null, null, body, it)
    }

    then:
    server.verify(1, postRequestedFor(urlPathEqualTo("/main")))
    0 * _
}

request body가 정확히 일치하는지 혹은 부분적으로 일치하는지에 대한 체크가 가능하다.

.withRequestBody(equalToJson(String value, boolean ignoreArrayOrder, boolean ignoreExtraElements))를 이용하면 된다.

ignoreArrayOrder, ignoreExtraElements의 이름에서 보다시피 설정하는 방식에 따라 list의 순서까지 체크할 것인지와 body의 field 중 선언하지 않은 field에 대해서도 체크할 것인지 지정할 수 있다.

 

예시에서는 request body는 example1example2 2개의 field가 존재하지만, mocking에서는 example1 field에 대해서만 체크하고 있다. example2의 값뿐만 아니라 추가적으로 들어간 field에 대해선 don't care 하겠다는 의미이다.

 

여러 가지 에러 Response에 대해 반환하기

@Unroll
def "error - #status"() {
    given:
    server.addStubMapping(
        post(urlPathEqualTo("/main"))
            .willReturn(response)
            .build()
    )
    when:
    CoroutineTestUtils.executeSuspendFun {
        testClient.callWithoutResponse(HttpMethod.POST, "/main", null, null, it)
    }
    then:
    def exception = thrown(WebClientResponseException)
    exception.rawStatusCode == status
    server.verify(1, postRequestedFor(urlPathEqualTo("/main")))
    0 * _
    where:
    response       || status
    badRequest()   || 400
    unauthorized() || 401
    forbidden()    || 403
    notFound()     || 404
    serverError()  || 500
    status(422)    || 422
}

지금까지는 willReturn에서 okJson (200 OK + response body)을 반환했지만, 위 예시처럼 여러가지 응답 값을 설정할 수 있다.

각각의 에러 이름을 통한 지정이 가능하고, 또한 status(code)를 통해 특정한 status code를 반환할 수도 있다.

 

Error Response와 Error Body도 반환하기

def "error with body"() {
    given:
    def errorBody = [
        code: 404,
        message: "Device not found"
    ]
    server.addStubMapping(
        post(urlPathEqualTo("/main"))
            .willReturn(badRequest().withBody(toJson(errorBody)))
            .build()
    )

    when:
    CoroutineTestUtils.executeSuspendFun {
        testClient.callWithoutResponse(HttpMethod.POST, "/main", null, null, it)
    }

    then:
    def exception = thrown(WebClientResponseException)
    exception.rawStatusCode == 400
    exception.responseBodyAsString == toJson(errorBody)
    server.verify(1, postRequestedFor(urlPathEqualTo("/main")))
    0 * _
}

.withBody(...)를 사용하면 에러에 대해 body를 포함한 response를 반환할 수 있다.

'Server > Test' 카테고리의 다른 글

Spock 병렬 테스트하기  (0) 2022.10.23
Spring Boot 테스트 속도 개선기  (0) 2022.09.19
복사했습니다!