Share feedback
Answers are generated based on the documentation.


title: 使用 MockServer 测试 REST API 集成 linkTitle: MockServer description: 了解如何使用 Testcontainers 的 MockServer 模块测试 Spring Boot 应用中的 REST API 集成。 keywords: testcontainers, java, spring boot, testing, mockserver, rest api, rest assured summary: | 了解如何创建一个与外部 REST API 集成的 Spring Boot 应用,然后使用 Testcontainers 和 MockServer 对这些集成进行测试。 aliases:

  • /guides/testcontainers-java-mockserver/create-project/
  • /guides/testcontainers-java-mockserver/run-tests/
  • /guides/testcontainers-java-mockserver/write-tests/ params: tags: [testing] time: 20 minutes

在本指南中,你将学习如何:

  • 创建一个与外部 REST API 通信的 Spring Boot 应用
  • 使用 Testcontainers 的 MockServer 模块测试外部 API 集成

先决条件

  • Java 17+
  • Maven 或 Gradle
  • 一个受 Testcontainers 支持的 Docker 环境
Note

如果你刚接触 Testcontainers,请访问 Testcontainers 概览 了解 Testcontainers 及其使用优势。

创建 Spring Boot 项目

搭建项目

Spring Initializr 上创建一个 Spring Boot 项目, 添加 Spring WebSpring Reactive Web 以及 Testcontainers 这几个 starter。

或者,克隆 指南仓库

生成项目后,添加 REST AssuredMockServer 库作为测试依赖。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 的 BOM(Bill of Materials,物料清单),这样你无需为每个 Testcontainers 模块依赖重复指定版本。

本指南构建的是一个管理视频相册(album)的应用。相册中的照片资源由第三方 REST API 处理。出于演示目的,该应用使用公开可用的 JSONPlaceholder API 作为照片服务。

该应用暴露一个 GET /api/albums/{albumId} 端点,它会调用照片服务以获取指定相册的 照片。MockServer 是一个用于 mock 基于 HTTP 的服务 的库。Testcontainers 提供了一个 MockServer 模块, 能以 Docker 容器方式运行 MockServer。

创建 Album 和 Photo 模型

使用 Java record 创建 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 注册为 bean

要生成 PhotoServiceClient 的运行时实现,需使用 HttpServiceProxyFactory 将其 注册为 Spring bean。该工厂需要一个 HttpClientAdapter 实现。Spring Boot 提供了 WebClientAdapter 作为 spring-webflux 库的一部分:

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 编写测试

在 HTTP 协议层面(而非 mock Java 方法)来 mock 外部 API 的交互,让你可以验证序列化与 反序列化行为,并模拟网络故障。

Testcontainers 提供了一个 MockServer 模块,可在 Docker 容器内启动一个 MockServer 实例。然后你可以使用 MockServerClient 配置 mock 预期(expectation)。

编写测试

创建 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 配置一个 mock 响应,向应用的 /api/albums/{albumId} 端点发送请求,并校验响应体。它还使用 mockServerClient.verify() 确认预期的 API 调用确实到达了 MockServer。
  • shouldReturn404StatusWhenAlbumNotFound() 配置 MockServer 返回 404 状态,并验证应用 将该状态传播给调用方。

运行测试与后续步骤

运行测试

$ ./mvnw test

或者使用 Gradle:

$ ./gradlew test

你应该在控制台输出中看到 MockServer 的 Docker 容器启动。它充当照片服务,依据配置的 预期(expectation)返回 mock 响应。所有测试都应通过。

小结

你构建了一个使用声明式 HTTP 客户端与外部 REST API 集成的 Spring Boot 应用,然后使用 Testcontainers 的 MockServer 模块对该集成进行了测试。在 HTTP 协议层面而非 mock Java 方法层面进行测试,能让你发现序列化问题并模拟真实的故障场景。

想进一步了解 Testcontainers,请访问 Testcontainers 概览

延伸阅读