You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于TestContainers的Spring Boot微服务集成测试问题咨询

微服务跨服务集成测试(TestContainers多服务场景)

问题描述

我有两个微服务:

  • Service A 提供创建Article实体的接口,Article包含Supplier属性。创建Article时,会通过Feign调用Service B的接口,判断Supplier是否存在。
  • 集成测试中,Service A用TestContainers的MySQL容器,Service B自身依赖MySQL容器,不知道如何实现两者的网络连接;此前仅完成单服务TestContainers测试,跨服务场景完全无头绪,疑问TestContainers是否适用于多服务集成测试。

相关代码片段

Article实体类

@Getter
@Setter
@NoArgsConstructor
@Entity
@EqualsAndHashCode
@Table(name = "article")
public class ArticleDAO {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Integer id;

    @Column(name = "name")
    private String name;

    // 省略其他属性

    @OneToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "supplier_id", referencedColumnName = "id")
    private SupplierDAO supplier;
}

Service A创建Article的业务逻辑

@Override
public Article createArticle(final Article article, final String usuario) {
    final List<ArticleDAO> articleDAOS;
    
    if(this.suppliersFeignClient.getSupplier(article.getSupplier().getId()).getBody() == null)
            // 处理Supplier不存在的逻辑
    this.checkArticleDontExistByName(article);
    articles.forEach(article -> article.setUser(usuario));
    articleDAOS = this.modelMapper.map(articles, new TypeToken<List<ArticleDAO>>(){}.getType());
    this.articleRepository.saveAll(articleDAOS);
    return this.modelMapper.map(articleDAOS, new TypeToken<List<Article>>(){}.getType());
}

当前集成测试代码(注释Feign调用可正常运行)

@ServiceConnection
@Container
private static final MySQLContainer<?> mySQLContainer = new MySQLContainer<>(ContainersConstants.MYSQL_CONTAINER_TAG);
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ArticlesService articlesService;
@Autowired
private ModelMapper modelMapper;
@Autowired
private ArticleRepository articleRepository;

@BeforeAll
static void beforeAll() {
    mySQLContainer.start();
}

@AfterAll
static void afterAll() {
    mySQLContainer.stop();
}

@Test
@DisplayName("Create articles /articles")
void WHEN_calling_create_articles_THEN_status_is_200() {
    final List<ArticleDTO> articleDTOS = Instancio.ofList(ArticleDTO.class).size(3)
            .ignore(field(ArticleDTO::getId))
            .ignore(field(ArticleDTO::getCategory))
            .ignore(field(ArticleDTO::getSupplier))
            .create();
    final CreateArticlesRequest createArticlesRequest = CreateArticlesRequest.builder()
            .articles(articleDTOS)
            .user(Instancio.create(String.class))
            .build();
    final ResponseEntity<CreateArticlesResponse> response = restTemplate.exchange(restTemplate.getRootUri() + URIConstants.ARTICLES_URI,
            HttpMethod.POST, new HttpEntity<>(createArticlesRequest), new ParameterizedTypeReference<>() {});
    final List<ArticleDTO> articles = Objects.requireNonNull(response.getBody()).getArticles();

    articles.forEach(article -> article.setId(null));

    assertEquals(articles.size(), articleDTOS.size() , "articles.size() should be " + articleDTOS.size());
    assertTrue(articles.containsAll(articleDTOS), "articles should contain the same elements as articulosDTOS");
    assertEquals(HttpStatus.OK, response.getStatusCode(), "Status Code should be " + HttpStatus.OK);
}

解决方案

TestContainers完全支持多服务集成测试,以下三种方案可解决你的问题:

方案一:Mock FeignClient(快速验证Service A逻辑)

如果仅需测试Service A的业务逻辑,不需要真的调用Service B,直接Mock FeignClient即可:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ArticleIntegrationTest {
    @ServiceConnection
    @Container
    private static final MySQLContainer<?> mySQLContainer = new MySQLContainer<>(ContainersConstants.MYSQL_CONTAINER_TAG);
    
    // Mock Service B的Feign客户端
    @MockBean
    private SuppliersFeignClient suppliersFeignClient;
    
    @Autowired
    private TestRestTemplate restTemplate;
    // 其他依赖注入...

    @BeforeEach
    void setUp() {
        // 模拟Supplier存在的场景
        Supplier mockSupplier = new Supplier();
        mockSupplier.setId(1);
        when(suppliersFeignClient.getSupplier(anyInt()))
                .thenReturn(ResponseEntity.ok(mockSupplier));
        
        // 如需模拟Supplier不存在,可返回ResponseEntity.notFound().build();
    }

    // 测试方法保持不变,无需注释Feign调用
    @Test
    @DisplayName("Create articles /articles")
    void WHEN_calling_create_articles_THEN_status_is_200() {
        // 修改测试数据,加入Supplier信息
        final List<ArticleDTO> articleDTOS = Instancio.ofList(ArticleDTO.class).size(3)
                .ignore(field(ArticleDTO::getId))
                .ignore(field(ArticleDTO::getCategory))
                .set(field(ArticleDTO::getSupplier), () -> {
                    SupplierDTO dto = new SupplierDTO();
                    dto.setId(1);
                    return dto;
                })
                .create();
        
        // 后续测试逻辑不变...
    }
}

方案二:用TestContainers启动Service B容器

如果需要真实调用Service B,可直接用TestContainers启动Service B的镜像(需先构建Service B的Docker镜像):

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ArticleIntegrationTest {
    @ServiceConnection
    @Container
    private static final MySQLContainer<?> mySQLContainer = new MySQLContainer<>(ContainersConstants.MYSQL_CONTAINER_TAG);
    
    // 启动Service B的容器
    @Container
    private static final GenericContainer<?> serviceBContainer = new GenericContainer<>("your-service-b-image:latest")
            .withExposedPorts(8081) // Service B的端口
            .withEnv("SPRING_DATASOURCE_URL", "jdbc:mysql://mysql-service-b:3306/supplier_db?useSSL=false")
            .withEnv("SPRING_DATASOURCE_USERNAME", "root")
            .withEnv("SPRING_DATASOURCE_PASSWORD", "password")
            .dependsOn(new MySQLContainer<>("mysql:8.0") // Service B的MySQL容器
                    .withDatabaseName("supplier_db")
                    .withUsername("root")
                    .withPassword("password")
                    .withNetworkAliases("mysql-service-b"));

    @Autowired
    private TestRestTemplate restTemplate;

    @BeforeAll
    static void beforeAll() {
        mySQLContainer.start();
        serviceBContainer.start();
        // 配置Service A的FeignClient指向Service B容器的地址
        System.setProperty("feign.client.config.suppliersFeignClient.url", 
                "http://localhost:" + serviceBContainer.getMappedPort(8081));
    }

    // 测试方法中加入真实的Supplier数据,确保Service B的数据库中存在对应记录
    @Test
    @DisplayName("Create articles with valid supplier")
    void WHEN_calling_create_articles_with_valid_supplier_THEN_status_is_200() {
        // 先调用Service B的接口创建测试用Supplier
        SupplierDTO testSupplier = new SupplierDTO();
        testSupplier.setName("Test Supplier");
        ResponseEntity<SupplierDTO> supplierResponse = restTemplate.exchange(
                "http://localhost:" + serviceBContainer.getMappedPort(8081) + "/suppliers",
                HttpMethod.POST, new HttpEntity<>(testSupplier), SupplierDTO.class);
        Integer supplierId = Objects.requireNonNull(supplierResponse.getBody()).getId();

        // 创建包含该Supplier的Article
        final List<ArticleDTO> articleDTOS = Instancio.ofList(ArticleDTO.class).size(3)
                .ignore(field(ArticleDTO::getId))
                .ignore(field(ArticleDTO::getCategory))
                .set(field(ArticleDTO::getSupplier), () -> {
                    SupplierDTO dto = new SupplierDTO();
                    dto.setId(supplierId);
                    return dto;
                })
                .create();
        
        // 后续测试逻辑不变...
    }
}

方案三:使用Docker Compose容器(最贴近生产环境)

编写docker-compose.yml定义整个服务栈,用TestContainers的DockerComposeContainer启动:

# docker-compose.yml
version: '3.8'
services:
  service-a:
    build: ./service-a
    ports:
      - "8080:8080"
    environment:
      - SPRING_DATASOURCE_URL=jdbc:mysql://mysql-a:3306/article_db?useSSL=false
      - SPRING_DATASOURCE_USERNAME=root
      - SPRING_DATASOURCE_PASSWORD=password
      - FEIGN_CLIENT_CONFIG_SUPPLIERSFEIGNCLIENT_URL=http://service-b:8081
    depends_on:
      - mysql-a
      - service-b

  service-b:
    build: ./service-b
    ports:
      - "8081:8081"
    environment:
      - SPRING_DATASOURCE_URL=jdbc:mysql://mysql-b:3306/supplier_db?useSSL=false
      - SPRING_DATASOURCE_USERNAME=root
      - SPRING_DATASOURCE_PASSWORD=password
    depends_on:
      - mysql-b

  mysql-a:
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=article_db
      - MYSQL_ROOT_PASSWORD=password
    ports:
      - "3306:3306"

  mysql-b:
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=supplier_db
      - MYSQL_ROOT_PASSWORD=password
    ports:
      - "3307:3306"

测试代码:

@SpringBootTest
public class ArticleIntegrationTest {
    @Container
    private static final DockerComposeContainer<?> composeContainer = new DockerComposeContainer<>(new File("docker-compose.yml"))
            .withExposedService("service-a", 8080, Wait.forHttp("/actuator/health").forStatusCode(200))
            .withExposedService("service-b", 8081, Wait.forHttp("/actuator/health").forStatusCode(200));

    private TestRestTemplate restTemplate;

    @BeforeAll
    static void beforeAll() {
        composeContainer.start();
    }

    @BeforeEach
    void setUp() {
        // 获取Service A的访问地址
        String serviceAUrl = "http://localhost:" + composeContainer.getServicePort("service-a", 8080);
        restTemplate = new TestRestTemplate(serviceAUrl);
    }

    // 测试方法逻辑不变,直接调用Service A的接口即可
}

总结

  • Mock FeignClient适合快速验证单服务逻辑,无需依赖其他服务
  • 直接启动Service B容器适合需要真实调用的场景,灵活性高
  • Docker Compose方案最贴近生产环境,适合复杂多服务集成测试

内容的提问来源于stack exchange,提问作者Ulises CT

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.25 05:12:09