MockServer으로 REST API 통합 테스트하기

MockServer으로 REST API 통합 테스트하기

이 가이드에서는 외부 REST API와 통합하는 Spring Boot 애플리케이션을 만들고, Testcontainers와 MockServer로 그 통합을 테스트하는 방법을 배워요.

출처: 문서

본문

이 가이드를 통해 다음 내용을 배울 수 있어요.

  • 외부 REST API와 통신하는 Spring Boot 애플리케이션 만들기
  • Testcontainers MockServer 모듈로 외부 API 통합 테스트하기

사전 준비 (Prerequisites)

  • Java 17 이상
  • Maven 또는 Gradle
  • Testcontainers가 지원하는 Docker 환경

참고: Testcontainers가 처음이라면 Testcontainers 개요를 방문해 알아보는 걸 권장해요.

Spring Boot 프로젝트 만들기

Spring Initializr에서 Spring Web, Spring Reactive Web, Testcontainers 스타터를 선택해 Spring Boot 프로젝트를 만들어요. 또는 가이드 저장소를 클론해도 돼요. 프로젝트를 생성한 뒤 REST Assured와 MockServer 라이브러리를 테스트 의존성으로 추가해요. pom.xml의 핵심 의존성은 다음과 같아요.

<properties>
    <java.version>17</java.version>
    <testcontainers.version>2.0.4</testcontainers.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</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-mockserver</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mock-server</groupId>
        <artifactId>mockserver-netty</artifactId>
        <version>5.15.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.rest-assured</groupId>
        <artifactId>rest-assured</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

모든 Testcontainers 모듈 의존성에 버전을 반복하지 않도록 Testcontainers BOM(Bill of Materials)을 사용하는 걸 권장해요.

이 가이드는 비디오 앨범을 관리하는 애플리케이션을 만들어요. 사진 에셋은 서드파티 REST API가 처리해요. 데모 목적으로 애플리케이션은 공개적으로 사용 가능한 JSONPlaceholder API를 사진 서비스로 사용해요. 애플리케이션은 GET /api/albums/{albumId} 엔드포인트를 노출하고, 이 엔드포인트가 사진 서비스를 호출해 주어진 앨범의 사진을 가져와요.

MockServer는 HTTP 기반 서비스를 목킹하기 위한 라이브러리예요. Testcontainers는 MockServer를 Docker 컨테이너로 실행하는 MockServer 모듈을 제공해요.

Album과 Photo 모델 만들기

Java 레코드로 Album.java를 만들어요.

package com.testcontainers.demo;

import java.util.List;

public record Album(Long albumId, List<Photo> photos) {}

record Photo(Long id, String title, String url, String thumbnailUrl) {}

PhotoServiceClient 인터페이스 만들기

Spring Framework 6은 선언적 HTTP 클라이언트 지원을 도입했어요. 주어진 앨범 ID에 대해 사진을 가져오는 메서드를 가진 인터페이스를 만들어요.

package com.testcontainers.demo;

import java.util.List;

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;

interface PhotoServiceClient {
    @GetExchange("/albums/{albumId}/photos")
    List<Photo> getPhotos(@PathVariable Long albumId);
}

PhotoServiceClient를 빈으로 등록하기

PhotoServiceClient의 런타임 구현을 생성하려면 HttpServiceProxyFactory를 사용해 Spring 빈으로 등록해요. 이 팩토리는 HttpClientAdapter 구현이 필요해요. Spring Boot는 spring-webflux 라이브러리의 일부로 WebClientAdapter를 제공해요.

package com.testcontainers.demo;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;

@Configuration
public class AppConfig {
    @Bean
    public PhotoServiceClient photoServiceClient(@Value("${photos.api.base-url}") String photosApiBaseUrl) {
        WebClient client = WebClient.builder().baseUrl(photosApiBaseUrl).build();
        HttpServiceProxyFactory factory = HttpServiceProxyFactory
            .builder(WebClientAdapter.forClient(client))
            .build();
        return factory.createClient(PhotoServiceClient.class);
    }
}

사진 서비스 기본 URL은 구성 속성으로 외부화돼요. src/main/resources/application.properties에 다음 항목을 추가해요.

photos.api.base-url=https://jsonplaceholder.typicode.com

REST API 엔드포인트 만들기

AlbumController.java를 만들어요.

package com.testcontainers.demo;

import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClientResponseException;

@RestController
@RequestMapping("/api")
class AlbumController {
    private static final Logger logger = LoggerFactory.getLogger(AlbumController.class);

    private final PhotoServiceClient photoServiceClient;

    AlbumController(PhotoServiceClient photoServiceClient) {
        this.photoServiceClient = photoServiceClient;
    }

    @GetMapping("/albums/{albumId}")
    public ResponseEntity<Album> getAlbumById(@PathVariable Long albumId) {
        try {
            List<Photo> photos = photoServiceClient.getPhotos(albumId);
            return ResponseEntity.ok(new Album(albumId, photos));
        } catch (WebClientResponseException e) {
            logger.error("Failed to get photos", e);
            return new ResponseEntity<>(e.getStatusCode());
        }
    }
}

이 엔드포인트는 주어진 앨범 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"
    }
  ]
}

Testcontainers MockServer로 테스트 작성하기

Java 메서드를 목킹하는 대신 HTTP 프로토콜 수준에서 외부 API 상호작용을 목킹하면 마샬링·언마샬링(marshalling·unmarshalling) 동작을 검증하고 네트워크 문제를 시뮬레이션할 수 있어요. Testcontainers는 Docker 컨테이너 안에서 MockServer 인스턴스를 시작하는 MockServer 모듈을 제공해요. 그런 다음 MockServerClient를 사용해 목 기대값을 구성할 수 있어요.

AlbumControllerTest.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.mockserver.model.HttpRequest.request;
import static org.mockserver.model.HttpResponse.response;
import static org.mockserver.model.JsonBody.json;

import io.restassured.RestAssured;
import io.restassured.http.ContentType;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockserver.client.MockServerClient;
import org.mockserver.model.Header;
import org.mockserver.verify.VerificationTimes;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.mockserver.MockServerContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class AlbumControllerTest {
    @LocalServerPort
    private Integer port;

    @Container
    static MockServerContainer mockServerContainer = new MockServerContainer("mockserver/mockserver:5.15.0");

    static MockServerClient mockServerClient;

    @DynamicPropertySource
    static void overrideProperties(DynamicPropertyRegistry registry) {
        mockServerClient = new MockServerClient(
                mockServerContainer.getHost(),
                mockServerContainer.getServerPort()
        );
        registry.add("photos.api.base-url", mockServerContainer::getEndpoint);
    }

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
        mockServerClient.reset();
    }

    @Test
    void shouldGetAlbumById() {
        Long albumId = 1L;

        mockServerClient
            .when(request().withMethod("GET").withPath("/albums/" + albumId + "/photos"))
            .respond(response()
                .withStatusCode(200)
                .withHeaders(new Header("Content-Type", "application/json; charset=utf-8"))
                .withBody(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" }
                    ]
                    """)));

        given()
            .contentType(ContentType.JSON)
            .when()
            .get("/api/albums/{albumId}", albumId)
            .then()
            .statusCode(200)
            .body("albumId", is(albumId.intValue()))
            .body("photos", hasSize(2));

        verifyMockServerRequest("GET", "/albums/" + albumId + "/photos", 1);
    }

    @Test
    void shouldReturn404StatusWhenAlbumNotFound() {
        Long albumId = 1L;

        mockServerClient
            .when(request().withMethod("GET").withPath("/albums/" + albumId + "/photos"))
            .respond(response().withStatusCode(404));

        given()
            .contentType(ContentType.JSON)
            .when()
            .get("/api/albums/{albumId}", albumId)
            .then()
            .statusCode(404);

        verifyMockServerRequest("GET", "/albums/" + albumId + "/photos", 1);
    }

    private void verifyMockServerRequest(String method, String path, int times) {
        mockServerClient.verify(
            request().withMethod(method).withPath(path),
            VerificationTimes.exactly(times)
        );
    }
}

테스트가 하는 일을 살펴보면,

  • @SpringBootTest는 임의의 포트에서 전체 애플리케이션을 시작해요.
  • @Testcontainers와 @Container 어노테이션이 MockServerContainer를 시작하고, 그에 연결된 MockServerClient를 만들어요.
  • @DynamicPropertySource는 photos.api.base-url을 MockServer 엔드포인트로 덮어써서 애플리케이션이 실제 사진 서비스 대신 MockServer와 통신하게 해요.
  • @BeforeEach는 매 테스트 전에 MockServerClient를 리셋해서 한 테스트의 기대값이 다른 테스트에 영향을 주지 않게 해요.
  • shouldGetAlbumById()는 /albums/{albumId}/photos에 대한 목 응답을 구성하고, 애플리케이션의 /api/albums/{albumId} 엔드포인트로 요청을 보낸 뒤 응답 본문을 검증해요. 또한 mockServerClient.verify()로 예상 API 호출이 MockServer에 도달했는지 확인해요.
  • shouldReturn404StatusWhenAlbumNotFound()는 MockServer가 404 상태를 반환하도록 구성하고, 애플리케이션이 그 상태를 호출자에게 전파하는지 검증해요.

테스트 실행과 다음 단계

테스트를 실행해요.

$ ./mvnw test

또는 Gradle로,

$ ./gradlew test

콘솔 출력에서 MockServer Docker 컨테이너가 시작되는 걸 볼 수 있어요. 그것이 사진 서비스 역할을 하며, 구성된 기대값에 따라 목 응답을 제공해요. 모든 테스트가 통과해야 해요.

요약 (Summary)

선언적 HTTP 클라이언트로 외부 REST API와 통합하는 Spring Boot 애플리케이션을 만들고, Testcontainers MockServer 모듈로 그 통합을 테스트했어요. Java 메서드를 목킹하는 대신 HTTP 프로토콜 수준에서 테스트하면 직렬화 문제를 잡고 실제적인 실패 시나리오를 시뮬레이션할 수 있어요.

Testcontainers에 대해 더 알아보고 싶다면 Testcontainers 개요를 방문해요.

더 읽어보기 (Further reading)

  • Testcontainers MockServer 모듈
  • MockServer 문서
  • Testcontainers JUnit 5 빠른 시작

더 알아보기 (Learn more)