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

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

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

출처: 문서

본문

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

  • 외부 REST API와 통신하는 Spring Boot 애플리케이션 만들기
  • JUnit 5 확장과 Testcontainers WireMock 모듈을 모두 사용해 WireMock으로 외부 API 통합 테스트하기

사전 준비 (Prerequisites)

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

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

Spring Boot 프로젝트 만들기

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

<properties>
    <java.version>17</java.version>
    <testcontainers.version>2.0.4</testcontainers.version>
    <wiremock-testcontainers.version>1.0-alpha-13</wiremock-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-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.wiremock</groupId>
        <artifactId>wiremock-standalone</artifactId>
        <version>3.6.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.wiremock.integrations.testcontainers</groupId>
        <artifactId>wiremock-testcontainers-module</artifactId>
        <version>${wiremock-testcontainers.version}</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} 엔드포인트를 노출하고, 이 엔드포인트가 사진 서비스를 호출해 주어진 앨범의 사진을 가져와요.

WireMock은 목(mock) API를 만드는 도구예요. Testcontainers는 WireMock을 Docker 컨테이너로 실행하는 WireMock 모듈을 제공해요.

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 만들기

RestTemplate을 사용해 주어진 앨범 ID의 사진을 가져오는 PhotoServiceClient.java를 만들어요.

package com.testcontainers.demo;

import java.util.List;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
class PhotoServiceClient {
    private final String baseUrl;
    private final RestTemplate restTemplate;

    PhotoServiceClient(@Value("${photos.api.base-url}") String baseUrl, RestTemplateBuilder builder) {
        this.baseUrl = baseUrl;
        this.restTemplate = builder.build();
    }

    List<Photo> getPhotos(Long albumId) {
        String url = baseUrl + "/albums/{albumId}/photos";
        ResponseEntity<List<Photo>> response = restTemplate.exchange(
                url, HttpMethod.GET, null, new ParameterizedTypeReference<>() {}, albumId);
        return response.getBody();
    }
}

사진 서비스 기본 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.client.RestClientResponseException;

@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 (RestClientResponseException 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"
    }
  ]
}

WireMock과 Testcontainers로 테스트 작성하기

Java 메서드를 목킹하는 대신 HTTP 프로토콜 수준에서 외부 API 상호작용을 목킹하면 마샬링·언마샬링(marshalling·unmarshalling) 동작을 검증하고 네트워크 문제를 시뮬레이션할 수 있어요.

WireMock JUnit 5 확장으로 테스트하기

WireMock은 인프로세스 WireMock 서버를 시작하는 JUnit 5 확장을 제공해요. WireMock Java API로 스텁 응답을 구성할 수 있어요. 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 static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;

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

    @RegisterExtension
    static WireMockExtension wireMock = WireMockExtension.newInstance()
            .options(wireMockConfig().dynamicPort())
            .build();

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("photos.api.base-url", wireMock::baseUrl);
    }

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

    @Test
    void shouldGetAlbumById() {
        Long albumId = 1L;
        wireMock.stubFor(WireMock.get(urlMatching("/albums/" + albumId + "/photos"))
                .willReturn(aResponse()
                    .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                    .withBody("""
                        [
                          { "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));
    }

    @Test
    void shouldReturnServerErrorWhenPhotoServiceCallFailed() {
        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);
    }
}

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

  • @SpringBootTest는 임의의 포트에서 전체 애플리케이션을 시작해요.
  • @RegisterExtension이 동적 포트에서 WireMock을 시작하는 WireMockExtension을 만들어요.
  • @DynamicPropertySource가 photos.api.base-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 wireMockServer = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort().usingFilesUnderClasspath("wiremock"))
        .build();

매핑 기반 스텁이 준비되면 AlbumControllerWireMockMappingTests.java를 만들어요.

package com.testcontainers.demo;

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 static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
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;

@SpringBootTest(webEnvironment = RANDOM_PORT)
class AlbumControllerWireMockMappingTests {
    @LocalServerPort
    private Integer port;

    @RegisterExtension
    static WireMockExtension wireMockServer = WireMockExtension.newInstance()
            .options(wireMockConfig().dynamicPort().usingFilesUnderClasspath("wiremock"))
            .build();

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("photos.api.base-url", wireMockServer::baseUrl);
    }

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

    @Test
    void shouldGetAlbumById() {
        Long albumId = 1L;
        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;
        given()
            .contentType(ContentType.JSON)
            .when()
            .get("/api/albums/{albumId}", albumId)
            .then()
            .statusCode(500);
    }

    @Test
    void shouldReturnEmptyPhotos() {
        Long albumId = 3L;
        given()
            .contentType(ContentType.JSON)
            .when()
            .get("/api/albums/{albumId}", albumId)
            .then()
            .statusCode(200)
            .body("albumId", is(albumId.intValue()))
            .body("photos", hasSize(0));
    }
}

이 테스트들은 WireMock이 매핑을 classpath에서 자동으로 로드하므로 인라인 스텁 정의가 필요 없어요.

Testcontainers WireMock 모듈로 테스트하기

Testcontainers WireMock 모듈은 WireMock Docker를 기반으로 WireMock을 독립 실행형 Docker 컨테이너로 프로비저닝해요. 이 접근은 테스트 JVM과 목 서버 사이에 완전한 격리를 원할 때 유용해요.

목 구성 파일을 src/test/resources/com/testcontainers/demo/AlbumControllerTestcontainersTests/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/com/testcontainers/demo/AlbumControllerTestcontainersTests/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.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

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

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.wiremock.integrations.testcontainers.WireMockContainer;

@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class AlbumControllerTestcontainersTests {
    @LocalServerPort
    private Integer port;

    @Container
    static WireMockContainer wiremockServer = new WireMockContainer("wiremock/wiremock:3.6.0")
            .withMapping("photos-by-album",
                    AlbumControllerTestcontainersTests.class, "mocks-config.json")
            .withFileFromResource("album-photos-response.json",
                    AlbumControllerTestcontainersTests.class, "album-photos-response.json");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("photos.api.base-url", wiremockServer::getBaseUrl);
    }

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

    @Test
    void shouldGetAlbumById() {
        Long albumId = 1L;
        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;
        given()
            .contentType(ContentType.JSON)
            .when()
            .get("/api/albums/{albumId}", albumId)
            .then()
            .statusCode(500);
    }

    @Test
    void shouldReturnEmptyPhotos() {
        Long albumId = 3L;
        given()
            .contentType(ContentType.JSON)
            .when()
            .get("/api/albums/{albumId}", albumId)
            .then()
            .statusCode(200)
            .body("albumId", is(albumId.intValue()))
            .body("photos", hasSize(0));
    }
}

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

  • @Testcontainers와 @Container 어노테이션이 wiremock/wiremock:3.6.0 Docker 이미지를 사용해 WireMockContainer를 시작해요.
  • withMapping()은 mocks-config.json에서 스텁 매핑을 로드하고, withFileFromResource()는 응답 본문 파일을 로드해요.
  • @DynamicPropertySource가 photos.api.base-url을 WireMock 컨테이너의 기본 URL로 덮어써요.
  • WireMock이 JSON 구성 파일에서 이를 로드하므로 테스트에 인라인 스텁 정의가 없어요.

테스트 실행과 다음 단계

테스트를 실행해요.

$ ./mvnw test

또는 Gradle로,

$ ./gradlew test

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

요약 (Summary)

외부 REST API와 통합하는 Spring Boot 애플리케이션을 만들고, 다음 세 가지 접근으로 그 통합을 테스트했어요.

  • 인라인 스텁이 있는 WireMock JUnit 5 확장
  • JSON 매핑 파일이 있는 WireMock JUnit 5 확장
  • Docker 컨테이너에서 WireMock을 실행하는 Testcontainers WireMock 모듈

Java 메서드를 목킹하는 대신 HTTP 프로토콜 수준에서 테스트하면 직렬화 문제를 잡고 실제적인 실패 시나리오를 시뮬레이션할 수 있어요.

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

더 읽어보기 (Further reading)

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

더 알아보기 (Learn more)