웹/Spring

[Spring] WireMock 기반의 테스트환경 구축하기

SeongOnion 2023. 11. 6. 22:36
728x90

WireMock?

WireMock is a library for stubbing and mocking web services. It constructs an HTTP server that we can connect to as we would to an actual web service.
When a WireMock server is in action, we can set up expectations, call the service and then verify its behaviors.

https://www.baeldung.com/introduction-to-wiremock

 

WireMock은 Mock API를 구축해주는 라이브러리로써, 외부 API와 통신을 하는 애플리케이션 코드를 테스트하기 위해 주로 사용된다. 

 

특히 서버가 MSA 기반으로 설계되었다면 기능의 정상적인 동작을 테스트하기 위해 이러한 Mock 서버가 필수적이다.

 

WireMock을 사용하면 특정 HTTP 요청에 대한 응답 코드, 헤더, 바디값 등을 스터빙할 수 있게 해준다.

 

보통 이러한 테스트 환경은 한 번 설정하고 나면 문제가 발생하지 않는 한 변경없이 계속 사용하기 때문에, 참고할만한 스니펫 코드를 하나 만들어두고 프로젝트를 새로 팔때마다 두고두고 참고할 수 있도록 하자.

 

의존성 추가 및 구동

WireMock을 사용하기 위해선 아래의 의존성을 testImplementation에 추가해야한다.

// build.gradle
dependencies {
    testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-stub-runner'
}

이후, Mock Server가 필요한 테스트코드에 @AutoConfigureWireMock 을 추가해주면 자동 설정이 완료된다. (너무 쉽다)

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock;

@AutoConfigureWireMock(port = 0)
@SpringBootTest
public class MockServerTest {
}

 

@AutoConfigureWireMock은 아래와 같이 생겼다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(WireMockConfiguration.class)
@PropertyMapping(value = "wiremock.server", skip = SkipPropertyMapping.ON_DEFAULT_VALUE)
@AutoConfigureHttpClient
@Inherited
public @interface AutoConfigureWireMock {

	/**
	 * Configures WireMock instance to listen on specified port.
	 * <p>
	 * Set this value to 0 for WireMock to listen to a random port.
	 * </p>
	 * @return port to which WireMock instance should listen to
	 */
	int port() default 8080;

	/**
	 * If specified, configures WireMock instance to enable <em>HTTPS</em> on specified
	 * port.
	 * <p>
	 * Set this value to 0 for WireMock to listen to a random port.
	 * </p>
	 * @return port to which WireMock instance should listen to
	 */
	int httpsPort() default -1;

	/**
	 * The resource locations to use for loading WireMock mappings.
	 * <p>
	 * When none specified, <em>src/test/resources/mappings</em> is used as default
	 * location.
	 * </p>
	 * <p>
	 * To customize the location, this attribute must be set to the directory where
	 * mappings are stored.
	 * </p>
	 * @return locations to read WireMock mappings from
	 */
	String[] stubs() default { "" };

	/**
	 * The resource locations to use for loading WireMock response bodies.
	 * <p>
	 * When none specified, <em>src/test/resources/__files</em> is used as default.
	 * </p>
	 * <p>
	 * To customize the location, this attribute must be set to the parent directory of
	 * <strong>__files</strong> directory.
	 * </p>
	 * @return locations to read WireMock response bodies from
	 */
	String[] files() default { "" };

}

기본적으로 4가지 옵션을 적용할 수 있는 것으로 보이고, 이외의 추가적인 옵션을 적용하고 싶다면 @Import로 가져오는 WireMockConfiguration 클래스를 직접 정의해 빈으로 띄워주면 되는 것으로 보인다.

 

우선 스니펫 코드에선 간단히 다이나믹 포트를 적용하기 위해 port 옵션만 0으로 설정하였다.

 

 이후, 아무 테스트 코드나 작성 후 실행시켜서 Mock Server가 제대로 구동되는지 확인해보자.

@Test
void mockServerRunTest() {
    System.out.println("테스트 실행..");
}
INFO 98190 --- [    Test worker] w.o.e.jetty.server.AbstractConnector     : Started NetworkTrafficServerConnector@7f2c57fe{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:12087}
INFO 98964 --- [    Test worker] w.org.eclipse.jetty.server.Server        : Started @1399ms
INFO 98190 --- [    Test worker] com.wiremock.settestenv.MockServerTest   : Started MockServerTest in 1.085 seconds (JVM running for 1.576)
테스트 실행..
INFO 98190 --- [    Test worker] w.o.e.jetty.server.AbstractConnector     : Stopped NetworkTrafficServerConnector@7f2c57fe{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:12087}

 

로그로 확인할 수 있다시피, 동적으로 12087번 포트를 할당받은 Mock Server가 떴다가 테스트 종료 후 다시 죽은 것을 확인할 수 있다.

 

응답 값 스터빙하기

이제 구동시킨 Mock Server가 요청을 받으면 특정 응답값을 내려주도록 스터빙해보자.

 

우리의 애플리케이션이 주문과 관련된 외부 API를 사용하고 있고, 해당 API는 GET /orders/{order_no} 호출 시 아래 형태의 json 리스폰스를 내려준다고 가정해보자.

{
    "id": String,
    "name": String,
    "type": String
}

 

스터빙하고 하는 응답값과 동일한 구조의 자바 객체, Map객체 혹은 json 파일의 경로를 직접 지정해서 스터빙해줄 수 있다.

 

우선 Map 객체로 구현해보자.

void stubResponse() throws Exception {
    Map<String, String> expectedBody = new HashMap<>();
    expectedBody.put("id", "1");
    expectedBody.put("name", "모자");
    expectedBody.put("type", "의류");

    String expectedBodyAsString = objectMapper.writeValueAsString(expectedBody);

    WireMock.stubFor(WireMock.get(WireMock.urlMatching("/orders/[0-9]+"))
            .willReturn(
                    ResponseDefinitionBuilder
                            .responseDefinition()
                            .withBody(expectedBodyAsString)
                            .withHeader("Content-Type", "application/json")
                            .withStatus(200)
            )
    );
}

 

우리가 기대하는 형태의 Map 객체를 생성 후, 직렬화하여 withBody()에 세팅해준다.

 

바디값 이외에도 헤더값, HTTP Status Code 또한 스터빙해줄 수 있다.

 

이제 해당 API를 실제 호출하는 메인코드를 테스트해보자.

@FeignClient(url = "${order.url}", name = "Order-Client")
public interface OrderFeignClient {
    @GetMapping(value = "/orders/{orderNo}", produces = "application/json")
    OrderResponse getOrders(@PathVariable("orderNo") long orderNo);
}

위에서 언급한 API 스펙을 FeignClient로 간단하게 구현하였다.

 

추가로, FeignClient의 URL값을 Mock Server의 동적 포트에 연결하기 위해 테스트 패키지의 yml파일에 다음과 같이 설정해주자.

// application.yml

order:
  url: localhost:${wiremock.server.port}

 

이제 테스트코드에서 getOrders를 호출해 스터빙한 응답값이 제대로 내려오는지 확인해보자.

@Test
void stubResponseTest() throws Exception {
    stubResponse();

    OrderResponse order = orderClient.getOrders(1);

    assertThat(order.getId()).isEqualTo("1");
    assertThat(order.getName()).isEqualTo("모자");
    assertThat(order.getType()).isEqualTo("의류");
}

테스트는 이변없이 성공했다.

 

Mock Server서버가 켜지고 꺼지는 시점

그렇다면 Mock Server는 테스트 실행 시작 후 언제 시작되고 언제 꺼지는 것일까?

 

그리고 또, 응답값을 스터빙할 수 있다는 점에서 동일한 URL에 대하여 2개 이상의 응답값을 스터빙하면 Dirty Context로 감지되는 것일까?

 

하나의 @SpringBootTest에서 여러 개의 테스트를 실행시키는 경우

우선 하나의 SpringBootTest 컨텍스트에서 테스트 두 개를 함께 실행시켜보자.

@Test
void mockServerRunTest() {
    System.out.println("테스트 실행..");
}

@Test
void mockServerRunTest2() {
    System.out.println("두 번째 테스트 실행..");
}
INFO 98964 --- [    Test worker] w.o.e.jetty.server.AbstractConnector     : Started NetworkTrafficServerConnector@2ca3d826{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:11334}
INFO 98964 --- [    Test worker] w.org.eclipse.jetty.server.Server        : Started @1399ms
INFO 98964 --- [    Test worker] com.wiremock.settestenv.MockServerTest   : Started MockServerTest in 1.094 seconds (JVM running for 1.578)
테스트 실행..
두 번째 테스트 실행..
INFO 98964 --- [    Test worker] w.o.e.jetty.server.AbstractConnector     : Stopped NetworkTrafficServerConnector@2ca3d826{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:11334}

테스트가 두 개 실행되더라도 Mock Server는 하나만 구동되었다.

 

하나의 @SpringBootTest에서 동일한 URL에 대한 스터빙이 여러 개 정의된 경우

이번엔 두 개의 테스트를 한 번에 돌리되, 각 테스트에서 동일한 요청에 대하여 서로 다른 응답값을 스터빙해보자.

void stubResponse() throws Exception {
    Map<String, String> expectedBody = new HashMap<>();
    expectedBody.put("id", "1");
    expectedBody.put("name", "모자");
    expectedBody.put("type", "의류");

    String expectedBodyAsString = objectMapper.writeValueAsString(expectedBody);

    WireMock.stubFor(WireMock.get(WireMock.urlMatching("/orders/[0-9]+"))
            .willReturn(
                    ResponseDefinitionBuilder
                            .responseDefinition()
                            .withBody(expectedBodyAsString)
                            .withHeader("Content-Type", "application/json")
                            .withStatus(200)
            )
    );
}

void stubAnotherResponse() throws Exception {
    Map<String, String> expectedBody = new HashMap<>();
    expectedBody.put("id", "1");
    expectedBody.put("name", "휴대폰");
    expectedBody.put("type", "전자기기");

    String expectedBodyAsString = objectMapper.writeValueAsString(expectedBody);

    WireMock.stubFor(WireMock.get(WireMock.urlMatching("/orders/[0-9]+"))
            .willReturn(
                    ResponseDefinitionBuilder
                            .responseDefinition()
                            .withBody(expectedBodyAsString)
                            .withHeader("Content-Type", "application/json")
                            .withStatus(200)
            )
    );
}

그리고 마찬가지로 테스트를 함께 돌려준다.

@Test
void stubResponseTest() throws Exception {
    stubResponse();

    OrderResponse order = orderClient.getOrders(1);

    assertThat(order.getId()).isEqualTo("1");
    assertThat(order.getName()).isEqualTo("모자");
    assertThat(order.getType()).isEqualTo("의류");
}

@Test
void stubResponseTest2() throws Exception {
    stubAnotherResponse();

    OrderResponse order = orderClient.getOrders(1);

    assertThat(order.getId()).isEqualTo("1");
    assertThat(order.getName()).isEqualTo("휴대폰");
    assertThat(order.getType()).isEqualTo("전자기기");
}
INFO 99030 --- [    Test worker] w.o.e.jetty.server.AbstractConnector     :Started NetworkTrafficServerConnector@6c951ada{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:11782}
INFO 99030 --- [    Test worker] w.org.eclipse.jetty.server.Server        : Started @1785ms
...
INFO 99030 --- [    Test worker] w.o.e.jetty.server.AbstractConnector     : Stopped NetworkTrafficServerConnector@6c951ada{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:11782}
...
INFO 99030 --- [    Test worker] w.o.e.jetty.server.AbstractConnector     : Started NetworkTrafficServerConnector@6c951ada{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:11782}
INFO 99030 --- [    Test worker] w.org.eclipse.jetty.server.Server        : Started @2332ms
...
INFO 99030 --- [ionShutdownHook] w.o.e.jetty.server.AbstractConnector     : Stopped NetworkTrafficServerConnector@6c951ada{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:11782}

로그에는 Mock Server의 가동과 중지를 알리는 Started와 Stopped 메시지가 두 번씩 뜨긴하지만,

 

결과적으로 Mock Server 인스턴스의 주소값과 포트 번호가 일치하는 것으로 봐선 실제 Mock Server 자체는 하나만 뜨는 것으로 보인다.

INFO 99030 --- [tp1992844647-35] WireMock: Request received:
127.0.0.1 - GET /orders/1

Accept: [application/json]
User-Agent: [Java/17.0.5]
Host: [localhost:11782]
Connection: [keep-alive]

Matched response definition:
{
  "status" : 200,
  "body" : "{\"name\":\"휴대폰\",\"id\":\"1\",\"type\":\"전자기기\"}",
  "headers" : {
    "Content-Type" : "application/json"
  }
}

Response:
HTTP/1.1 200
Content-Type: [application/json]
Matched-Stub-Id: [234796d0-10af-46ae-abc2-ef3d2fef94ee]


INFO 99030 --- [tp1992844647-23] WireMock: Request received:
127.0.0.1 - GET /orders/1

Accept: [application/json]
User-Agent: [Java/17.0.5]
Host: [localhost:11782]
Connection: [keep-alive]

Matched response definition:
{
  "status" : 200,
  "body" : "{\"name\":\"모자\",\"id\":\"1\",\"type\":\"의류\"}",
  "headers" : {
    "Content-Type" : "application/json"
  }
}

Response:
HTTP/1.1 200
Content-Type: [application/json]
Matched-Stub-Id: [5e350c0a-9ee5-47b6-b6a7-4e1ed80debb9]

테스트 후 찍히는 WireMock의 로그를 보면 조금 더 명확한 힌트가 나오는데, 스터빙된 각각의 Response가 Matched-Stub-Id 라는 이름으로 저마다 고유값을 지니고 있는 것으로 보인다.

 

만약 스터빙 값이 다르다고 Mock Server를 두 번 띄운다면 저런 식의 아이디값을 기록할 필요도 없을 것이다.

 

하나의 테스트 메서드 블록 내에서 두 개의 스터빙을 정의한 경우

마지막으로, 하나의 테스트에서 서로 다른 응답값을 스터빙해보자. 만약 여기서 에러가 난다면 WireMock의 스터빙 값은 단일 테스트 블록 단위로 관리된다고 볼수도 있을 것이다.

@Test
void stubResponseTest() throws Exception {
    stubAnotherResponse();
    stubResponse();

    OrderResponse order = orderClient.getOrders(1);

    assertThat(order.getId()).isEqualTo("1");
    assertThat(order.getName()).isEqualTo("모자");
    assertThat(order.getType()).isEqualTo("의류");
}

위 테스트는 실패하긴 했지만, 스터빙이 겹친다거나 하는 오류가 아닌 Assertion에서 발생한 오류였다.

실제로 두 번째 순서로 호출한 stubAnotherResponse()order.name에 "휴대폰"을 응답으로 스터빙했다.

 

만약 아래와 같이 스터빙의 순서를 바꾸면, 테스트는 성공적으로 통과한다.

@Test
void stubResponseTest() throws Exception {
    stubAnotherResponse();
    stubResponse();

    OrderResponse order = orderClient.getOrders(1);

    assertThat(order.getId()).isEqualTo("1");
    assertThat(order.getName()).isEqualTo("모자");
    assertThat(order.getType()).isEqualTo("의류");
}

정리하자면, WireMock의 동일한 URL에 스터빙된 값은 가장 마지막에 설정된 값을 최종 값으로 판단한다고 볼 수 있다.

 

서로 다른 @SpringBootTest 컨텍스트를 사용하는 경우

그렇다면 진짜진짜 마지막으로, 서로 다르게 로드된 @SpringBootTest에서도 동일한 Mock Server를 공유할까?

@AutoConfigureWireMock(port = 0)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DirtiesContext
public class DifferentSpringBootTest {
    @Test
    void test() {
        System.out.println("new context");
    }
}

@DirtiesContext를 적용해 스프링 부트 테스트가 새로운 컨텍스트를 띄우도록 강제하고, 다른 스프링 부트 테스트와 함께 작동시켜보자.

INFO 99260 --- [    Test worker] w.o.e.jetty.server.AbstractConnector     : Started NetworkTrafficServerConnector@367d34c0{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:11389}
INFO 99260 --- [    Test worker] w.org.eclipse.jetty.server.Server        : Started @1445ms
INFO 99260 --- [    Test worker] c.w.settestenv.DifferentSpringBootTest   : Started DifferentSpringBootTest in 1.097 seconds (JVM running for 1.603)
new context
INFO 99260 --- [    Test worker] w.o.e.jetty.server.AbstractConnector     : Stopped NetworkTrafficServerConnector@367d34c0{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:11389}
...
...

INFO 99260 --- [    Test worker] w.o.e.jetty.server.AbstractConnector     : Started NetworkTrafficServerConnector@579dde54{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:10994}
INFO 99260 --- [    Test worker] w.org.eclipse.jetty.server.Server        : Started @1887ms
INFO 99260 --- [    Test worker] com.wiremock.settestenv.MockServerTest   : Started MockServerTest in 0.133 seconds (JVM running for 1.943)
...
INFO 99260 --- [    Test worker] w.o.e.jetty.server.AbstractConnector     : Stopped NetworkTrafficServerConnector@579dde54{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:10994}

로그를 확인해본 결과, 서로 다른 SpringBootTest는 서로 다른 WireMock 인스턴스를 서로 다른 포트에서 Mock Server를 구동시킨 후 테스트가 종료되며 중지시켰다.

 

작동방식 요약

결과적으로, WireMock의 작동 방식을 정리하자면 다음과 같다.

 

1. 하나의 @SpringBootTest Context에서는 하나의 Mock Server를 띄워 공유한다. 즉, 새로운 Context가 로드되면 새로운 Mock Server가 뜬다.

 

2. 하나의 Mock Server에 동일한 URL에 대한 스터빙이 여러 개 정의되어있는 경우, 가장 마지막에 정의된 스터빙을 따른다.

 

해당 내용은 사실, 공식문서에 정의된 부분을 보면 유추할 수 있다.

Using @AutoConfigureWireMock adds a bean of type WiremockConfiguration to your test application context, where it is cached between methods and classes having the same context. The same is true for Spring integration tests.