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.0 Docker 이미지를 사용해 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 통합 테스트하기

더 알아보기 (Learn more)