集成测试详解
一、集成测试概述与测试金字塔
1.1 测试金字塔模型
测试金字塔(Test Pyramid)由Mike Cohn提出,描述了不同层级测试的比例关系:
/ E2E \ ← 少量,验证完整业务流程
/----------\
/ Integration \ ← 适量,验证组件交互
/----------------\
/ Unit Tests \ ← 大量,验证单个单元
/______________________\
| 测试层级 | 速度 | 数量 | 关注点 | 典型工具 |
|---|---|---|---|---|
| 单元测试 | 毫秒级 | 最多 | 单个类/方法逻辑 | JUnit 5, Mockito |
| 集成测试 | 秒级 | 适中 | 组件间交互、外部依赖 | Spring Boot Test, Testcontainers |
| E2E测试 | 分钟级 | 最少 | 完整用户流程 | Selenium, Cypress |
1.2 集成测试的定义与目标
集成测试验证多个组件/模块协同工作时的行为,重点关注:
- 组件交互:Service → Repository → Database 的完整链路
- 外部依赖:数据库、消息队列、缓存、第三方API
- 配置正确性:Spring上下文装配、Bean依赖注入
- 数据一致性:事务边界、并发场景下的数据完整性
1.3 集成测试 vs 单元测试
java
// 单元测试:隔离测试Service逻辑,Mock掉Repository
@ExtendWith(MockitoExtension.class)
class OrderServiceUnitTest {
@Mock
private OrderRepository orderRepository;
@InjectMocks
private OrderService orderService;
@Test
void shouldCalculateTotalPrice() {
when(orderRepository.findById(1L))
.thenReturn(Optional.of(new Order(List.of(
new OrderItem("iPhone", 2, 5999.0)
))));
double total = orderService.calculateTotal(1L);
assertEquals(11998.0, total);
}
}
// 集成测试:验证Service + Repository + Database的完整链路
@SpringBootTest
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldPersistAndRetrieveOrder() {
Order order = orderService.createOrder(new CreateOrderRequest("iPhone", 2));
Order found = orderRepository.findById(order.getId()).orElseThrow();
assertEquals("iPhone", found.getItems().get(0).getProductName());
assertEquals(2, found.getItems().get(0).getQuantity());
}
}
二、Spring Boot Test注解体系
2.1 @SpringBootTest --- 完整应用上下文
@SpringBootTest 加载完整的Spring应用上下文,是最"重量级"的测试注解:
java
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"spring.datasource.url=jdbc:tc:mysql:8.0:///testdb",
"app.feature.new-checkout=true"
}
)
@ActiveProfiles("test")
class FullApplicationIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ApplicationContext context;
@Test
void contextLoads() {
assertNotNull(context.getBean(OrderService.class));
}
@Test
void shouldCreateOrderViaApi() {
var request = new CreateOrderRequest("MacBook Pro", 1);
ResponseEntity<OrderResponse> response = restTemplate.postForEntity(
"/api/orders", request, OrderResponse.class);
assertEquals(HttpStatus.CREATED, response.getStatusCode());
assertNotNull(response.getBody().getId());
}
}
webEnvironment 选项:
| 选项 | 说明 | 适用场景 |
|---|---|---|
MOCK(默认) |
模拟Servlet环境,不启动真实服务器 | 配合MockMvc使用 |
RANDOM_PORT |
启动真实服务器,随机端口 | 配合TestRestTemplate/WebTestClient |
DEFINED_PORT |
启动真实服务器,使用配置端口 | 需要固定端口的场景 |
NONE |
不创建Web环境 | 非Web应用/消息消费者测试 |
2.2 @WebMvcTest --- Web层切片测试
仅加载Controller层相关Bean,Service层需要Mock:
java
@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldReturnOrderList() throws Exception {
List<OrderResponse> orders = List.of(
new OrderResponse(1L, "iPhone", 5999.0),
new OrderResponse(2L, "iPad", 3999.0)
);
when(orderService.getAllOrders()).thenReturn(orders);
mockMvc.perform(get("/api/orders")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].productName").value("iPhone"))
.andExpect(jsonPath("$[1].price").value(3999.0));
verify(orderService).getAllOrders();
}
@Test
void shouldReturn404WhenOrderNotFound() throws Exception {
when(orderService.getOrderById(999L))
.thenThrow(new ResourceNotFoundException("Order not found"));
mockMvc.perform(get("/api/orders/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("Order not found"));
}
@Test
void shouldValidateRequestBody() throws Exception {
String invalidJson = """
{"productName": "", "quantity": -1}
""";
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors.productName").exists());
}
}
2.3 @DataJpaTest --- 持久层切片测试
仅加载JPA相关组件(Entity、Repository),默认使用内嵌H2数据库:
java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindByNameContaining() {
entityManager.persist(new Product("iPhone 15", 5999.0, "ELECTRONICS"));
entityManager.persist(new Product("iPhone 14", 4999.0, "ELECTRONICS"));
entityManager.persist(new Product("Galaxy S24", 4599.0, "ELECTRONICS"));
entityManager.flush();
List<Product> results = productRepository.findByNameContaining("iPhone");
assertEquals(2, results.size());
assertTrue(results.stream().allMatch(p -> p.getName().contains("iPhone")));
}
@Test
void shouldSupportPagination() {
for (int i = 0; i < 20; i++) {
entityManager.persist(new Product("Product-" + i, 100.0 * i, "GENERAL"));
}
entityManager.flush();
Page<Product> page = productRepository.findAll(
PageRequest.of(0, 5, Sort.by("price").descending()));
assertEquals(20, page.getTotalElements());
assertEquals(4, page.getTotalPages());
assertEquals(5, page.getContent().size());
}
@Test
void shouldExecuteCustomQuery() {
entityManager.persist(new Product("Laptop", 8999.0, "ELECTRONICS"));
entityManager.persist(new Product("Phone", 3999.0, "ELECTRONICS"));
entityManager.flush();
List<Product> expensive = productRepository.findByPriceGreaterThan(5000.0);
assertEquals(1, expensive.size());
assertEquals("Laptop", expensive.get(0).getName());
}
}
2.4 @JsonTest --- JSON序列化测试
java
@JsonTest
class OrderJsonSerializationTest {
@Autowired
private JacksonTester<OrderResponse> json;
@Test
void shouldSerializeOrder() throws IOException {
OrderResponse order = new OrderResponse(1L, "iPhone", 5999.0);
assertThat(json.write(order))
.hasJsonPathNumberValue("@.id")
.hasJsonPathStringValue("@.productName")
.extractingJsonPathStringValue("@.productName")
.isEqualTo("iPhone");
}
@Test
void shouldDeserializeOrder() throws IOException {
String jsonContent = """
{"id": 1, "productName": "MacBook", "price": 12999.0}
""";
assertThat(json.parse(jsonContent))
.usingRecursiveComparison()
.isEqualTo(new OrderResponse(1L, "MacBook", 12999.0));
}
}
三、MockMvc(API测试)
3.1 MockMvc配置方式
java
// 方式一:@WebMvcTest 自动配置(推荐)
@WebMvcTest(OrderController.class)
class OrderControllerSliceTest {
@Autowired
private MockMvc mockMvc;
}
// 方式二:@SpringBootTest + @AutoConfigureMockMvc
@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerFullTest {
@Autowired
private MockMvc mockMvc;
}
// 方式三:手动构建(Standalone,不加载Spring上下文)
class OrderControllerStandaloneTest {
private MockMvc mockMvc;
@BeforeEach
void setup() {
OrderService orderService = mock(OrderService.class);
OrderController controller = new OrderController(orderService);
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.setMessageConverters(new MappingJackson2HttpMessageConverter())
.build();
}
}
3.2 完整CRUD测试示例
java
@WebMvcTest(ProductController.class)
@Import(SecurityConfig.class)
class ProductControllerCrudTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService;
@Autowired
private ObjectMapper objectMapper;
@Test
void createProduct_shouldReturn201() throws Exception {
CreateProductRequest request = new CreateProductRequest("AirPods Pro", 1899.0);
ProductResponse response = new ProductResponse(1L, "AirPods Pro", 1899.0);
when(productService.create(any())).thenReturn(response);
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
.header("Authorization", "Bearer valid-token"))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/products/1"))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("AirPods Pro"));
}
@Test
void updateProduct_shouldReturn200() throws Exception {
UpdateProductRequest request = new UpdateProductRequest("AirPods Max", 4399.0);
ProductResponse response = new ProductResponse(1L, "AirPods Max", 4399.0);
when(productService.update(eq(1L), any())).thenReturn(response);
mockMvc.perform(put("/api/products/1")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("AirPods Max"))
.andExpect(jsonPath("$.price").value(4399.0));
}
@Test
void deleteProduct_shouldReturn204() throws Exception {
doNothing().when(productService).delete(1L);
mockMvc.perform(delete("/api/products/1"))
.andExpect(status().isNoContent());
verify(productService).delete(1L);
}
@Test
void listProducts_withPagination() throws Exception {
Page<ProductResponse> page = new PageImpl<>(
List.of(new ProductResponse(1L, "iPhone", 5999.0)),
PageRequest.of(0, 10), 1);
when(productService.list(any())).thenReturn(page);
mockMvc.perform(get("/api/products")
.param("page", "0")
.param("size", "10")
.param("sort", "price,desc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.totalElements").value(1));
}
}
3.3 文件上传测试
java
@Test
void shouldUploadFile() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "report.pdf",
MediaType.APPLICATION_PDF_VALUE,
"PDF content".getBytes());
when(fileService.upload(any())).thenReturn(new FileUploadResponse("file-123", "report.pdf"));
mockMvc.perform(multipart("/api/files/upload")
.file(file)
.param("category", "reports"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.fileId").value("file-123"));
}
四、WebTestClient(响应式API测试)
4.1 基本用法
java
@WebFluxTest(ReactiveOrderController.class)
class ReactiveOrderControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockBean
private ReactiveOrderService orderService;
@Test
void shouldReturnOrderFlux() {
when(orderService.getAllOrders()).thenReturn(Flux.just(
new OrderResponse(1L, "iPhone", 5999.0),
new OrderResponse(2L, "iPad", 3999.0)
));
webTestClient.get()
.uri("/api/reactive/orders")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBodyList(OrderResponse.class)
.hasSize(2)
.contains(new OrderResponse(1L, "iPhone", 5999.0));
}
@Test
void shouldCreateOrder() {
CreateOrderRequest request = new CreateOrderRequest("MacBook", 1);
when(orderService.createOrder(any()))
.thenReturn(Mono.just(new OrderResponse(1L, "MacBook", 12999.0)));
webTestClient.post()
.uri("/api/reactive/orders")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.exchange()
.expectStatus().isCreated()
.expectBody()
.jsonPath("$.id").isEqualTo(1)
.jsonPath("$.productName").isEqualTo("MacBook");
}
}
4.2 绑定到真实服务器
java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ReactiveApiIntegrationTest {
@Autowired
private WebTestClient webTestClient;
@Test
void shouldStreamServerSentEvents() {
webTestClient.get()
.uri("/api/events/stream")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(String.class)
.getResponseBody()
.take(5) // 取前5个事件
.as(StepVerifier::create)
.expectNextCount(5)
.verifyComplete();
}
@Test
void shouldHandleTimeout() {
webTestClient = webTestClient.mutate()
.responseTimeout(Duration.ofSeconds(10))
.build();
webTestClient.get()
.uri("/api/slow-endpoint")
.exchange()
.expectStatus().isOk();
}
}
五、Testcontainers(Docker容器化测试)
5.1 核心概念与依赖
Testcontainers 是一个Java库,利用Docker在测试期间启动真实容器,测试结束后自动销毁:
xml
<!-- pom.xml -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>
5.2 基本使用模式
java
@Testcontainers
class BasicContainerTest {
// 每个测试方法启动新容器
@Container
private GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Test
void shouldConnectToRedis() {
String host = redis.getHost();
Integer port = redis.getMappedPort(6379);
Jedis jedis = new Jedis(host, port);
jedis.set("key", "value");
assertEquals("value", jedis.get("key"));
}
}
5.3 @ServiceConnection(Spring Boot 3.1+)
Spring Boot 3.1引入了 @ServiceConnection,自动配置容器连接属性:
java
@SpringBootTest
@Testcontainers
class ServiceConnectionTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldConnectToPostgres() {
jdbcTemplate.execute("CREATE TABLE test_table (id INT, name VARCHAR(50))");
jdbcTemplate.update("INSERT INTO test_table VALUES (1, 'hello')");
String name = jdbcTemplate.queryForObject(
"SELECT name FROM test_table WHERE id = 1", String.class);
assertEquals("hello", name);
}
}
5.4 容器复用(加速测试)
java
// 方式一:withReuse(true) + ~/.testcontainers.properties 中设置 testcontainers.reuse.enable=true
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withReuse(true);
// 方式二:Singleton Container Pattern(推荐)
public abstract class AbstractIntegrationTest {
static final MySQLContainer<?> MYSQL;
static {
MYSQL = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
MYSQL.start();
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
}
}
六、Testcontainers + MySQL/PostgreSQL
6.1 MySQL集成测试
java
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class MySqlIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("order_db")
.withUsername("root")
.withPassword("secret")
.withInitScript("schema-mysql.sql")
.withCommand("--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci");
@DynamicPropertySource
static void mysqlProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
@Autowired
private OrderRepository orderRepository;
@Test
void shouldUseMySqlSpecificFunctions() {
// 测试MySQL特有函数,H2无法支持
orderRepository.save(new Order("ORD-001", LocalDateTime.now()));
List<Object[]> results = orderRepository.findOrdersWithDateFormat();
assertFalse(results.isEmpty());
}
@Test
void shouldHandleJsonColumn() {
// MySQL 8.0 JSON类型支持
Order order = new Order();
order.setMetadata("{\"priority\": \"high\", \"source\": \"web\"}");
orderRepository.save(order);
Order found = orderRepository.findById(order.getId()).orElseThrow();
assertTrue(found.getMetadata().contains("\"priority\": \"high\""));
}
}
6.2 PostgreSQL集成测试
java
@SpringBootTest
@Testcontainers
class PostgresIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("app_db")
.withUsername("postgres")
.withPassword("postgres")
.withInitScript("init-postgres.sql");
@DynamicPropertySource
static void postgresProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldSupportPostgresArrayTypes() {
jdbcTemplate.update(
"INSERT INTO articles (title, tags) VALUES (?, ?)",
"Test Article", new String[]{"java", "spring", "testing"});
String[] tags = jdbcTemplate.queryForObject(
"SELECT tags FROM articles WHERE title = ?",
(rs, rowNum) -> (String[]) rs.getArray("tags").getArray(),
"Test Article");
assertArrayEquals(new String[]{"java", "spring", "testing"}, tags);
}
@Test
void shouldSupportFullTextSearch() {
jdbcTemplate.update("INSERT INTO documents (content) VALUES (?)",
"Spring Boot makes integration testing easy with Testcontainers");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM documents WHERE to_tsvector(content) @@ to_tsquery('spring & testing')",
Integer.class);
assertEquals(1, count);
}
}
七、Testcontainers + Redis/Kafka
7.1 Redis容器测试
java
@SpringBootTest
@Testcontainers
class RedisIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379)
.waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1));
@DynamicPropertySource
static void redisProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private CacheService cacheService;
@Test
void shouldCacheAndRetrieve() {
cacheService.cacheProduct(1L, new Product("iPhone", 5999.0));
Product cached = cacheService.getCachedProduct(1L);
assertNotNull(cached);
assertEquals("iPhone", cached.getName());
}
@Test
void shouldExpireCache() throws InterruptedException {
redisTemplate.opsForValue().set("temp-key", "temp-value", 1, TimeUnit.SECONDS);
assertEquals("temp-value", redisTemplate.opsForValue().get("temp-key"));
Thread.sleep(1500);
assertNull(redisTemplate.opsForValue().get("temp-key"));
}
@Test
void shouldSupportRedisPubSub() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<String> received = new AtomicReference<>();
redisTemplate.execute((RedisCallback<Void>) connection -> {
connection.subscribe((message, pattern) -> {
received.set(new String(message.getBody()));
latch.countDown();
}, "notifications".getBytes());
return null;
});
redisTemplate.convertAndSend("notifications", "order-created");
assertTrue(latch.await(5, TimeUnit.SECONDS));
assertEquals("order-created", received.get());
}
}
7.2 Kafka容器测试
java
@SpringBootTest
@Testcontainers
class KafkaIntegrationTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0"))
.withKraft(); // 使用KRaft模式,无需Zookeeper
@DynamicPropertySource
static void kafkaProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
registry.add("spring.kafka.consumer.auto-offset-reset", () -> "earliest");
registry.add("spring.kafka.consumer.group-id", () -> "test-group");
}
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Autowired
private OrderEventPublisher eventPublisher;
@Test
void shouldProduceAndConsumeMessage() throws Exception {
// 使用KafkaConsumer直接验证
Map<String, Object> consumerProps = KafkaTestUtils.consumerProps(
"test-verify", "true", kafka.getBootstrapServers());
Consumer<String, String> consumer = new DefaultKafkaConsumer<>(consumerProps);
consumer.subscribe(List.of("order-events"));
kafkaTemplate.send("order-events", "order-1", "{\"status\":\"CREATED\"}").get();
ConsumerRecords<String, String> records = KafkaTestUtils.getRecords(consumer, Duration.ofSeconds(10));
assertThat(records.count()).isGreaterThan(0);
consumer.close();
}
@Test
void shouldProcessOrderEventEndToEnd() throws Exception {
// 测试完整的 生产者 → Kafka → 消费者 链路
CountDownLatch latch = new CountDownLatch(1);
// 假设有一个 @KafkaListener 将处理结果写入数据库
eventPublisher.publishOrderCreated(new OrderCreatedEvent("ORD-100", 5999.0));
// 等待消费者处理完成
assertTrue(latch.await(10, TimeUnit.SECONDS));
// 验证消费者副作用(如数据库记录)
}
}
八、数据库测试(@DataJpaTest/H2/Testcontainers)
8.1 H2内嵌数据库测试
java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
class H2RepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindByEmailIgnoreCase() {
entityManager.persist(new User("Alice", "alice@example.com"));
entityManager.flush();
Optional<User> user = userRepository.findByEmailIgnoreCase("ALICE@EXAMPLE.COM");
assertTrue(user.isPresent());
}
}
H2的局限性:
- 不支持MySQL的
JSON_EXTRACT、ON DUPLICATE KEY UPDATE - 不支持PostgreSQL的数组类型、
ON CONFLICT - 存储过程支持有限
- 某些窗口函数行为不同
8.2 使用Testcontainers替代H2
java
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class RealDatabaseRepositoryTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb");
@DynamicPropertySource
static void dbProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
}
@Autowired
private ProductRepository productRepository;
@Test
void shouldExecuteNativeQueryWithMySqlSyntax() {
productRepository.saveAll(List.of(
new Product("A", 100.0), new Product("B", 200.0), new Product("C", 300.0)));
// MySQL特有的 GROUP_CONCAT
String names = productRepository.getAllProductNamesConcatenated();
assertTrue(names.contains("A"));
assertTrue(names.contains("B"));
}
}
8.3 TestEntityManager的使用
java
@DataJpaTest
class EntityManagerTest {
@Autowired
private TestEntityManager em;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldManageEntityLifecycle() {
// persistAndFlush = persist + flush,立即写入数据库
Order order = em.persistAndFlush(new Order("ORD-001", OrderStatus.PENDING));
// 验证ID已生成
assertNotNull(order.getId());
// 修改并验证
order.setStatus(OrderStatus.CONFIRMED);
em.flush();
Order found = orderRepository.findById(order.getId()).orElseThrow();
assertEquals(OrderStatus.CONFIRMED, found.getStatus());
// 删除
em.remove(found);
em.flush();
assertTrue(orderRepository.findById(order.getId()).isEmpty());
}
}
九、测试数据管理(@Sql/Flyway)
9.1 @Sql注解
java
@SpringBootTest
@Sql(scripts = {
"/sql/clean-tables.sql",
"/sql/insert-test-data.sql"
}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/sql/clean-tables.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class SqlDataManagementTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldQueryPreloadedData() {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM products", Integer.class);
assertEquals(10, count); // insert-test-data.sql 插入了10条
}
}
sql
-- src/test/resources/sql/clean-tables.sql
DELETE FROM order_items;
DELETE FROM orders;
DELETE FROM products;
-- src/test/resources/sql/insert-test-data.sql
INSERT INTO products (id, name, price, category) VALUES
(1, 'iPhone 15', 5999.00, 'ELECTRONICS'),
(2, 'MacBook Pro', 12999.00, 'ELECTRONICS'),
(3, 'AirPods Pro', 1899.00, 'ACCESSORIES');
9.2 Flyway迁移测试
java
@SpringBootTest(properties = {
"spring.flyway.enabled=true",
"spring.flyway.locations=classpath:db/migration"
})
@Testcontainers
class FlywayMigrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private Flyway flyway;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldApplyAllMigrations() {
MigrationInfo[] applied = flyway.info().applied();
assertTrue(applied.length >= 3);
// 验证最终schema状态
Integer columnCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'orders'",
Integer.class);
assertTrue(columnCount > 0);
}
@Test
void shouldMigrateFromSpecificVersion() {
// 测试增量迁移
flyway.clean();
flyway.migrate();
// 验证数据完整性约束
assertThrows(DataIntegrityViolationException.class, () ->
jdbcTemplate.update("INSERT INTO orders (id, customer_id) VALUES (1, 99999)"));
}
}
9.3 测试数据工厂(ObjectMother模式)
java
public class TestDataFactory {
private static final AtomicLong ID_SEQ = new AtomicLong(1000);
public static Product aProduct() {
return Product.builder()
.id(ID_SEQ.incrementAndGet())
.name("Test Product")
.price(99.99)
.category("GENERAL")
.stock(100)
.createdAt(LocalDateTime.now())
.build();
}
public static Product aProductWithPrice(double price) {
return aProduct().toBuilder().price(price).build();
}
public static Order anOrderWithItems(int itemCount) {
Order order = Order.builder()
.id(ID_SEQ.incrementAndGet())
.orderNo("ORD-" + UUID.randomUUID().toString().substring(0, 8))
.status(OrderStatus.PENDING)
.build();
for (int i = 0; i < itemCount; i++) {
order.addItem(new OrderItem(aProduct(), i + 1));
}
return order;
}
}
// 使用
@Test
void shouldCalculateDiscount() {
Order order = TestDataFactory.anOrderWithItems(3);
double total = pricingService.calculateWithDiscount(order, 0.9);
// assertions...
}
十、契约测试(Spring Cloud Contract)
10.1 契约定义(Groovy DSL)
groovy
// src/test/resources/contracts/order/shouldReturnOrder.groovy
org.springframework.cloud.contract.spec.Contract.make {
description "should return order by id"
request {
method GET()
url "/api/orders/1"
headers {
contentType(applicationJson())
}
}
response {
status OK()
body([
id: 1,
orderNo: "ORD-2024-001",
status: "CONFIRMED",
items: [[
productName: "iPhone 15",
quantity: 2,
unitPrice: 5999.0
]],
totalAmount: 11998.0
])
headers {
contentType(applicationJson())
}
}
}
10.2 生产者端验证
xml
<!-- 生产者 pom.xml -->
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>4.1.2</version>
<extensions>true</extensions>
<configuration>
<baseClassForTests>com.example.contract.BaseContractTest</baseClassForTests>
<build>
<testFramework>JUNIT5</testFramework>
</build>
</configuration>
</plugin>
java
// 生产者端:契约测试基类
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
public abstract class BaseContractTest {
@Autowired
private WebApplicationContext context;
@MockBean
private OrderService orderService;
@BeforeEach
void setup() {
RestAssuredMockMvc.webAppContextSetup(context);
// 设置通用Mock行为
when(orderService.getOrderById(1L)).thenReturn(new OrderResponse(
1L, "ORD-2024-001", "CONFIRMED",
List.of(new OrderItemResponse("iPhone 15", 2, 5999.0)),
11998.0
));
}
}
10.3 消费者端使用Stub
java
// 消费者端测试
@SpringBootTest
@AutoConfigureStubRunner(
ids = "com.example:order-service:+:stubs:8080",
stubRunnerProperties = {"spring.cloud.contract.stubrunner.repository.root=file://target/stubs"}
)
class OrderClientContractTest {
@Autowired
private OrderClient orderClient; // 消费者的Feign/RestTemplate客户端
@Test
void shouldGetOrderFromProvider() {
OrderResponse order = orderClient.getOrder(1L);
assertNotNull(order);
assertEquals("ORD-2024-001", order.getOrderNo());
assertEquals("CONFIRMED", order.getStatus());
assertEquals(11998.0, order.getTotalAmount());
}
}
十一、WireMock(外部服务Mock)
11.1 基本配置与使用
java
@SpringBootTest(properties = "payment.service.url=http://localhost:${wiremock.server.port}")
@WireMockTest(httpPort = 0) // 随机端口
class PaymentServiceIntegrationTest {
@Autowired
private PaymentGatewayClient paymentClient;
@Test
void shouldProcessPaymentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) {
// 定义Stub
stubFor(post(urlEqualTo("/api/v1/payments"))
.withRequestBody(matchingJsonPath("$.amount", equalTo("5999.0")))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"transactionId": "TXN-12345",
"status": "SUCCESS",
"message": "Payment processed"
}
""")));
// 执行
PaymentResult result = paymentClient.processPayment(new PaymentRequest("ORD-001", 5999.0));
// 验证
assertEquals("SUCCESS", result.getStatus());
assertEquals("TXN-12345", result.getTransactionId());
// 验证请求确实发出
verify(postRequestedFor(urlEqualTo("/api/v1/payments"))
.withHeader("Content-Type", equalTo("application/json")));
}
@Test
void shouldHandlePaymentTimeout(WireMockRuntimeInfo wmRuntimeInfo) {
stubFor(post(urlEqualTo("/api/v1/payments"))
.willReturn(aResponse()
.withStatus(200)
.withFixedDelay(5000))); // 模拟5秒延迟
assertThrows(PaymentTimeoutException.class, () ->
paymentClient.processPayment(new PaymentRequest("ORD-002", 100.0)));
}
@Test
void shouldRetryOnServerError(WireMockRuntimeInfo wmRuntimeInfo) {
// 第一次返回500,第二次返回200
stubFor(post(urlEqualTo("/api/v1/payments"))
.inScenario("retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(500))
.willSetStateTo("SECOND_CALL"));
stubFor(post(urlEqualTo("/api/v1/payments"))
.inScenario("retry")
.whenScenarioStateIs("SECOND_CALL")
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"transactionId\":\"TXN-RETRY\",\"status\":\"SUCCESS\"}")));
PaymentResult result = paymentClient.processPayment(new PaymentRequest("ORD-003", 200.0));
assertEquals("SUCCESS", result.getStatus());
// 验证重试:共调用了2次
verify(2, postRequestedFor(urlEqualTo("/api/v1/payments")));
}
}
11.2 录制与回放
java
@Test
void recordExternalApiCalls() {
// 启动录制模式(通常手动执行一次,保存响应)
WireMock.configureFor("localhost", 8080);
WireMock.startRecording(
recordSpec()
.forTarget("https://api.external-service.com")
.captureHeader("Authorization")
.extractTextBodiesOver(10)
);
// 执行真实调用...
externalClient.fetchData();
// 停止录制,Stub自动保存
SnapshotRecordResult result = WireMock.stopRecording();
// 保存的Stub可用于后续测试
}
十二、测试切片策略
12.1 切片选择指南
需要测试什么?
├── Controller层(请求映射、参数校验、响应格式)
│ └── @WebMvcTest / @WebFluxTest
├── Service + Repository + DB(业务逻辑+持久化)
│ └── @SpringBootTest + Testcontainers
├── 仅Repository(SQL正确性)
│ └── @DataJpaTest
├── JSON序列化/反序列化
│ └── @JsonTest
├── REST客户端调用
│ └── @RestClientTest
└── 消息消费者
└── @SpringBootTest + Testcontainers(Kafka/RabbitMQ)
12.2 自定义测试切片
java
// 定义切片注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@BootstrapWith(KafkaSliceTestContextBootstrapper.class)
@AutoConfigureKafkaTest
public @interface KafkaSliceTest {
}
// 自动配置类
@AutoConfiguration
@ConditionalOnClass(KafkaTemplate.class)
public class KafkaTestAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public KafkaTestUtils kafkaTestUtils(ConsumerFactory<String, String> consumerFactory) {
return new KafkaTestUtils(consumerFactory);
}
}
// 注册到 META-INF/spring/org.springframework.boot.test.autoconfigure.core.AutoConfigureImportFilter
// 或 META-INF/spring.factories
// 使用
@KafkaSliceTest
class OrderEventConsumerTest {
@Autowired
private KafkaTestUtils kafkaTestUtils;
@Test
void shouldConsumeOrderEvent() {
kafkaTestUtils.send("order-events", new OrderCreatedEvent("ORD-1", 100.0));
// 验证消费结果...
}
}
12.3 上下文缓存优化
Spring Test框架会缓存ApplicationContext,相同配置的测试类共享上下文:
java
// 这两个测试类共享同一个上下文(配置完全相同)
@SpringBootTest(properties = "app.mode=test")
class ServiceATest { }
@SpringBootTest(properties = "app.mode=test")
class ServiceBTest { }
// 这个测试类会创建新上下文(配置不同)
@SpringBootTest(properties = "app.mode=production")
class ServiceCTest { }
注意事项:
@DirtiesContext会强制销毁并重建上下文,慎用@MockBean会改变上下文配置,导致缓存失效- 尽量统一测试配置,最大化上下文复用
十三、CI/CD中的集成测试
13.1 Maven Failsafe Plugin配置
xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<!-- 集成测试命名约定:*IT.java, *IntegrationTest.java -->
<includes>
<include>**/*IT.java</include>
<include>**/*IntegrationTest.java</include>
</includes>
<!-- 并行执行 -->
<parallel>classes</parallel>
<threadCount>4</threadCount>
<!-- 失败后继续执行其他测试 -->
<testFailureIgnore>false</testFailureIgnore>
<!-- 环境变量 -->
<environmentVariables>
<TESTCONTAINERS_RYUK_DISABLED>true</TESTCONTAINERS_RYUK_DISABLED>
</environmentVariables>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
13.2 GitHub Actions配置
yaml
# .github/workflows/integration-tests.yml
name: Integration Tests
on:
pull_request:
branches: [main, develop]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Run Unit Tests
run: mvn test -pl '!integration-tests' --batch-mode
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
services:
docker:
image: docker:dind
options: --privileged
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Run Integration Tests
run: mvn verify -Pintegration-test --batch-mode
env:
TESTCONTAINERS_RYUK_DISABLED: "true"
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@v4
with:
name: integration-test-reports
path: target/failsafe-reports/
13.3 GitLab CI配置
yaml
# .gitlab-ci.yml
stages:
- test
- integration-test
unit-test:
stage: test
image: maven:3.9-eclipse-temurin-21
script:
- mvn test --batch-mode
cache:
paths:
- .m2/repository
integration-test:
stage: integration-test
image: maven:3.9-eclipse-temurin-21
services:
- docker:24-dind
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
TESTCONTAINERS_RYUK_DISABLED: "true"
TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: /var/run/docker.sock
script:
- mvn verify -Pintegration-test --batch-mode
artifacts:
when: always
reports:
junit: target/failsafe-reports/*.xml
13.4 测试执行优化
java
// 使用JUnit 5并行执行
// junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=2
十四、最佳实践
14.1 测试命名规范
java
// 推荐:should_期望行为_when_条件
@Test
void shouldReturn404_whenOrderDoesNotExist() { }
@Test
void shouldRollbackTransaction_whenPaymentFails() { }
@Test
void shouldRetryThreeTimes_whenExternalServiceUnavailable() { }
// 使用 @DisplayName 提供更可读的描述
@Test
@DisplayName("当库存不足时,下单应返回409冲突")
void shouldReturnConflict_whenStockInsufficient() { }
14.2 测试隔离性
java
@SpringBootTest
@Testcontainers
class IsolatedOrderTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void cleanDatabase() {
orderRepository.deleteAll(); // 每个测试前清理
}
// 避免测试间依赖:每个测试独立准备数据
@Test
void testA() {
orderRepository.save(new Order("ORD-A"));
assertEquals(1, orderRepository.count());
}
@Test
void testB() {
// 不依赖testA的数据
orderRepository.save(new Order("ORD-B"));
assertEquals(1, orderRepository.count()); // 仍然是1,因为BeforeEach清理了
}
}
14.3 异步测试处理
java
@SpringBootTest
class AsyncIntegrationTest {
@Autowired
private AsyncNotificationService notificationService;
@Test
void shouldSendNotificationAsynchronously() {
notificationService.sendAsync("user@example.com", "Order Confirmed");
// 使用Awaitility等待异步完成
await()
.atMost(10, TimeUnit.SECONDS)
.pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
NotificationRecord record = notificationRepository
.findByEmail("user@example.com");
assertNotNull(record);
assertEquals("SENT", record.getStatus());
});
}
}
14.4 避免常见陷阱
java
// 陷阱1:过度使用 @DirtiesContext(极慢)
// 错误做法
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class BadTest { }
// 正确做法:使用事务回滚或手动清理
@SpringBootTest
@Transactional // 每个测试方法后自动回滚
class GoodTest { }
// 陷阱2:测试中硬编码端口
// 错误做法
String url = "http://localhost:8080/api/orders";
// 正确做法
@SpringBootTest(webEnvironment = RANDOM_PORT)
class ApiTest {
@LocalServerPort
private int port;
// 或使用 TestRestTemplate(自动配置了正确端口)
@Autowired
private TestRestTemplate restTemplate;
}
// 陷阱3:依赖外部网络/服务
// 错误做法:测试中调用真实第三方API
@Test
void badTest() {
String result = restTemplate.getForObject("https://api.real-service.com/data", String.class);
}
// 正确做法:使用WireMock或Testcontainers
@Test
void goodTest() {
stubFor(get("/data").willReturn(aResponse().withBody("{\"key\":\"value\"}")));
String result = restTemplate.getForObject(wireMockUrl + "/data", String.class);
}
14.5 测试配置Profile
yaml
# src/test/resources/application-test.yml
spring:
datasource:
hikari:
maximum-pool-size: 5
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
flyway:
enabled: true
kafka:
consumer:
auto-offset-reset: earliest
logging:
level:
org.hibernate.SQL: DEBUG
org.testcontainers: INFO
com.github.dockerjava: WARN
app:
feature:
new-checkout: true
external:
payment-timeout: 5s
14.6 性能优化清单
| 优化手段 | 效果 | 实现方式 |
|---|---|---|
| 容器复用 | 减少容器启动时间 | Singleton Pattern / withReuse(true) |
| 上下文缓存 | 避免重复加载Spring上下文 | 统一测试配置,减少@MockBean |
| 并行执行 | 缩短总执行时间 | JUnit 5 parallel + Maven parallel |
| 分层执行 | 快速反馈 | 单元测试先行,集成测试后行 |
| 镜像预拉取 | 避免CI中重复下载 | CI缓存层 / 私有Registry |
| 轻量镜像 | 减少容器启动时间 | 使用alpine版本(postgres:16-alpine) |
| 禁用Ryuk | 减少额外容器开销 | TESTCONTAINERS_RYUK_DISABLED=true |
14.7 集成测试检查清单
- 测试是否覆盖了核心业务链路(下单→支付→发货)
- 数据库测试是否使用了与生产一致的数据库引擎
- 外部服务是否通过WireMock/契约测试隔离
- 测试数据是否独立,无测试间依赖
- 异步操作是否有超时等待机制
- CI中是否有Docker环境支持
- 测试执行时间是否在可接受范围内(<10分钟)
- 失败测试是否有清晰的错误信息
- 是否覆盖了异常路径(超时、重试、降级)
- 敏感配置是否通过环境变量/Profile隔离
