Skip to content
清晨的一缕阳光
返回

微服务测试策略

微服务测试策略

测试金字塔

测试层次

           /\
          /  \
         / 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 流程中集成自动化测试,建立质量门禁,确保代码质量。


分享这篇文章到:

上一篇文章
Spring Boot Micrometer 指标采集
下一篇文章
Spring Boot 结构化日志与 ELK