微服务测试策略
测试金字塔
测试层次
/\
/ \
/ E2E \ 端到端测试(少量)
/------\
/ \
/ Integration\ 集成测试(适量)
/--------------\
/ \
/ Unit Test \ 单元测试(大量)
/--------------------\
测试类型
单元测试(Unit Test):
- 测试单个类或方法
- 不依赖外部服务
- 执行速度快
- 覆盖率要求高
集成测试(Integration Test):
- 测试多个组件协作
- 依赖外部服务(数据库、消息队列等)
- 执行速度中等
- 验证接口契约
端到端测试(E2E Test):
- 测试完整业务流程
- 模拟真实用户场景
- 执行速度慢
- 维护成本高
契约测试(Contract Test):
- 测试服务间接口契约
- 保证服务兼容性
- 独立于实现
- 支持消费者驱动
单元测试
1. JUnit 5
基础测试:
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
@DisplayName("测试根据 ID 查询用户")
public void testFindById() {
// 准备数据
User mockUser = new User(1L, "test", "test@example.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));
// 执行测试
User user = userService.findById(1L);
// 验证结果
assertNotNull(user);
assertEquals("test", user.getUsername());
verify(userRepository).findById(1L);
}
@Test
@DisplayName("测试查询不存在的用户")
public void testFindByIdNotFound() {
when(userRepository.findById(999L)).thenReturn(Optional.empty());
assertThrows(ResourceNotFoundException.class, () -> {
userService.findById(999L);
});
}
}
参数化测试:
@ParameterizedTest
@ValueSource(strings = {"user1", "user2", "user3"})
public void testFindUsers(String username) {
User user = userService.findByUsername(username);
assertNotNull(user);
}
@ParameterizedTest
@CsvSource({
"user1, true",
"user2, false",
"user3, true"
})
public void testUserStatus(String username, boolean expected) {
User user = userService.findByUsername(username);
assertEquals(expected, user.isActive());
}
2. Mockito
Mock 对象:
@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentClient paymentClient;
@InjectMocks
private OrderService orderService;
@Test
public void testCreateOrder() {
// Mock 返回值
Order mockOrder = new Order();
mockOrder.setId(1L);
when(orderRepository.save(any(Order.class))).thenReturn(mockOrder);
when(paymentClient.pay(any())).thenReturn(Result.success());
// 执行
Order order = orderService.createOrder(new CreateOrderRequest());
// 验证
assertNotNull(order);
verify(orderRepository).save(any(Order.class));
verify(paymentClient).pay(any());
}
}
Mock 异常:
@Test
public void testCreateOrderFail() {
when(orderRepository.save(any())).thenThrow(new RuntimeException("DB error"));
assertThrows(SystemException.class, () -> {
orderService.createOrder(new CreateOrderRequest());
});
}
ArgumentCaptor:
@Test
public void testSaveUser() {
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
userService.createUser("test", "test@example.com");
verify(userRepository).save(captor.capture());
User savedUser = captor.getValue();
assertEquals("test", savedUser.getUsername());
assertEquals("test@example.com", savedUser.getEmail());
}
3. 测试工具
AssertJ:
@Test
public void testUser() {
User user = new User(1L, "test", "test@example.com");
assertThat(user)
.isNotNull()
.hasFieldOrPropertyWithValue("id", 1L)
.hasFieldOrPropertyWithValue("username", "test")
.hasFieldOrPropertyWithValue("email", "test@example.com");
assertThat(user.getUsername())
.isNotBlank()
.hasSize(4)
.contains("test");
}
WireMock:
@ExtendWith(WireMockExtension.class)
public class PaymentClientTest {
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.port(8080)
.build();
@Test
public void testPay() {
// Mock HTTP 响应
stubFor(post(urlEqualTo("/pay"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"code\":200,\"message\":\"success\"}")));
// 调用
Result result = paymentClient.pay(new PayRequest());
// 验证
assertTrue(result.isSuccess());
verify(postRequestedFor(urlEqualTo("/pay")));
}
}
集成测试
1. Spring Boot Test
基础集成测试:
@SpringBootTest
@AutoConfigureMockMvc
public class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
public void testGetUser() throws Exception {
mockMvc.perform(get("/api/users/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.username").value("test"));
}
@Test
public void testCreateUser() throws Exception {
CreateUserRequest request = new CreateUserRequest("test", "test@example.com");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.username").value("test"));
}
}
2. Testcontainers
数据库测试:
@SpringBootTest
@Testcontainers
public class UserRepositoryIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
public void testSave() {
User user = new User("test", "test@example.com");
User saved = userRepository.save(user);
assertNotNull(saved.getId());
assertEquals("test", saved.getUsername());
}
}
Redis 测试:
@Testcontainers
public class RedisIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7")
.withExposedPorts(6379);
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.redis.host", redis::getHost);
registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Test
public void testRedis() {
redisTemplate.opsForValue().set("key", "value");
String value = redisTemplate.opsForValue().get("key");
assertEquals("value", value);
}
}
Kafka 测试:
@Testcontainers
public class KafkaIntegrationTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.4.0")
);
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Test
public void testSend() {
kafkaTemplate.send("test-topic", "key", "value");
// 验证消息发送
ConsumerRecords<String, String> records = consumeMessages("test-topic");
assertThat(records.count()).isEqualTo(1);
}
}
3. 服务间调用测试
Feign 客户端测试:
@SpringBootTest
@Import(TestFeignConfig.class)
public class OrderClientIntegrationTest {
@Autowired
private OrderClient orderClient;
@Test
public void testGetOrder() {
Result<Order> result = orderClient.getOrder(1L);
assertTrue(result.isSuccess());
assertNotNull(result.getData());
}
}
@Configuration
public class TestFeignConfig {
@Bean
@Primary
public OrderClient orderClient() {
return id -> Result.success(new Order(id, "test"));
}
}
契约测试
1. Spring Cloud Contract
生产者配置:
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>4.0.0</version>
<extensions>true</extensions>
<configuration>
<baseClassForTests>com.example.BaseContractTest</baseClassForTests>
</configuration>
</plugin>
定义契约:
// src/test/resources/contracts/user-service/get-user.groovy
package contracts.user.service
org.springframework.cloud.contract.spec.Contract.make {
description "should return user by id"
request {
method GET()
url '/api/users/1'
}
response {
status 200
headers {
header('Content-Type': 'application/json')
}
body(
id: 1,
username: 'test',
email: 'test@example.com'
)
}
}
生成测试:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class UserContractTest extends BaseContractTest {
@Autowired
private UserRepository userRepository;
@Before
public void setup() {
userRepository.save(new User(1L, "test", "test@example.com"));
}
}
2. 消费者驱动契约
消费者配置:
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>4.0.0</version>
<extensions>true</extensions>
<configuration>
<contractsMode>REMOTE</contractsMode>
<contractRepository>
<repositoryUrl>git://git@github.com:user-service/contracts.git</repositoryUrl>
</contractRepository>
</configuration>
</plugin>
使用契约:
@SpringBootTest
@AutoConfigureStubRunner(
ids = "com.example:user-service:+:stubs:8080",
stubsMode = StubsMode.REMOTE
)
public class OrderServiceContractTest {
@Autowired
private OrderService orderService;
@Test
public void testGetUser() {
User user = orderService.getUser(1L);
assertNotNull(user);
assertEquals("test", user.getUsername());
}
}
端到端测试
1. 测试环境
Docker Compose:
version: '3.8'
services:
gateway:
image: gateway:latest
ports:
- "8080:8080"
user-service:
image: user-service:latest
depends_on:
- mysql
- redis
order-service:
image: order-service:latest
depends_on:
- mysql
- user-service
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
redis:
image: redis:7
2. E2E 测试框架
Cucumber:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@CucumberContextConfiguration
public class CucumberIntegrationTest {
@Autowired
private TestContext testContext;
}
Gherkin 特性文件:
Feature: 订单创建
Scenario: 成功创建订单
Given 用户 "test" 已登录
And 用户有足够余额
And 商品有库存
When 用户创建订单
Then 订单创建成功
And 用户余额扣减
And 商品库存扣减
Scenario: 余额不足创建订单失败
Given 用户 "test" 已登录
And 用户余额不足
When 用户创建订单
Then 订单创建失败
And 返回错误信息 "余额不足"
步骤定义:
public class OrderStepDefinitions {
@Autowired
private TestRestTemplate restTemplate;
@Given("用户 {string} 已登录")
public void userLoggedIn(String username) {
// 登录逻辑
}
@When("用户创建订单")
public void createOrder() {
CreateOrderRequest request = new CreateOrderRequest();
restTemplate.postForEntity("/api/orders", request, Void.class);
}
@Then("订单创建成功")
public void orderCreatedSuccessfully() {
// 验证订单创建
}
}
测试覆盖率
1. JaCoCo 配置
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.8</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
<configuration>
<excludes>
<exclude>**/entity/**</exclude>
<exclude>**/dto/**</exclude>
<exclude>**/config/**</exclude>
</excludes>
</configuration>
</plugin>
2. 覆盖率要求
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
CI/CD 集成
1. GitHub Actions
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Run tests
run: mvn clean test
- name: Run integration tests
run: mvn verify
- name: Upload coverage
uses: codecov/codecov-action@v3
2. 质量门禁
name: Quality Gate
on:
pull_request:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run tests
run: mvn clean test
- name: Check coverage
run: |
coverage=$(cat target/site/jacoco/jacoco.xml | grep -oP 'covered="[^"]*"' | head -1 | grep -oP '\d+\.\d+')
if (( $(echo "$coverage < 80" | bc -l) )); then
echo "Coverage is below 80%"
exit 1
fi
最佳实践
1. 测试命名
// 好的命名
@Test
@DisplayName("当用户不存在时抛出异常")
public void testFindByIdNotFound() { }
// 不好的命名
@Test
public void test1() { }
2. 测试独立性
// 每个测试独立
@Test
public void testCreateUser() { }
@Test
public void testDeleteUser() { }
// 避免测试依赖
@Test
@DependsOnMethods("testCreateUser") // 不推荐
public void testDeleteUser() { }
3. 测试数据
// 使用测试数据工厂
@Test
public void testUser() {
User user = UserFactory.createTestUser();
// ...
}
// 避免硬编码
@Test
public void testUser() {
User user = new User(1L, "test", "test@example.com",
"password", true, LocalDateTime.now());
// ...
}
4. 测试清理
@AfterEach
public void tearDown() {
// 清理测试数据
userRepository.deleteAll();
}
// 或使用事务回滚
@Transactional
@Test
public void testUser() {
// 测试完成后自动回滚
}
总结
微服务测试是保证系统质量的重要手段,需要建立完善的测试体系。
单元测试是基础,集成测试验证组件协作,契约测试保证服务兼容性,端到端测试验证完整流程。
在 CI/CD 流程中集成自动化测试,建立质量门禁,确保代码质量。