Share feedback
Answers are generated based on the documentation.


title: 使用 Testcontainers 测试 Spring Boot Kafka 监听器 linkTitle: Spring Boot Kafka description: 了解如何使用 Testcontainers 的 Kafka 与 MySQL 模块测试 Spring Boot Kafka 监听器。 keywords: testcontainers, java, spring boot, testing, kafka, mysql, jpa, awaitility summary: | 了解如何创建一个带有 Kafka 监听器、并将数据持久化到 MySQL 的 Spring Boot 应用, 然后使用 Testcontainers 的 Kafka 与 MySQL 模块配合 Awaitility 对其进行测试。 aliases:

  • /guides/testcontainers-java-spring-boot-kafka/create-project/
  • /guides/testcontainers-java-spring-boot-kafka/run-tests/
  • /guides/testcontainers-java-spring-boot-kafka/write-tests/ params: tags: [testing] time: 25 minutes

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

  • 创建一个集成了 Kafka 的 Spring Boot 应用
  • 实现一个 Kafka 监听器并将数据持久化到 MySQL 数据库
  • 使用 Testcontainers 与 Awaitility 测试该 Kafka 监听器

先决条件

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

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

创建 Spring Boot 项目

搭建项目

Spring Initializr 上创建一个 Spring Boot 项目,添加 Spring for Apache KafkaSpring Data JPAMySQL Driver 以及 Testcontainers 这几个 starter。

或者,克隆 指南仓库

生成应用后,将 Awaitility 库添加为测试依赖。稍后你会用它来断言一个异步流程的预期结果。

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-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
    </dependency>
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka-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-kafka</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-mysql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.awaitility</groupId>
        <artifactId>awaitility</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

建议使用 Testcontainers 的 BOM(Bill of Materials,物料清单),这样你无需为每个 Testcontainers 模块依赖重复指定版本。

创建 JPA 实体

该应用会监听一个名为 product-price-changes 的主题(topic)。当消息到达时,它会从 事件负载中提取产品编码和价格,并更新 MySQL 数据库中该产品的价格。

创建 Product.java

package com.testcontainers.demo;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;

@Entity
@Table(name = "products")
class Product {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false, unique = true)
  private String code;

  @Column(nullable = false)
  private String name;

  @Column(nullable = false)
  private BigDecimal price;

  public Product() {}

  public Product(Long id, String code, String name, BigDecimal price) {
    this.id = id;
    this.code = code;
    this.name = name;
    this.price = price;
  }

  public Long getId() {
    return id;
  }

  public void setId(Long id) {
    this.id = id;
  }

  public String getCode() {
    return code;
  }

  public void setCode(String code) {
    this.code = code;
  }

  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }

  public BigDecimal getPrice() {
    return price;
  }

  public void setPrice(BigDecimal price) {
    this.price = price;
  }
}

创建 Spring Data JPA 仓储

Product 实体创建一个仓储接口,包含按编码查找产品以及更新指定产品编码价格的方法:

package com.testcontainers.demo;

import java.math.BigDecimal;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

interface ProductRepository extends JpaRepository<Product, Long> {
  Optional<Product> findByCode(String code);

  @Modifying
  @Query("update Product p set p.price = :price where p.code = :productCode")
  void updateProductPrice(
    @Param("productCode") String productCode,
    @Param("price") BigDecimal price
  );
}

添加 schema 创建脚本

由于该应用不使用内存数据库,你需要创建 MySQL 表。生产环境的推荐做法是使用 Flyway 或 Liquibase 之类的迁移工具,但就本指南而言,使用 Spring Boot 内置的 schema 初始化已足够。

创建 src/main/resources/schema.sql

create table products (
      id int NOT NULL AUTO_INCREMENT,
      code varchar(255) not null,
      name varchar(255) not null,
      price numeric(5,2) not null,
      PRIMARY KEY (id),
      UNIQUE (code)
);

src/main/resources/application.properties 中启用 schema 初始化:

spring.sql.init.mode=always

创建事件负载

创建一个名为 ProductPriceChangedEvent 的 record,表示来自 Kafka 主题的事件负载结构:

package com.testcontainers.demo;

import java.math.BigDecimal;

record ProductPriceChangedEvent(String productCode, BigDecimal price) {}

发送方与接收方约定的 JSON 格式如下:

{
  "productCode": "P100",
  "price": 25.0
}

实现 Kafka 监听器

创建 ProductPriceChangedEventHandler.java,处理来自 product-price-changes 主题的 消息,并更新数据库中的产品价格:

package com.testcontainers.demo;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
@Transactional
class ProductPriceChangedEventHandler {

  private static final Logger log = LoggerFactory.getLogger(
    ProductPriceChangedEventHandler.class
  );

  private final ProductRepository productRepository;

  ProductPriceChangedEventHandler(ProductRepository productRepository) {
    this.productRepository = productRepository;
  }

  @KafkaListener(topics = "product-price-changes", groupId = "demo")
  public void handle(ProductPriceChangedEvent event) {
    log.info(
      "Received a ProductPriceChangedEvent with productCode:{}: ",
      event.productCode()
    );
    productRepository.updateProductPrice(event.productCode(), event.price());
  }
}

@KafkaListener 注解指定要监听的主题名称。Spring Kafka 会依据 application.properties 中配置的属性处理序列化和反序列化。

配置 Kafka 序列化

src/main/resources/application.properties 中添加以下 Kafka 属性:

######## Kafka Configuration  #########
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer

spring.kafka.consumer.group-id=demo
spring.kafka.consumer.auto-offset-reset=latest
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer
spring.kafka.consumer.properties.spring.json.trusted.packages=com.testcontainers.demo

productCode 键使用 StringSerializer/StringDeserializer 进行(反)序列化, ProductPriceChangedEvent 值则使用 JsonSerializer/JsonDeserializer 进行(反)序列化。

使用 Testcontainers 编写测试

要测试 Kafka 监听器,你需要一个运行中的 Kafka broker 和一个 MySQL 数据库,外加一个启动 的 Spring 上下文。Testcontainers 会在 Docker 容器中启动这两个服务,而 @DynamicPropertySource 会将它们连接到 Spring。

编写测试

创建 ProductPriceChangedEventHandlerTest.java

package com.testcontainers.demo;

import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

import java.math.BigDecimal;
import java.time.Duration;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.TestPropertySource;
import org.testcontainers.kafka.ConfluentKafkaContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest
@TestPropertySource(
  properties = {
    "spring.kafka.consumer.auto-offset-reset=earliest",
    "spring.datasource.url=jdbc:tc:mysql:8.0.32:///db",
  }
)
@Testcontainers
class ProductPriceChangedEventHandlerTest {

  @Container
  static final ConfluentKafkaContainer kafka =
    new ConfluentKafkaContainer("confluentinc/cp-kafka:7.8.0");

  @DynamicPropertySource
  static void overrideProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
  }

  @Autowired
  private KafkaTemplate<String, Object> kafkaTemplate;

  @Autowired
  private ProductRepository productRepository;

  @BeforeEach
  void setUp() {
    Product product = new Product(null, "P100", "Product One", BigDecimal.TEN);
    productRepository.save(product);
  }

  @Test
  void shouldHandleProductPriceChangedEvent() {
    ProductPriceChangedEvent event = new ProductPriceChangedEvent(
      "P100",
      new BigDecimal("14.50")
    );

    kafkaTemplate.send("product-price-changes", event.productCode(), event);

    await()
      .pollInterval(Duration.ofSeconds(3))
      .atMost(10, SECONDS)
      .untilAsserted(() -> {
        Optional<Product> optionalProduct = productRepository.findByCode(
          "P100"
        );
        assertThat(optionalProduct).isPresent();
        assertThat(optionalProduct.get().getCode()).isEqualTo("P100");
        assertThat(optionalProduct.get().getPrice())
          .isEqualTo(new BigDecimal("14.50"));
      });
  }
}

该测试的作用如下:

  • @SpringBootTest 启动完整的 Spring 应用上下文。
  • @TestPropertySource 中 Testcontainers 的专用 JDBC URL(jdbc:tc:mysql:8.0.32:///db) 会启动一个 MySQL 容器,并将其自动配置为数据源。
  • @Testcontainers@Container 管理 Kafka 容器的生命周期。@DynamicPropertySource 将 Kafka bootstrap 服务器注册到 Spring,使生产者和消费者都能连接到测试容器。
  • @BeforeEach 在每个测试之前在数据库中创建一个 Product 记录。
  • 该测试使用 KafkaTemplateproduct-price-changes 主题发送一个 ProductPriceChangedEvent。Spring Boot 会使用 JsonSerializer 将对象转换为 JSON。
  • 由于 Kafka 消息处理是异步的,测试使用 Awaitility 每 3 秒轮询一次(最多 10 秒),直到数据库中的产品价格与期望值匹配。
  • 属性 spring.kafka.consumer.auto-offset-reset 设为 earliest,这样即使消息在监听器 就绪之前就发送到了主题,监听器也能消费到消息。该设置对运行测试很有帮助。

运行测试与后续步骤

运行测试

$ ./mvnw test

或者使用 Gradle:

$ ./gradlew test

你应该会看到 Kafka 和 MySQL 的 Docker 容器启动,并且所有测试通过。测试结束后,容器会 自动停止并被移除。

小结

相比 mock 或嵌入式替代方案,使用真实的 Kafka 和 MySQL 实例进行测试,能让你对代码的正确性 更有信心。Testcontainers 库管理着容器生命周期,使你的集成测试能够针对与生产环境相同的服务 运行。

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

延伸阅读