Micronaut 앱에서 WireMock으로 REST API 통합 테스트하기
Micronaut 앱에서 WireMock으로 REST API 통합 테스트하기
이 가이드에서는 외부 REST API와 통합하는 Micronaut 애플리케이션을 만들고, WireMock과 Testcontainers WireMock 모듈로 그 통합을 테스트하는 방법을 배워요.
출처: 문서
본문
이 가이드를 통해 다음 내용을 배울 수 있어요.
- 외부 REST API와 통신하는 Micronaut 애플리케이션 만들기
- WireMock으로 외부 API 통합 테스트하기
- Testcontainers WireMock 모듈로 WireMock을 Docker 컨테이너로 실행하기
사전 준비 (Prerequisites)
- Java 17 이상
- Maven 또는 Gradle
- Testcontainers가 지원하는 Docker 환경
참고: Testcontainers가 처음이라면 Testcontainers 개요를 방문해 알아보는 걸 권장해요.
Micronaut 프로젝트 만들기
Micronaut Launch에서 http-client, micronaut-test-rest-assured, testcontainers 기능을 선택해 Micronaut 프로젝트를 만들어요. 또는 가이드 저장소를 클론해도 돼요. 프로젝트를 생성한 뒤 WireMock과 Testcontainers WireMock 라이브러리를 테스트 의존성으로 추가해요. pom.xml의 핵심 의존성은 다음과 같아요.
<parent>
<groupId>io.micronaut.platform</groupId>
<artifactId>micronaut-parent</artifactId>
<version>4.1.2</version>
</parent>
<properties>
<jdk.version>17</jdk.version>
<micronaut.version>4.1.2</micronaut.version>
<micronaut.runtime>netty</micronaut.runtime>
</properties>
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-http-client</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-http-server-netty</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.micronaut.serde</groupId>
<artifactId>micronaut-serde-jackson</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.micronaut.test</groupId>
<artifactId>micronaut-test-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.micronaut.test</groupId>
<artifactId>micronaut-test-rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.2.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.wiremock.integrations.testcontainers</groupId>
<artifactId>wiremock-testcontainers-module</artifactId>
<version>1.0-alpha-13</version>
<scope>test</scope>
</dependency>
</dependencies>
이 가이드는 비디오 앨범을 관리하는 애플리케이션을 만들어요. 사진 에셋은 서드파티 REST API가 처리해요. 데모 목적으로 애플리케이션은 공개적으로 사용 가능한 JSONPlaceholder API를 사진 서비스로 사용해요. 애플리케이션은 GET /api/albums/{albumId} 엔드포인트를 노출하고, 이 엔드포인트가 사진 서비스를 호출해 주어진 앨범의 사진을 가져와요.
WireMock은 목(mock) API를 만드는 도구예요. Testcontainers는 WireMock을 Docker 컨테이너로 실행하는 WireMock 모듈을 제공해요.
Album과 Photo 모델 만들기
Java 레코드로 Album.java를 만들어요. 두 레코드 모두 직렬화·역직렬화를 허용하도록 @Serdeable로 주석을 답니다.
package com.testcontainers.demo;
import io.micronaut.serde.annotation.Serdeable;
import java.util.List;
@Serdeable
public record Album(Long albumId, List<Photo> photos) {}
@Serdeable
record Photo(Long id, String title, String url, String thumbnailUrl) {}
PhotoServiceClient 만들기
Micronaut는 선언적(declarative) HTTP 클라이언트를 지원해요. 주어진 앨범 ID에 대해 사진을 가져오는 메서드를 가진 인터페이스를 만들어요.
package com.testcontainers.demo;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.client.annotation.Client;
import java.util.List;
@Client(id = "photosapi")
interface PhotoServiceClient {
@Get("/albums/{albumId}/photos")
List<Photo> getPhotos(@PathVariable Long albumId);
}
@Client(id = "photosapi") 어노테이션은 이 클라이언트를 명명된 구성에 연결해요. 기본 URL을 설정하려면 src/main/resources/application.properties에 다음 속성을 추가해요.
micronaut.http.services.photosapi.url=https://jsonplaceholder.typicode.com
REST API 엔드포인트 만들기
AlbumController.java를 만들어요.
package com.testcontainers.demo;
import static io.micronaut.scheduling.TaskExecutors.BLOCKING;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.scheduling.annotation.ExecuteOn;
@Controller("/api")
class AlbumController {
private final PhotoServiceClient photoServiceClient;
AlbumController(PhotoServiceClient photoServiceClient) {
this.photoServiceClient = photoServiceClient;
}
@ExecuteOn(BLOCKING)
@Get("/albums/{albumId}")
public Album getAlbumById(@PathVariable Long albumId) {
return new Album(albumId, photoServiceClient.getPhotos(albumId));
}
}
이 컨트롤러가 하는 일을 살펴보면,
@Controller("/api")가 컨트롤러를/api경로에 매핑해요.- 생성자 주입이
PhotoServiceClient빈을 제공해요. @ExecuteOn(BLOCKING)은 블로킹 I/O를 별도의 스레드 풀로 오프로드해서 이벤트 루프를 막지 않아요.@Get("/albums/{albumId}")가getAlbumById()메서드를 HTTP GET 요청에 매핑해요.
이 엔드포인트는 주어진 앨범 ID에 대해 사진 서비스를 호출하고 다음과 같은 응답을 반환해요.
{
"albumId": 1,
"photos": [
{
"id": 51,
"title": "non sunt voluptatem placeat consequuntur rem incidunt",
"url": "https://via.placeholder.com/600/8e973b",
"thumbnailUrl": "https://via.placeholder.com/150/8e973b"
},
{
"id": 52,
"title": "eveniet pariatur quia nobis reiciendis laboriosam ea",
"url": "https://via.placeholder.com/600/121fa4",
"thumbnailUrl": "https://via.placeholder.com/150/121fa4"
}
]
}
WireMock과 Testcontainers로 테스트 작성하기
Java 메서드를 목킹하는 대신 HTTP 프로토콜 수준에서 외부 API 상호작용을 목킹하면 마샬링·언마샬링(marshalling·unmarshalling) 동작을 검증하고 네트워크 문제를 시뮬레이션할 수 있어요.
WireMock의 JUnit 5 확장으로 테스트하기
첫 번째 접근은 WireMock의 WireMockExtension을 사용해 동적 포트에서 인프로세스(in-process) WireMock 서버를 시작하는 거예요. AlbumControllerTest.java를 만들어요.
package com.testcontainers.demo;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.Matchers.hasSize;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import io.micronaut.context.ApplicationContext;
import io.micronaut.http.MediaType;
import io.micronaut.runtime.server.EmbeddedServer;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import java.util.Collections;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
class AlbumControllerTest {
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
private Map<String, Object> getProperties() {
return Collections.singletonMap("micronaut.http.services.photosapi.url", wireMock.baseUrl());
}
@Test
void shouldGetAlbumById() {
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) {
RestAssured.port = server.getPort();
Long albumId = 1L;
String responseJson = """
[
{
"id": 1,
"title": "accusamus beatae ad facilis cum similique qui sunt",
"url": "https://via.placeholder.com/600/92c952",
"thumbnailUrl": "https://via.placeholder.com/150/92c952"
},
{
"id": 2,
"title": "reprehenderit est deserunt velit ipsam",
"url": "https://via.placeholder.com/600/771796",
"thumbnailUrl": "https://via.placeholder.com/150/771796"
}
]
""";
wireMock.stubFor(WireMock.get(urlMatching("/albums/" + albumId + "/photos"))
.willReturn(aResponse()
.withHeader("Content-Type", MediaType.APPLICATION_JSON)
.withBody(responseJson)));
given().contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(200)
.body("albumId", is(albumId.intValue()))
.body("photos", hasSize(2));
}
}
@Test
void shouldReturnServerErrorWhenPhotoServiceCallFailed() {
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) {
RestAssured.port = server.getPort();
Long albumId = 2L;
wireMock.stubFor(WireMock.get(urlMatching("/albums/" + albumId + "/photos"))
.willReturn(aResponse().withStatus(500)));
given().contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(500);
}
}
}
이 테스트가 하는 일을 살펴보면,
WireMockExtension은 동적 포트에서 WireMock 서버를 시작해요.getProperties()메서드가micronaut.http.services.photosapi.url을 WireMock 엔드포인트로 덮어써서, 애플리케이션이 실제 사진 서비스 대신 WireMock과 통신하게 해요.shouldGetAlbumById()는/albums/{albumId}/photos에 대한 목 응답을 구성하고, 애플리케이션의/api/albums/{albumId}엔드포인트로 요청을 보낸 뒤 응답 본문을 검증해요.shouldReturnServerErrorWhenPhotoServiceCallFailed()는 WireMock이500상태를 반환하도록 구성하고 애플리케이션이 그 에러를 전파하는지 검증해요.
JSON 매핑 파일로 스텁하기
WireMock Java API로 스텁하는 대신 JSON 매핑 기반 구성을 사용할 수 있어요. src/test/resources/wiremock/mappings/get-album-photos.json을 만들어요.
{
"mappings": [
{
"request": {
"method": "GET",
"urlPattern": "/albums/([0-9]+)/photos"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "album-photos-resp-200.json"
}
},
{
"request": {
"method": "GET",
"urlPattern": "/albums/2/photos"
},
"response": {
"status": 500,
"headers": {
"Content-Type": "application/json"
}
}
},
{
"request": {
"method": "GET",
"urlPattern": "/albums/3/photos"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": []
}
}
]
}
src/test/resources/wiremock/__files/album-photos-resp-200.json을 만들어요.
[
{
"id": 1,
"title": "accusamus beatae ad facilis cum similique qui sunt",
"url": "https://via.placeholder.com/600/92c952",
"thumbnailUrl": "https://via.placeholder.com/150/92c952"
},
{
"id": 2,
"title": "reprehenderit est deserunt velit ipsam",
"url": "https://via.placeholder.com/600/771796",
"thumbnailUrl": "https://via.placeholder.com/150/771796"
}
]
그런 다음 이런 파일들에서 스텁 매핑을 로드하도록 WireMock을 초기화해요.
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig()
.dynamicPort()
.usingFilesUnderClasspath("wiremock")
)
.build();
매핑 파일 기반 스텁이 준비되면, 프로그래밍 방식 스텁 없이도 테스트를 작성할 수 있어요.
@Test
void shouldGetAlbumById() {
Long albumId = 1L;
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) {
RestAssured.port = server.getPort();
given().contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(200)
.body("albumId", is(albumId.intValue()))
.body("photos", hasSize(2));
}
}
Testcontainers WireMock 모듈 사용하기
Testcontainers WireMock 모듈은 WireMock Docker를 기반으로 테스트 안에서 독립 실행형 컨테이너로 WireMock 서버를 제공해요. src/test/resources/mocks-config.json을 스텁 매핑과 함께 만들어요.
{
"mappings": [
{
"request": {
"method": "GET",
"urlPattern": "/albums/([0-9]+)/photos"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "album-photos-response.json"
}
},
{
"request": {
"method": "GET",
"urlPattern": "/albums/2/photos"
},
"response": {
"status": 500,
"headers": {
"Content-Type": "application/json"
}
}
},
{
"request": {
"method": "GET",
"urlPattern": "/albums/3/photos"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": []
}
}
]
}
src/test/resources/album-photos-response.json을 만들어요.
[
{
"id": 1,
"title": "accusamus beatae ad facilis cum similique qui sunt",
"url": "https://via.placeholder.com/600/92c952",
"thumbnailUrl": "https://via.placeholder.com/150/92c952"
},
{
"id": 2,
"title": "reprehenderit est deserunt velit ipsam",
"url": "https://via.placeholder.com/600/771796",
"thumbnailUrl": "https://via.placeholder.com/150/771796"
}
]
AlbumControllerTestcontainersTests.java를 만들어요.
package com.testcontainers.demo;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue;
import io.micronaut.context.ApplicationContext;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.runtime.server.EmbeddedServer;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import java.util.Collections;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.wiremock.integrations.testcontainers.WireMockContainer;
@Testcontainers(disabledWithoutDocker = true)
class AlbumControllerTestcontainersTests {
@Container
static WireMockContainer wiremockServer = new WireMockContainer("wiremock/wiremock:2.35.0")
.withMappingFromResource("mocks-config.json")
.withFileFromResource("album-photos-response.json");
@NonNull
public Map<String, Object> getProperties() {
return Collections.singletonMap("micronaut.http.services.photosapi.url", wiremockServer.getBaseUrl());
}
@Test
void shouldGetAlbumById() {
Long albumId = 1L;
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) {
RestAssured.port = server.getPort();
given().contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(200)
.body("albumId", is(albumId.intValue()))
.body("photos", hasSize(2));
}
}
@Test
void shouldReturnServerErrorWhenPhotoServiceCallFailed() {
Long albumId = 2L;
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) {
RestAssured.port = server.getPort();
given().contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(500);
}
}
@Test
void shouldReturnEmptyPhotos() {
Long albumId = 3L;
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) {
RestAssured.port = server.getPort();
given().contentType(ContentType.JSON)
.when()
.get("/api/albums/{albumId}", albumId)
.then()
.statusCode(200)
.body("albumId", is(albumId.intValue()))
.body("photos", nullValue());
}
}
}
이 테스트가 하는 일을 살펴보면,
@Testcontainers와@Container어노테이션이wiremock/wiremock:2.35.0Docker 이미지를 사용해WireMockContainer를 시작해요.withMappingFromResource("mocks-config.json")은 스텁 매핑을 classpath 리소스에서 로드해요.withFileFromResource("album-photos-response.json")은 응답 본문 파일을 WireMock이 사용할 수 있게 해요.getProperties()는 사진 서비스 URL을 WireMock 컨테이너의 기본 URL로 덮어써요.shouldGetAlbumById()는 애플리케이션이 사진 두 개를 가진 예상 앨범을 반환하는지 검증해요.shouldReturnServerErrorWhenPhotoServiceCallFailed()는 사진 서비스의500이 호출자에게 전파되는지 검증해요.shouldReturnEmptyPhotos()는 애플리케이션이 빈 사진 목록을 처리하는지 검증해요.
테스트 실행과 다음 단계
테스트를 실행해요.
$ ./mvnw test
또는 Gradle로,
$ ./gradlew test
콘솔 출력에서 WireMock Docker 컨테이너가 시작되는 걸 볼 수 있어요. 그것이 사진 서비스 역할을 하며, 구성된 기대값에 따라 목 응답을 제공해요. 모든 테스트가 통과해야 해요.
요약 (Summary)
선언적 HTTP 클라이언트로 외부 REST API와 통합하는 Micronaut 애플리케이션을 만들고, WireMock과 Testcontainers WireMock 모듈로 그 통합을 테스트했어요. Java 메서드를 목킹하는 대신 HTTP 프로토콜 수준에서 테스트하면 직렬화 문제를 잡고 실제적인 실패 시나리오를 시뮬레이션할 수 있어요.
팁: Testcontainers WireMock 모듈은 Go와 Python용으로도 있어요.
Testcontainers에 대해 더 알아보고 싶다면 Testcontainers 개요를 방문해요.
더 읽어보기 (Further reading)
- Testcontainers WireMock 모듈
- WireMock 문서
- Testcontainers JUnit 5 빠른 시작
- Spring Boot에서 WireMock으로 REST API 통합 테스트하기