基于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
相关产品推荐
相关产品推荐

