WireMock#
WireMock은 HTTP 기반 API를 위한 시뮬레이션 도구로, Java 환경에서 개발되었으나 다양한 플랫폼에서 활용 가능하다. 톰 애컬턴(Tom Akehurst)이 개발한 이 오픈소스 도구는 실제 서비스와 동일하게 동작하는 모의(Mock) API를 쉽게 구축할 수 있게 해준다.
WireMock의 주요 특징#
- 독립 실행형 서버: 자체 HTTP 서버로 실행 가능
- JUnit과의 통합: Java 테스트 코드에 내장하여 사용 가능
- 정확한 HTTP 응답 모방: 상태 코드, 헤더, 본문 등 완벽 모방
- 요청 매칭: URL, 헤더, 쿼리 파라미터, 본문 등 다양한 요소 기반 매칭
- 응답 템플릿팅: 동적 응답 생성 가능
- 상태 기반 행동: 이전 요청에 따라 다른 응답 반환 가능
- 요청 검증: 특정 요청이 발생했는지 확인 가능
- 장애 시뮬레이션: 지연, 오류, 손상된 응답 등 시뮬레이션
- 프록시 모드: 실제 서비스와 모킹 결합 가능
- 레코딩 기능: 실제 서비스의 응답을 기록하여 모킹에 활용
WireMock의 작동 원리#
WireMock은 실제로 두 가지 주요 모드로 작동한다:
독립 실행형 프로세스#
별도의 Java 프로세스로 실행되며, HTTP API를 통해 제어된다.
이 방식은 다양한 언어로 개발된 클라이언트를 테스트할 때 유용하다.
1
2
| # JAR 파일로 독립 실행
java -jar wiremock-standalone-3.0.0.jar --port 8080
|
JUnit 통합 라이브러리#
Java 테스트 코드 내에 직접 내장되어 사용된다. 이 방식은 Java 개발 환경에서 특히 효율적이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // JUnit 5와 WireMock 통합 예시
@WireMockTest(httpPort = 8080)
public class MyApiClientTest {
@Test
public void testApiClient() {
// 스텁 설정
stubFor(get(urlEqualTo("/api/resource"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"message\":\"Success\"}")));
// 테스트 대상 API 클라이언트 호출
MyApiClient client = new MyApiClient("http://localhost:8080");
ApiResponse response = client.getResource();
// 검증
assertEquals("Success", response.getMessage());
// 요청 검증
verify(getRequestedFor(urlEqualTo("/api/resource")));
}
}
|
WireMock 설치 및 설정#
WireMock을 사용하기 위한 설치 방법은 사용 방식에 따라 다양하다.
Java 프로젝트에 추가 (Maven)#
1
2
3
4
5
6
7
| <!-- pom.xml에 의존성 추가 -->
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId>
<version>2.35.0</version>
<scope>test</scope>
</dependency>
|
독립 실행형으로 설치#
1
2
3
4
5
| # 최신 버전 다운로드
wget https://repo1.maven.org/maven2/com/github/tomakehurst/wiremock-jre8-standalone/2.35.0/wiremock-jre8-standalone-2.35.0.jar -O wiremock.jar
# 실행
java -jar wiremock.jar --port 8080
|
Docker로 실행#
1
2
| # Docker로 WireMock 실행
docker run -it --rm -p 8080:8080 wiremock/wiremock:2.35.0
|
WireMock 기본 사용법#
WireMock의 핵심 기능은 HTTP 요청을 매칭하고 적절한 응답을 반환하는 것이다.
이를 위한 기본적인 구성 요소를 살펴보면:
스텁 정의#
스텁(stub)은 특정 요청 패턴에 대해 어떤 응답을 반환할지 정의한다.
Java 코드를 사용한 스텁 정의:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // GET 요청에 대한 스텁 설정
stubFor(get(urlEqualTo("/api/users"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("[{\"id\":1,\"name\":\"홍길동\"},{\"id\":2,\"name\":\"김철수\"}]")));
// POST 요청에 대한 스텁 설정
stubFor(post(urlEqualTo("/api/users"))
.withRequestBody(containing("\"name\":"))
.willReturn(aResponse()
.withStatus(201)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":3,\"name\":\"새사용자\",\"created\":true}")));
|
JSON 파일을 사용한 스텁 정의 (독립 실행형):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // mappings/users-get.json
{
"request": {
"method": "GET",
"url": "/api/users"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "[{\"id\":1,\"name\":\"홍길동\"},{\"id\":2,\"name\":\"김철수\"}]"
}
}
|
요청 패턴 매칭#
WireMock은 다양한 방식으로 요청을 매칭할 수 있다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // URL 정확히 일치
urlEqualTo("/exact/path")
// URL 패턴 일치
urlPathMatching("/path/[a-z]+")
// 쿼리 파라미터 포함
urlPathEqualTo("/api/search").withQueryParam("q", equalTo("keyword"))
// 헤더 매칭
withHeader("Content-Type", containing("application/json"))
// 쿠키 매칭
withCookie("session", matching(".*"))
// 본문 매칭
withRequestBody(equalToJson("{\"key\":\"value\"}"))
withRequestBody(matchingJsonPath("$.user.name"))
|
응답 설정#
다양한 HTTP 응답 특성을 설정할 수 있다:
1
2
3
4
5
6
7
8
9
10
11
12
13
| // 기본 응답 설정
aResponse()
.withStatus(200) // 상태 코드
.withStatusMessage("OK") // 상태 메시지
.withHeader("Content-Type", "text/plain") // 헤더
.withBody("Hello, World!") // 본문
.withFixedDelay(1500) // 지연 시간(ms)
// 파일로부터 응답 본문 로드
aResponse().withBodyFile("response.json") // __files 디렉토리에서 로드
// 이진 응답 설정
aResponse().withBody(new byte[] { ... })
|
WireMock 고급 기능#
WireMock은 단순한 요청-응답 매칭을 넘어 다양한 고급 기능을 제공한다.
응답 템플릿팅#
Handlebars 기반 템플릿 엔진을 사용하여 동적 응답을 생성할 수 있다:
1
2
3
4
5
6
7
8
9
10
| // 템플릿 기반 응답
stubFor(get(urlPathMatching("/api/users/([0-9]+)"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\n" +
" \"id\": {{request.pathSegments.[2]}},\n" +
" \"name\": \"사용자{{request.pathSegments.[2]}}\",\n" +
" \"requestTime\": \"{{now}}\"\n" +
"}")));
|
이 예시에서는:
{{request.pathSegments.[2]}}
는 URL 경로의 세 번째 부분(사용자 ID)을 추출{{now}}
는 현재 시간을 반환
상태 기반 동작#
WireMock은 상태(Scenario)를 통해 순차적으로 변화하는 동작을 시뮬레이션할 수 있다:
1
2
3
4
5
6
7
8
9
10
11
12
13
| // 시나리오 기반 스텁
stubFor(get(urlEqualTo("/api/resource"))
.inScenario("리소스 수정 시나리오")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse()
.withBody("{\"status\":\"초기 상태\"}"))
.willSetStateTo("업데이트됨"));
stubFor(get(urlEqualTo("/api/resource"))
.inScenario("리소스 수정 시나리오")
.whenScenarioStateIs("업데이트됨")
.willReturn(aResponse()
.withBody("{\"status\":\"업데이트된 상태\"}")));
|
이 예시에서는 첫 번째 호출과 두 번째 호출이 서로 다른 응답을 반환한다.
프록시 모드#
실제 서버와 모킹을 결합하여 사용할 수 있다:
1
2
3
4
5
6
7
8
9
| // 기본 프록시 설정
stubFor(get(urlMatching(".*"))
.atPriority(10)
.willReturn(aResponse().proxiedFrom("https://api.real-server.com")));
// 특정 패턴만 모킹하고 나머지는 프록시
stubFor(get(urlEqualTo("/api/mocked"))
.atPriority(1) // 우선순위 높음
.willReturn(aResponse().withBody("Mocked response")));
|
응답 레코딩#
실제 API의 응답을 기록하여 모킹 데이터로 활용할 수 있다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // 레코딩 시작
WireMock.startRecording(
recordSpec()
.forTarget("https://api.real-server.com")
.extractBinaryBodiesOver(10240)
.extractTextBodiesOver(2048)
.makeStubsPersistent(true)
.ignoreRepeatRequests()
);
// 클라이언트로 요청 수행...
// 레코딩 중지
StubMapping[] recordings = WireMock.stopRecording();
|
장애 시뮬레이션#
다양한 네트워크 문제와 장애 상황을 시뮬레이션할 수 있다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| // 응답 지연
aResponse().withFixedDelay(3000) // 3초 지연
// 랜덤 지연
aResponse().withUniformRandomDelay(1000, 5000) // 1~5초 랜덤 지연
// 연결 끊김 시뮬레이션
aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)
// 손상된 응답
aResponse().withFault(Fault.MALFORMED_RESPONSE_CHUNK)
// 요청 비율 제한
stubFor(get(urlEqualTo("/api/limited"))
.atPriority(1)
.inScenario("요청 제한")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(200))
.willSetStateTo("제한됨"));
stubFor(get(urlEqualTo("/api/limited"))
.atPriority(1)
.inScenario("요청 제한")
.whenScenarioStateIs("제한됨")
.willReturn(aResponse().withStatus(429))
.willSetStateTo(Scenario.STARTED));
|
WireMock 확장 및 플러그인#
WireMock은 확장 가능한 아키텍처를 가지고 있어 다양한 플러그인을 통해 기능을 확장할 수 있다.
커스텀 요청 매처#
특별한 요청 매칭 로직이 필요한 경우 커스텀 매처를 구현할 수 있다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // 커스텀 요청 매처 구현
public class CustomHeaderMatcher extends RequestMatcherExtension {
@Override
public MatchResult match(Request request, Parameters parameters) {
String headerValue = request.getHeader("X-Custom-Header");
String expectedPattern = parameters.getString("expectedPattern");
boolean matches = Pattern.matches(expectedPattern, headerValue);
return MatchResult.of(matches);
}
}
// 사용 방법
stubFor(requestMatching(new CustomHeaderMatcher(),
Parameters.one("expectedPattern", "value-\\d+"))
.willReturn(aResponse().withStatus(200)));
|
응답 변환기#
응답을 동적으로 변환하는 커스텀 로직을 구현할 수 있다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // 커스텀 응답 변환기 구현
public class DateStampTransformer extends ResponseTransformerV2 {
@Override
public Response transform(Request request, Response response, FileSource files, Parameters parameters) {
String body = response.getBodyAsString();
String transformed = body.replace("{{currentDate}}",
LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE));
return Response.Builder.like(response)
.but().body(transformed)
.build();
}
@Override
public String getName() {
return "date-stamp";
}
}
// 사용 방법
stubFor(get("/api/with-date")
.willReturn(aResponse()
.withBody("{\"date\":\"{{currentDate}}\"}")
.withTransformerParameter("transformerName", "date-stamp")));
|
주요 공식 확장 모듈#
WireMock 에코시스템에는 다양한 확장 모듈이 있다:
- wiremock-extension-json-body-transformer: JSON 응답 본문을 동적으로 조작
- wiremock-jolt-transformer: Jolt를 사용한 JSON 변환
- wiremock-velocity-transformer: Velocity 템플릿을 사용한 응답 생성
- wiremock-random-data: 임의의 테스트 데이터 생성
WireMock 실제 적용 사례#
WireMock이 실제 개발 및 테스트 환경에서 어떻게 활용되는지 살펴보면.
단위 테스트에서의 활용#
Java JUnit 테스트에서 외부 API 의존성을 모킹:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| @WireMockTest(httpPort = 8080)
public class WeatherServiceTest {
private WeatherService weatherService;
@BeforeEach
void setup() {
weatherService = new WeatherService("http://localhost:8080");
}
@Test
void testGetCurrentWeather() {
// 날씨 API 응답 모킹
stubFor(get(urlEqualTo("/api/weather?city=seoul"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"city\":\"Seoul\",\"temperature\":25,\"condition\":\"Sunny\"}")));
// 테스트 대상 메서드 호출
Weather weather = weatherService.getCurrentWeather("seoul");
// 검증
assertEquals("Seoul", weather.getCity());
assertEquals(25, weather.getTemperature());
assertEquals("Sunny", weather.getCondition());
// API 호출 검증
verify(getRequestedFor(urlEqualTo("/api/weather?city=seoul")));
}
@Test
void testWeatherApiError() {
// API 오류 시뮬레이션
stubFor(get(urlEqualTo("/api/weather?city=unknown"))
.willReturn(aResponse()
.withStatus(404)
.withBody("{\"error\":\"City not found\"}")));
// 예외 발생 검증
WeatherApiException exception = assertThrows(WeatherApiException.class, () -> {
weatherService.getCurrentWeather("unknown");
});
assertEquals("City not found", exception.getMessage());
}
}
|
통합 테스트에서의 활용#
Spring Boot 애플리케이션에서 외부 서비스 모킹:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| @SpringBootTest
@AutoConfigureWireMock(port = 0) // 랜덤 포트 사용
class PaymentIntegrationTest {
@Autowired
private PaymentService paymentService;
@Value("${wiremock.server.port}")
private int wiremockPort;
@BeforeEach
void setup() {
// 외부 결제 게이트웨이 URL 설정
ReflectionTestUtils.setField(paymentService, "paymentGatewayUrl",
"http://localhost:" + wiremockPort + "/api");
}
@Test
void testProcessPayment() {
// 결제 게이트웨이 응답 모킹
stubFor(post(urlEqualTo("/api/payments"))
.withRequestBody(matchingJsonPath("$.amount", equalTo("1000")))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"transactionId\":\"tx123\",\"status\":\"APPROVED\"}")));
// 결제 처리
PaymentResult result = paymentService.processPayment("order123", 1000);
// 검증
assertEquals("tx123", result.getTransactionId());
assertEquals(PaymentStatus.APPROVED, result.getStatus());
// 결제 요청 검증
verify(postRequestedFor(urlEqualTo("/api/payments"))
.withRequestBody(matchingJsonPath("$.orderId", equalTo("order123")))
.withHeader("Content-Type", containing("application/json")));
}
}
|
성능 테스트에서의 활용#
JMeter 또는 Gatling과 같은 성능 테스트 도구와 함께 사용:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // WireMock 서버 설정 (독립 실행형)
WireMockServer wireMockServer = new WireMockServer(wireMockConfig()
.port(8080)
.jettyAcceptors(4) // 동시 연결 처리 향상
.jettyAcceptQueueSize(100)
.asynchronousResponseEnabled(true)
.containerThreads(100)); // 컨테이너 스레드 수 증가
wireMockServer.start();
// 대량 테스트를 위한 응답 설정
wireMockServer.stubFor(get(urlMatching("/api/products/.*"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"{{request.pathSegments.[2]}}\",\"name\":\"Product {{request.pathSegments.[2]}}\",\"price\":1000}")
.withFixedDelay(5))); // 5ms 지연
// 성능 테스트 실행 후...
wireMockServer.stop();
|
계약 테스트에서의 활용#
소비자-제공자 계약 테스트(Consumer-Driven Contract Testing)에서 WireMock 활용:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // 소비자 측 테스트
@WireMockTest(httpPort = 8080)
public class ProductClientContractTest {
@Test
void testGetProductContract() {
// 계약에 따른 스텁 설정
stubFor(get(urlEqualTo("/api/products/1"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBodyFile("contract/product-1.json")));
// 클라이언트 테스트
ProductClient client = new ProductClient("http://localhost:8080");
Product product = client.getProduct(1);
// 계약 검증
assertEquals(1, product.getId());
assertEquals("스마트폰", product.getName());
assertNotNull(product.getDescription());
assertTrue(product.getPrice() > 0);
}
}
|
WireMock 베스트 프랙티스#
프로젝트 구조 최적화#
1
2
3
4
5
6
7
8
9
10
11
| src/
└── test/
├── java/ # 테스트 코드
└── resources/
└── wiremock/
├── mappings/ # 스텁 매핑 JSON 파일
│ ├── users-get.json
│ └── ...
└── __files/ # 응답 본문 파일
├── users.json
└── ...
|
테스트 격리#
각 테스트가 서로 영향을 주지 않도록 격리하는 방법:
1
2
3
4
5
6
7
8
| // 테스트 메서드마다 WireMock 상태 초기화
@BeforeEach
void setup() {
WireMock.reset();
}
// 테스트 클래스마다 별도의 WireMock 서버 사용
@WireMockTest(httpPort = 0) // 랜덤 포트 사용
|
실제 API와의 동기화#
실제 API와 모킹을 항상 동기화하는 방법:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // API 응답 레코딩 자동화
@Test
void recordRealApiResponses() throws Exception {
WireMock.startRecording(
recordSpec()
.forTarget("https://api.real-service.com")
.captureHeader("Accept")
.captureHeader("Content-Type")
.extractBinaryBodiesOver(10240)
.extractTextBodiesOver(2048)
.makeStubsPersistent(true)
.ignoreRepeatRequests()
.matchRequestBodyWithEqualToJson(true, true)
);
// 모든 필요한 API 엔드포인트 호출
apiClient.getUsers();
apiClient.getUser(1);
apiClient.createUser(new User("New User"));
// ...
WireMock.stopRecording();
}
|
상태 관리 최소화#
시나리오와 상태를 최소화하여 테스트 복잡성 관리:
1
2
3
4
5
6
7
8
9
10
| // 가능하면 상태 없는(stateless) 스텁 사용
stubFor(get(urlPathMatching("/api/users/([0-9]+)"))
.willReturn(aResponse()
.withTransformers("user-transformer"))); // 상태 대신 변환기 사용
// 상태 사용 시 명확하게 리셋 처리
@AfterEach
void resetScenarios() {
WireMock.resetAllScenarios();
}
|
최적화#
대규모 테스트에서 WireMock 성능 최적화:
1
2
3
4
5
6
7
8
9
10
11
| WireMockConfiguration config = wireMockConfig()
.port(8080)
.jettyAcceptors(2)
.jettyAcceptQueueSize(100)
.containerThreads(50)
.asynchronousResponseEnabled(true)
.maxRequestJournalEntries(100) // 요청 저널 크기 제한
.disableRequestJournal() // 대규모 테스트에서는 저널 비활성화
.networkTrafficListener(new ConsoleNotifier(true));
WireMockServer server = new WireMockServer(config);
|
11. WireMock의 한계와 대안#
모든 도구에는 한계가 있다.
WireMock의 한계#
- 복잡한 동적 응답 생성: 매우 복잡한 비즈니스 로직이 필요한 응답 생성에는 한계가 있다.
- 실시간 프로토콜 제한: HTTP/HTTPS 외의 프로토콜(예: WebSocket, gRPC) 지원에 제한이 있다.
- 대규모 환경 확장성: 매우 대규모 테스트 환경에서는 성능 병목이 발생할 수 있다.
- GUI 부재: 기본적으로 GUI 인터페이스가 없어 비개발자에게 어려울 수 있습니다.
11.2 대안 및 보완 도구#
11.2.1 Hoverfly#
Java 외 언어 지원 및 더 높은 확장성이 필요한 경우:
1
2
3
4
5
6
7
8
9
10
| // Hoverfly Java 예시
try (Hoverfly hoverfly = new Hoverfly(configs().localConfigs(), SIMULATE)) {
hoverfly.simulate(dsl(
service("api.example.com")
.get("/users")
.willReturn(success("{\"users\":[…]}", "application/json"))));
// 테스트 수행
// …
}
|
11.2.2 MockServer#
프록시 기능과 높은 확장성이 필요한 경우:
1
2
3
4
5
6
7
8
9
10
11
12
13
| // MockServer 예시
ClientAndServer mockServer = ClientAndServer.startClientAndServer(1080);
mockServer.when(
request()
.withMethod("GET")
.withPath("/users")
).respond(
response()
.withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"users\":[…]}")
);
|
11.2.3 Mountebank#
다중 프로토콜 지원이 필요한 경우:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Mountebank 예시 (JavaScript)
const mb = require('mountebank');
const settings = {
port: 2525,
protoports: { http: 8080 },
ipWhitelist: ['*']
};
mb.create(settings).then(() => {
const stubs = [{
predicates: [{ equals: { path: '/users' } }],
responses: [{ is: { statusCode: 200, body: '{"users":[…]}' } }]
}];
mb.post('/imposters', {
protocol: 'http',
port: 8080,
stubs: stubs
});
});
|
용어 정리#
참고 및 출처#