야, 너도 WireMock으로 테스트할 수 있어
통합 테스트(Integration Test)를 작성할 때 외부 API를 호출하는 로직이 들어가 있어서 테스트 코드를 작성하기 난감했던 적 없으신가요?
혹은 외부 API가 비정상적인 응답을 반환했을 때에 대한 테스트를 작성하고 싶었지만, 비정상 응답을 발생시킬 수 없어서 포기했던 적은 없으신가요?
개발 과정에서 테스트는 매우 중요한 역할을 합니다.
테스트에는 기능 테스트, 단위 테스트, 통합 테스트 등 여러 종류가 있는데,
그 중 통합 테스트(Integration Test)는 애플리케이션의 다양한 부분들이 서로 잘 작동하는지 확인하는 데 중점을 둡니다.
특히, 외부 API를 호출하는 로직이 포함된 경우 이를 제대로 테스트하는 것이 매우 중요한데요.
외부 API에 영향을 주지 않으면서, 장애 상황이나 다양한 응답 케이스에 대한 테스트가 필요합니다.
이 글에서는 WireMock
이라는 라이브러리가 무엇인지, 팀에서 테스트 코드에 WireMock
을 왜 적용했는지, 어떻게 적용하는지, 어떤 검증을 하는지 소개해보려 합니다.
01. 배경 설명
셀러시스템팀에서는 기존에 외부 API를 호출하는 로직에 대해 통합 테스트를 작성할 때 아래의 세 가지 방법을 사용하여 작성했습니다.
01-1. 베타환경 API를 직접 호출
먼저 베타 환경에서 API를 직접 호출해 테스트하는 방법입니다.
이 방식은 테스트 코드를 실행할 때마다 타 시스템에 부하를 줄 수 있고,
누군가 테스트 코드에서 사용하는 데이터를 변경하거나, 베타 API에 문제가 있을 경우 테스트가 실패하게 됩니다.
또한, Read Timeout 상황 또는 호출하는 API에 장애가 발생했을 때 에러 디코딩에 대한 테스트 작성이 불가합니다.
01-2.Postman에 mock 서버를 구성하고 호출
Postman에서 제공하는 mock 서버를 구성해 호출하는 방식도 사용했었습니다.
Postman mock 서버의 경우에는 베타 환경 API를 직접 호출할 때 문제점은 거의 모두 해소됐지만
API 호출량이 제한되어 있어서, 호출량이 초과되는 경우에는 그 이후 테스트가 모두 실패하게 됩니다. (돈을 내면 해결될 수도..)
01-3. Mockito 라이브러리의 @MockBean을 사용
API 호출 자체를 하지 않도록 @MockBean
어노테이션을 사용하는 방식도 있습니다.
저희 프로젝트는 외부 API를 호출할 때 FeignClient
를 사용하는데, FeignClient
에 @MockBean
어노테이션을 사용하면
FeignClient
자체에 대한 테스트는 어렵고, FeignClient
를 의존하는 기능들에 대한 테스트만 가능하게 됩니다.
예를 들어, 외부 API에서 404 에러가 발생했을 때 FeignClient
가 null을 리턴하는지를 검증하는 것이 아닌
FeignClient
가 null을 리턴한 후에 로직만 검증할 수 있습니다.
그리고 서로 다른 테스트에서 선언한 @MockBean
클래스가 다르다면,
각 테스트마다 애플리케이션을 새로 띄우게 되면서 테스트 수행 시간이 기하급수적으로 늘어나게 되기 때문에
@MockBean
선언하는 것 자체를 최대한 줄이고 싶었습니다.
이처럼 각각의 방법마다 불편함이 있었고, 그 불편함 들을 해소하기 위해 WireMock
을 도입했습니다.
02. WireMock이란?
WireMock 홈페이지에 들어가면 대문짝만하게 보이는 문구인
Mock the APIs You Depend On
라는 문구 뜻 그대로, 의존하는 API 들을 mocking 해주는 도구입니다.
WireMock
은 보통 두 가지 방식으로 사용합니다.
- Docker 환경 또는 별도로 서버를 구동하는 Standalone 방식
- Java 코드 내에 의존성을 추가해, JUnit 테스트할 때만 구동하는 방식
테스트 시점에만 필요하기도 했고,
Spring Cloud
에서 WireMock
을 쉽게 사용할 수 있도록 라이브러리를 제공해 주기 때문에
2번째 방식으로 Junit 테스트할 때만 구동하도록 했습니다.
03. WireMock 적용하기
(여기에서 설명할 때 나온 코드는 Github에서 직접 확인해 보실 수 있습니다.)
예시를 위해, https://httpbin.org/ 에서 제공하는 API를 활용했습니다.
03-1. 초기 프로젝트 세팅 (커밋)
HttpBinFeignClient.java
@FeignClient(name = "httpBinClient", url = "${client.httpbin-api.access-url}", decode404 = true)
public interface HttpBinFeignClient {
@GetMapping("/anything/{id}")
AnythingResponse getAnythingById(@PathVariable("id") Long id);
}
application-httpbin-client.yml
...
feign:
client:
config:
httpBinClient:
connectTimeout: 1000
readTimeout: 3000
client:
httpbin-api:
access-url: http://httpbin.org
위와 같은 httpbin을 호출하고, readTimeout 이 3초로 설정된 FeignClient
가 있을 때,
일반적인 테스트 코드는 아래와 같이 작성할 수 있습니다.
HttpBinFeignClientIntegrationTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class HttpBinFeignClientIntegrationTest {
@Autowired
HttpBinFeignClient httpBinFeignClient;
@Test
@DisplayName("getAnythingById 1 정상 조회")
void getAnythingById_1() {
// when
AnythingResponse response = httpBinFeignClient.getAnythingById(1L);
// then
assertThat(response)
.as("응답이 null 이 아님")
.isNotNull()
.as("data 가 비어있음")
.extracting(AnythingResponse::getData)
.isEqualTo(Strings.EMPTY);
}
}
하지만 현재 코드만으로는 Read Timeout 설정이 정상적으로 적용되었는지 테스트하는 코드를 작성하기에는 무리가 있습니다.
(httpbin에서 응답에 delay를 줄 수 있는 API를 제공하긴 하지만.. 그게 중요한 게 아닌 거 아시죠..?)
03-2. WireMock 설정
지금은 한 개의 client 밖에 없지만, 여러 client가 존재할 때 편하게 적용할 수 있도록
client-core 모듈에 test fixture를 사용하여 구성하는 방식으로 설명하겠습니다.
03-2-1. 의존성 추가 (커밋)
client-core 모듈에 spring-cloud-contract-wiremock
의존성을 추가해 줍니다.
build.gradle
plugins {
...
id 'java-test-fixtures' // test fixture 를 사용하기 위한 플러그인 추가
}
dependencies {
...
testFixturesImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock'
...
}
03-2-2. 테스트시 사용할 WireMockSupport 구성 (커밋)
client-core 모듈 testFixtures에 WireMockSupport
를 구성합니다.
여기서 WireMockSupport
를 구성하는 이유는 WireMock
이 필요한 테스트에 WireMockSupport
를 상속시켜서 쉽게 WireMock
을 사용할 수 있게 하기 위함입니다.
(테스트를 하나만 만들 거 아니잖아요?ㅎㅎ)
HttpBinFeignClientIntegrationTest.java
@AutoConfigureWireMock(port = 0, stubs = "classpath:mappings")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class WireMockSupport {
}
제일 먼저 @AutoConfigureWireMock
어노테이션을 달아주어서 테스트 실행 시 WireMock
이 실행될 수 있도록 합니다.
그리고 @AutoConfigureWireMock
설정 중에 port
와 stubs
를 입력하는 부분이 있습니다.
port
설정은 WireMock
애플리케이션과 통신할 port 번호입니다.
0
으로 지정하면 random으로 port 번호를 선택하게 됩니다.
별도로 지정해야 할 필요가 있다면 변경해도 크게 상관없습니다. (아! 당연히 사용 중인 port 번호는 쓰면 안 되겠죠?)
stubs
설정은 테스트 시 사용할 stub용 json 파일들을 저장하는 위치입니다.
아무것도 입력을 하지 않으면, 기본 설정으로 src/test/resources/mappings
를 바라보게 되는데
저희처럼 testFixtures에 구성을 했을 때에는 src/testFixtures/resources/mappings
를 바라보는 것이 아니기 때문에,
classpath에 있는 mappings를 바라보도록 명시해 주었습니다.
여기까지 테스트 코드를 위한 설정은 마무리가 됐고 (참 쉽죠?)
이제 client 설정만 남았습니다.
03-2-3. yml 설정 추가 (커밋)
httpbin-client 모듈의 test profile에서 WireMock 애플리케이션과 통신하도록 설정을 추가해 줍니다.
위에서 random으로 port 번호를 선택하도록 했기 때문에, port 번호를 어떻게 지정해 주어야 하나? 생각하셨다면
아래와 같이 yml 파일에 ${wiremock.server.port}
를 사용하면 쉽게 해결됩니다. (Spring Cloud Contract WireMock 공식 문서)
application-httpbin-client.yml
...
---
spring:
config:
activate:
on-profile: test
client:
httpbin-api:
access-url: http://localhost:${wiremock.server.port}
03-2-4. 기존 테스트에 적용 (커밋)
여기까지 WireMock
으로 테스트를 수행할 준비는 모두 마쳤습니다.
이제 기존 테스트 코드에 적용해 보겠습니다.
먼저, 기존 테스트 코드에서 `WireMockSupport를 상속합니다.
HttpBinFeignClientIntegrationTest.java
class HttpBinFeignClientIntegrationTest extends WireMockSupport {
...
}
아무런 추가 작업 없이 이 상태 그대로 테스트 코드를 실행해 보면
Caused by: java.io.FileNotFoundException: class path resource [mappings/] cannot be resolved to URL because it does not exist
at org.springframework.core.io.ClassPathResource.getURL(ClassPathResource.java:214)
at org.springframework.core.io.support.PathMatchingResourcePatternResolver.findPathMatchingResources(PathMatchingResourcePatternResolver.java:501)
at org.springframework.core.io.support.PathMatchingResourcePatternResolver.getResources(PathMatchingResourcePatternResolver.java:298)
at org.springframework.cloud.contract.WireMock.WireMockConfiguration.registerStubs(WireMockConfiguration.java:217)
... 120 more
이런 Exception 이 발생하게 됩니다.
위 Exception 은 03-2-2 단계에서 설정했던 stubs 위치에 파일이 아무것도 없기 때문에 발생하는 에러입니다.
이때 당황하지 말고, stubs 위치(src/testFixtures/resources/mapping
)에 stub 파일을 만들면 됩니다.
mappings/http-bin/anything/1_OK.json
{
"request": {
"method": "GET",
"urlPath": "/anything/1"
},
"response": {
"headers": {
"Content-Type": "application/json"
},
"status": 200,
"jsonBody": {
"data": ""
}
}
}
stub 파일은 위와 같이 json 파일로 관리할 수 있습니다.
mappings 하위에 모든 디렉토리 안에 있는 json 파일을 stub 파일로 인식하기 때문에
여러 개의 client, 여러 개의 요청에 대해서 stub을 만들 때 디렉터리를 통해 구분해 주시면 가독성이 좋아집니다.
저는 {client-name}/{api-path}/{id}_{case}.json
이런 식으로 구분하는 편입니다.
위 stub 파일 내용의 구조를 간단히 살펴보면
request의 내용과 일치하는 요청이 오면
response의 내용으로 응답을 주겠다는 내용입니다.
상세 설정 내용은 밑에서 예시와 함께 설명드리겠습니다.
이렇게 stub 용 json 파일까지 정상적으로 생성했다면, 테스트 코드를 실행했을 때
2024-02-29 16:52:01.683 INFO 46964 --- [tp1505978931-35] WireMock : Request received:
127.0.0.1 - GET /anything/1
Accept: [*/*]
Content-Length: [0]
Host: [localhost:11198]
Connection: [keep-alive]
User-Agent: [Apache-HttpClient/4.5.13 (Java/11.0.11)]
Matched response definition:
{
"status" : 200,
"jsonBody" : {
"data" : ""
},
"headers" : {
"Content-Type" : "application/json"
}
}
Response:
HTTP/1.1 200
Content-Type: [application/json]
Matched-Stub-Id: [0f785912-88fa-4d08-b9e3-ea950e25bf97]
위처럼 WireMock 애플리케이션이 받은 Request와 stub 파일과 매칭된 응답이 로그에 나오면서 정상적으로 테스트가 수행되는 것을 확인할 수 있습니다.
04. WireMock 활용하기
간단히 stub을 만드는 방법까지 살펴보았는데요.
팀 내에서 어떻게 활용하고 있는지 예시 3가지만 소개해 드리고 마칠까 합니다.
04-1. Read Timeout 설정 검증 (커밋)
기존에 작성했던 테스트에 이어서, API 호출 시 Read Timeout이 발생하는 상황을 테스트해 보겠습니다.
기존에 /anything/1
로 요청을 보냈을 때 정상 응답이 나오도록 했었기 때문에
이번에는 /anything/2
로 요청했을 때, Read Timeout 이 발생하도록 해보겠습니다.
class HttpBinFeignClientIntegrationTest extends WireMockSupport {
...
@Test
@DisplayName("getAnythingById 2 Timeout 발생")
void getAnythingById_2() {
// when
Throwable throwable = catchThrowable(() -> httpBinFeignClient.getAnythingById(2L));
// then
assertThat(throwable)
.as("SocketTimeoutException 발생")
.hasCauseInstanceOf(SocketTimeoutException.class)
.hasMessageContaining("Read timed out");
}
}
TDD 방식으로 기대 결과를 먼저 테스트로 작성하고 나서
위 테스트가 성공하도록 해보겠습니다.
이 프로젝트에서는 application-httpbin-client.yml
에 readTimeout을 3초로 설정했기 때문에,
3초 이상 delay 이후 응답하도록 stub용 json 파일을 새로 만들겠습니다.
mappings/http-bin/anything/2_timeout.json
{
"request": {
"method": "GET",
"urlPath": "/anything/2"
},
"response": {
"headers": {
"Content-Type": "application/json"
},
"status": 200,
"fixedDelayMilliseconds": 3500,
"jsonBody": {
"data": ""
}
}
}
기존과 거의 동일한 내용이지만, response 내에 fixedDelayMilliseconds
필드를 추가해서 원하는 시간 동안 Delay 후 응답하도록 설정했습니다.
(3초로 잡을 경우, Read Timeout 이 발생하지 않을 수 있음)
이렇게 Read Timeout 테스트를 위한 stub 용 json 파일까지 만들어주고, 테스트를 다시 실행하면 정상적으로(?) Read Timeout이 발생하게 되고
테스트 코드 검증에도 성공하는 것을 볼 수 있습니다.
응답에 delay를 주는 방법에는 fixedDelayMilliseconds
외에도 다음과 같이 여러 가지 방식으로 delay를 설정할 수 있습니다.
- 무작위로 delay 주는 방식
- 특정 시간 범위 안에서 응답하도록 하는 방식
- 둥등
해당 내용은 WireMock 공식 문서(simulating-faults)에 자세히 나와있으니 참고하시기 바랍니다.
04-2. Priority를 이용한 Default 응답 만들기
외부 API에 의존하는 Service의 통합 테스트 코드를 작성하다 보면, stub 용 json 파일에 정의되지 않은 요청으로 API 호출이 발생할 수 있는데
이때마다 stub 용 json 파일을 작성해 줘야 한다면, WireMock을 쓰는 게 맞나 회의감이 들 수 있습니다.
그래서 특이 케이스(Read Timeout, 4xx ~ 5xx Error, 등등)에 대해서만 stub 용 json 파일을 작성하고,
priority 설정과 request 패턴 매칭을 통해 default 응답이 내려가도록 설정해 줄 수 있습니다.
request에 패턴으로 요청을 매칭하는 방식이 어떤 값을 기준으로 구분하는지에 따라 달라지는데
- Path variable을 통해 구분하는 경우
- Body의 내용을 통해 구분하는 경우
위 두 가지 경우에 대해서 예시와 함께 설명을 조금 드려보겠습니다.
04-2-1. Path variable을 통해 구분하는 요청 (커밋)
Path variable을 통해 구분하는 요청의 경우
priority를 통해 우선순위를 9999(최하)로 변경
request에 urlPath 가 아닌, urlPathPattern을 사용해서 원하는 url 패턴에 대해 모두 적용
두 가지 설정을 통해, 특정 path 요청에 대해 정규 표현식으로 매칭해서 Default 응답을 설정해 줄 수 있습니다.
예시) mappings/http-bin/anything/default_OK.json
{
"priority": 9999,
"request": {
"method": "GET",
"urlPathPattern": "/anything/([0-9]+)"
},
"response": {
"headers": {
"Content-Type": "application/json"
},
"status": 200,
"jsonBody": {
"data": ""
}
}
}
그래서 아래와 같이 별도로 지정하지 않은 Path variable에 대한 요청이더라도 설정한 정규 표현식에 만족한다면 정상적으로 응답이 조회되는 것을 확인할 수 있습니다.
class HttpBinFeignClientIntegrationTest extends WireMockSupport {
...
@Test
@DisplayName("getAnythingById 123123 기본 응답 조회")
void getAnythingById_3() {
// when
AnythingResponse response = httpBinFeignClient.getAnythingById(123123L);
// then
assertThat(response)
.as("응답이 null 이 아님")
.isNotNull()
.as("data 가 비어있음")
.extracting(AnythingResponse::getData)
.isEqualTo(Strings.EMPTY);
}
}
주의하실 점은, urlPathPattern
과 비슷한 동작을 하는 urlPattern
도 있습니다.
urlPattern
: Query parameter 가 있는 경우 Query parameter도 같이 판단urlPathPattern
: Query parameter를 무시하고 단순 Path까지만 판단
이런 차이가 있기 때문에, 팀에서는 보통 urlPathPattern를 사용해서 Path까지만 비교하도록 했고
Query parameter도 같이 체크해야 하는 경우에는, queryParameters 필드를 별도로 사용해서 체크하고 있습니다.
Query parameter 매칭에 대한 내용은 이 문서에서는 생략했는데,
궁금하신 부분이 있다면 WireMock 공식 문서(request-matching) 부분을 참고하시면 좋을 것 같습니다.
04-2-2. Body의 내용을 통해 구분하는 요청 (커밋)
Body의 내용을 통해 구분하는 경우
- 마찬가지로, priority를 통해 우선순위를 9999(최하)로 변경
- bodyPatterns[].equalToJson.data에 아래와 같은 placeholder 적용
- ${json-unit.regex}[A-Z]+
- ${json-unit.any-string}
- ${json-unit.any-boolean}
- ${json-unit.any-number}
두 가지 설정을 통해, Body의 내용으로도 패턴 매칭을 통해 default 응답을 설정해 줄 수 있습니다.
예시) mappings/http-bin/anything/default_OK.json
{
"priority": 9999,
"request": {
"method": "POST",
"urlPath": "/anything",
"bodyPatterns" : [ {
"equalToJson" : {
"data": "${json-unit.any-string}"
}
} ]
},
"response": {
"headers": {
"Content-Type": "application/json"
},
"status": 200,
"jsonBody": {
"data": ""
}
}
}
placeholder는 JsonUnit에서 제공하는 placeholder를 그대로 사용할 수 있습니다.
JsonUnit의 placeholder에 대해 더 자세히 알고 싶으시다면 JsonUnit의 Type placeholders 부분을 참고해 주세요
테스트 코드를 통한 검증은 중복되는 내용이라 설명은 넘어가겠습니다.
(궁금하시다면 커밋을 참고해 주세요)
04-3. 서로 다른 API의 path가 동일한 경우 (커밋)
WireMock
을 위에서 설명드린 것처럼 동일하게 구성한 경우
WireMock
애플리케이션은 몇 개의 API를 사용하든 상관없이 하나의 애플리케이션만 실행되게 됩니다.
따라서, 이런 상황에서 서로 다른 두 개의 API의 Path가 같다면,
WireMock
애플리케이션은 host가 하나이기 때문에 이 요청이 어떤 응답을 내려줘야 하는지 구분할 수 없게 됩니다.
정석적인 해결책은, 필요한 API 개수만큼 각각 port 번호를 다르게 해서 WireMock
애플리케이션을 띄우도록 하는 방법이지만
아쉽게도 아직 그 방법은 찾지 못했습니다..ㅠ (아시는 분은 댓글로 알려주세요 ㅠ)
그래서 저는 로컬 환경에서만 각각의 요청을 구분할 수 있는 헤더를 추가로 보내도록 해서 구분하였습니다.
(우리는 답을 찾을 것이다. 늘 그랬듯이..)
예시)
같은 path를 가진 별도의 AnythingFeignClient를 생성 후 Profile이 test 일 때만 헤더에 type 정보를 추가하도록 설정했고(기타 설정은 커밋 참고)
AnythingFeignClientConfig.java
public class AnythingFeignClientConfig {
@Bean
@Profile("test")
public RequestInterceptor requestInterceptor() {
return requestTemplate -> requestTemplate.header("type", "anything");
}
}
아래와 같이 헤더의 type 값도 같이 일치할 때 응답하도록 했습니다.
mappings/anything/1_OK.json
{
"request": {
"method": "GET",
"urlPath": "/anything/1",
"headers": {
"type": {
"equalTo": "anything"
}
}
},
"response": {
"headers": {
"Content-Type": "application/json"
},
"status": 200,
"jsonBody": {
"data": "AnythingFeignClient"
}
}
}
추후 기회가 된다면 WireMock을 port 번호를 다르게 여러 대 띄워서 구분할 수 있는 방법을 찾아보고 공유드려 보겠습니다.
05. 정리
위에서 열심히 설명드리긴 했지만, WireMock을 제가 설명드린 방식으로 사용하는 게 최선의 방법은 아닙니다.
처음에 설명했던 불편함은 WireMock을 통해 어느 정도 해소됐지만,
테스트 코드 자체만 봤을 때는 요청 시 id만 바뀌었을 뿐인데 기대 결과가 왜 달라지는지 파악할 수가 없고, mappings에 생성해 놓은 stub용 json 파일을 같이 찾아야 이해할 수 있다는 문제점도 있습니다.
그래서 json 파일로 관리하는 것이 아닌, 테스트 코드 내에서 stub 응답을 선언하는 방식도 많이 사용하고 있습니다.
이 방식을 사용하면 테스트 코드만 봐도 왜 기대 결과가 이런지 파악하기 편한 장점이 있습니다.
하지만, 기존에 작성되었던 테스트들을 WireMock 기반으로 테스트로 변경할 때,
베타 환경이나 실제 API를 호출했을 때 나온 응답 또는 Postman에 미리 생성했던 응답을 붙여넣기 쉽기 때문에 json 파일로 설정하는 방식을 채택했습니다.
제가 자세히 설명드린 WireMock이 마음에 드셨다면,
어떤 방식이 더 자신의 프로젝트에 맞을지 고민해 보시고 적용까지 하셔서 조금 더 튼튼한 프로젝트를 만들어보셨으면 좋겠습니다.
배달의민족 서비스의 심장인 셀러시스템팀의 서버 개발을 맡고 있는 부은형입니다.