14. Java 测试策略与工程化

Java 测试金字塔实战:JUnit 5 参数化/嵌套/动态测试、Mockito 模拟与验证、Testcontainers 集成测试、Pact 契约测试与 CI/CD 测试流水线设计

测试是软件质量的最后一道防线。JUnit 5 提供了灵活的测试扩展模型,Mockito 解耦外部依赖,Testcontainers 在真实环境中运行集成测试,Pact 保障微服务间的契约兼容性。本文以"测试金字塔"为指导,构建从单元到 E2E 的完整测试体系。

1. 测试金字塔

        /\
       /  \      E2E 测试(少而精)
      /----\     覆盖核心用户旅程
     /      \
    /--------\   集成测试(中等数量)
   /          \  数据库/消息队列/外部服务
  /------------\ 单元测试(大量)
 /              \ 业务逻辑、算法、工具类
/________________\
层级比例速度稳定性工具
单元测试70%< 1s最高JUnit 5 + Mockito
集成测试20%秒级Testcontainers
E2E 测试10%分钟级Selenium/RestAssured

2. JUnit 5 进阶

2.1 基础结构

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    
    @Mock
    private OrderRepository orderRepository;
    
    @Mock
    private PaymentClient paymentClient;
    
    @InjectMocks
    private OrderService orderService;
    
    @BeforeEach
    void setUp() {
        // MockitoExtension 自动初始化 @Mock 对象
    }
    
    @Test
    @DisplayName("创建订单:正常流程")
    void createOrder_success() {
        // Given
        OrderRequest request = new OrderRequest(1L, List.of(
            new OrderItem("SKU001", 2, new BigDecimal("99.99"))
        ));
        when(paymentClient.charge(any())).thenReturn(
            new PaymentResult("txn-123", PaymentStatus.SUCCESS));
        when(orderRepository.save(any())).thenAnswer(inv -> {
            Order order = inv.getArgument(0);
            order.setId(1L);
            return order;
        });
        
        // When
        Order result = orderService.createOrder(request);
        
        // Then
        assertThat(result).isNotNull();
        assertThat(result.getId()).isEqualTo(1L);
        assertThat(result.getStatus()).isEqualTo(OrderStatus.PAID);
        verify(paymentClient).charge(any(ChargeRequest.class));
        verify(orderRepository).save(any(Order.class));
    }
}

2.2 参数化测试

@ParameterizedTest
@CsvSource({
    "100, 0.1, 10",      // 原价 100,折扣 10%,应收 10
    "99.99, 0.2, 19.998", // 原价 99.99,折扣 20%
    "0, 0.5, 0",         // 边界:0 元
    "100, 0, 0"          // 边界:无折扣
})
@DisplayName("计算折扣金额")
void calculateDiscount(BigDecimal price, BigDecimal rate, BigDecimal expected) {
    BigDecimal discount = PriceCalculator.calculateDiscount(price, rate);
    assertThat(discount).isEqualByComparingTo(expected);
}

@ParameterizedTest
@MethodSource("provideInvalidOrders")
@DisplayName("创建订单参数校验")
void createOrder_invalidParams_throwsException(OrderRequest request, 
                                                 Class<Exception> expected) {
    assertThatThrownBy(() -> orderService.createOrder(request))
        .isInstanceOf(expected);
}

static Stream<Arguments> provideInvalidOrders() {
    return Stream.of(
        Arguments.of(null, IllegalArgumentException.class),
        Arguments.of(new OrderRequest(null, List.of()), ValidationException.class),
        Arguments.of(new OrderRequest(1L, List.of()), ValidationException.class)
    );
}

@ParameterizedTest
@ValueSource(strings = {"pending", "paid", "shipped", "completed"})
@DisplayName("订单状态转换有效")
void orderStatusTransition_valid(String status) {
    assertThat(OrderStatus.fromString(status)).isNotNull();
}

2.3 嵌套测试与动态测试

@DisplayName("购物车服务")
class CartServiceTest {
    
    @Nested
    @DisplayName("添加商品")
    class AddItem {
        
        @Test
        @DisplayName("新商品直接添加")
        void addNewItem() { ... }
        
        @Test
        @DisplayName("现有商品增加数量")
        void addExistingItem() { ... }
        
        @Test
        @DisplayName("超过库存限制抛出异常")
        void exceedsStockLimit() { ... }
    }
    
    @Nested
    @DisplayName("计算总价")
    class CalculateTotal {
        @Test
        @DisplayName("空购物车为0")
        void emptyCart() { ... }
        
        @Test
        @DisplayName("多商品累计")
        void multipleItems() { ... }
    }
    
    @TestFactory
    @DisplayName("动态生成折扣场景测试")
    Stream<DynamicTest> discountScenarios() {
        return Stream.of(
            Map.of("会员等级", "普通", "折扣", 1.0),
            Map.of("会员等级", "银卡", "折扣", 0.95),
            Map.of("会员等级", "金卡", "折扣", 0.90),
            Map.of("会员等级", "钻石", "折扣", 0.85)
        ).map(data -> DynamicTest.dynamicTest(
            data.get("会员等级") + "会员折扣",
            () -> assertDiscount(data.get("会员等级"), (Double) data.get("折扣"))
        ));
    }
}

2.4 条件执行与生命周期

@Test
@EnabledOnOs(OS.LINUX)
@EnabledIfSystemProperty(named = "env", matches = "ci")
void nativeIntegrationTest() { ... }

@Test
@DisabledIf("#systemProperties.get('skip.slow') == 'true'")
void slowTest() { ... }

@Test
@Timeout(value = 200, unit = TimeUnit.MILLISECONDS)
void performanceTest() { ... }

@RepeatedTest(5)
void flakyTestRetry() { ... }

3. Mockito 深度使用

3.1 模拟行为

// 基础 stub
when(repository.findById(1L)).thenReturn(Optional.of(order));

// 连续调用返回不同值
when(queue.poll()).thenReturn("msg1", "msg2", null);

// 异常抛出
when(service.process(any())).thenThrow(new ServiceException("error"));

// 参数匹配器
when(service.findByName(startsWith("张"))).thenReturn(List.of(user1, user2));
when(service.findByAge(intThat(age -> age >= 18 && age <= 60)))
    .thenReturn(adultUsers);

// Answer 应答
when(idGenerator.next()).thenAnswer(inv -> UUID.randomUUID().toString());

// spy 部分模拟
List<String> spyList = spy(new ArrayList<>());
when(spyList.size()).thenReturn(100); // size() 被模拟
spyList.add("real"); // add() 调用真实方法

3.2 验证交互

// 验证调用次数
verify(repository, times(1)).save(any());
verify(notificationService, never()).send(any());
verify(cache, atLeast(2)).get(any());
verify(cache, atMost(5)).put(any(), any());

// 验证调用顺序
InOrder inOrder = inOrder(paymentService, orderRepository);
inOrder.verify(paymentService).charge(any());
inOrder.verify(orderRepository).save(any());

// 验证无多余交互
verifyNoMoreInteractions(paymentService);
verifyNoInteractions(notificationService);

// 捕获参数
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
verify(repository).save(captor.capture());
Order saved = captor.getValue();
assertThat(saved.getTotal()).isEqualByComparingTo(new BigDecimal("199.98"));

3.3 静态方法与构造器模拟(Mockito-inline)

// build.gradle: testImplementation 'org.mockito:mockito-inline'

@Test
void staticMethod() {
    try (MockedStatic<LocalDateTime> mocked = mockStatic(LocalDateTime.class)) {
        mocked.when(LocalDateTime::now)
            .thenReturn(LocalDateTime.of(2024, 1, 1, 0, 0));
        
        String result = service.getGreeting(); // 依赖 LocalDateTime.now()
        assertThat(result).isEqualTo("新年快乐!");
    }
}

4. 集成测试与 Testcontainers

4.1 数据库集成测试

@Testcontainers
@SpringBootTest
class OrderRepositoryTest {
    
    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("test")
        .withUsername("test")
        .withPassword("test");
    
    @DynamicPropertySource
    static void configure(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 OrderRepository repository;
    
    @Test
    @DisplayName("复杂查询:按状态和日期范围")
    void findByStatusAndDateRange() {
        // 真实 MySQL 中执行测试
        LocalDate start = LocalDate.of(2024, 1, 1);
        LocalDate end = LocalDate.of(2024, 1, 31);
        
        List<Order> orders = repository.findByStatusAndCreatedAtBetween(
            OrderStatus.PAID, start.atStartOfDay(), end.atTime(LocalTime.MAX));
        
        assertThat(orders).hasSize(2);
    }
}

4.2 Redis + Kafka 集成测试

@Testcontainers
class CacheIntegrationTest {
    
    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379);
    
    @Container
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
    
    @DynamicPropertySource
    static void configure(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", redis::getFirstMappedPort);
        registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
    }
    
    @Autowired
    private CacheManager cacheManager;
    
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
    
    @Test
    void cacheAndKafkaIntegration() {
        // 写入缓存
        Cache cache = cacheManager.getCache("orders");
        cache.put("order:1", new Order(1L, "PAID"));
        
        // 发送 Kafka 消息
        kafkaTemplate.send("order-events", "order:1", "CREATED");
        
        // 验证消费
        // ...
    }
}

4.3 共享容器优化启动速度

@Testcontainers
public abstract class AbstractIntegrationTest {
    
    @Container
    static final PostgreSQLContainer<?> postgres = 
        new PostgreSQLContainer<>("postgres:15-alpine")
            .withReuse(true); // 复用容器(Testcontainers 1.19+)
    
    @DynamicPropertySource
    static void configure(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
}

// 所有集成测试继承
class UserRepositoryIT extends AbstractIntegrationTest { ... }
class OrderRepositoryIT extends AbstractIntegrationTest { ... }

5. 契约测试(Pact)

5.1 Consumer 端契约定义

@PactTestFor(providerName = "user-service")
class UserServicePactTest {
    
    @Pact(consumer = "order-service")
    RequestResponsePact getUserPact(PactDslWithProvider builder) {
        return builder
            .given("user exists")
            .uponReceiving("fetch user by id")
            .path("/api/v1/users/1")
            .method("GET")
            .headers("Authorization", "Bearer token123")
            .willRespondWith()
            .status(200)
            .body(new PactDslJsonBody()
                .integerType("id", 1)
                .stringType("name", "张三")
                .stringType("email", "zhangsan@example.com")
                .stringType("phone", "13800138000"))
            .toPact();
    }
    
    @PactTestFor(pactMethod = "getUserPact")
    @Test
    void getUser(MockServer mockServer) {
        UserFeignClient client = Feign.builder()
            .decoder(new JacksonDecoder())
            .target(UserFeignClient.class, mockServer.getUrl());
        
        UserDTO user = client.getUser(1L);
        assertThat(user.getName()).isEqualTo("张三");
    }
}

5.2 Provider 端契约验证

@Provider("user-service")
@PactBroker(host = "pact-broker.company.com")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserServiceProviderPactTest {
    
    @MockBean
    private UserRepository repository;
    
    @State("user exists")
    void userExists() {
        User user = new User(1L, "张三", "zhangsan@example.com", "13800138000");
        when(repository.findById(1L)).thenReturn(Optional.of(user));
    }
    
    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void pactVerificationTestTemplate(PactVerificationContext context) {
        context.verifyInteraction();
    }
}

5.3 CI/CD 中的 Pact 工作流

# .github/workflows/contract-test.yml
name: Contract Tests

on: [push, pull_request]

jobs:
  consumer-contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Consumer Pact Tests
        run: ./gradlew pactTest
      - name: Publish Pact
        run: |
          pact-broker publish build/pacts \
            --broker-base-url ${{ secrets.PACT_BROKER_URL }} \
            --consumer-app-version ${{ github.sha }} \
            --branch ${{ github.ref_name }}

  provider-contract:
    needs: consumer-contract
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Verify Provider
        run: ./gradlew pactVerify \
          -Ppact.provider.branch=${{ github.ref_name }} \
          -Ppact.verifier.publishResults=true

6. E2E 测试与 API 测试

6.1 RestAssured API 测试

@SpringBootTest(webEnvironment = RANDOM_PORT)
class OrderApiE2ETest {
    
    @LocalServerPort
    private int port;
    
    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }
    
    @Test
    void createOrder_fullFlow() {
        String token = obtainAccessToken("user@example.com", "password");
        
        given()
            .contentType(JSON)
            .header("Authorization", "Bearer " + token)
            .body("""
                {
                  "items": [
                    {"sku": "SKU001", "quantity": 2, "price": 99.99}
                  ],
                  "addressId": 1
                }
                """)
        .when()
            .post("/api/v1/orders")
        .then()
            .statusCode(201)
            .body("id", notNullValue())
            .body("status", equalTo("PENDING_PAYMENT"))
            .body("totalAmount", closeTo(199.98f, 0.01f))
            .header("Location", containsString("/orders/"));
    }
    
    @Test
    void createOrder_invalidRequest_returns400() {
        given()
            .contentType(JSON)
            .body("{}")
        .when()
            .post("/api/v1/orders")
        .then()
            .statusCode(400)
            .body("error", equalTo("VALIDATION_ERROR"))
            .body("details", hasSize(greaterThan(0)));
    }
}

6.2 WebTestClient(WebFlux)

@WebFluxTest(OrderController.class)
class OrderControllerTest {
    
    @Autowired
    private WebTestClient client;
    
    @MockBean
    private OrderService orderService;
    
    @Test
    void createOrder() {
        when(orderService.create(any())).thenReturn(Mono.just(new Order(1L)));
        
        client.post().uri("/api/v1/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(new OrderRequest(...))
            .exchange()
            .expectStatus().isCreated()
            .expectBody()
            .jsonPath("$.id").isEqualTo(1)
            .jsonPath("$.status").isEqualTo("PENDING_PAYMENT");
    }
}

7. 测试覆盖率与质量门

7.1 JaCoCo 配置

// build.gradle
plugins {
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.11"
}

test {
    useJUnitPlatform()
    finalizedBy jacocoTestReport
}

jacocoTestReport {
    dependsOn test
    reports {
        xml.required = true
        html.required = true
    }
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                minimum = 0.70 // 行覆盖率门槛 70%
            }
        }
        rule {
            limit {
                counter = 'BRANCH'
                minimum = 0.60 // 分支覆盖率门槛 60%
            }
        }
    }
}

check.dependsOn jacocoTestCoverageVerification

7.2 变异测试(PIT)

plugins {
    id 'info.solidsoft.pitest' version '1.15.0'
}

pitest {
    targetClasses = ['com.example.service.*']
    targetTests = ['com.example.service.*Test']
    threads = 4
    outputFormats = ['XML', 'HTML']
    mutationThreshold = 50 // 变异分数门槛
}

变异测试通过修改代码(如 == 改为 !=)验证测试是否能捕获缺陷,比行覆盖率更能反映测试质量。

8. 测试策略总结

场景测试类型工具关键指标
纯业务逻辑单元测试JUnit 5 + Mockito覆盖率 > 80%
复杂参数校验参数化测试@ParameterizedTest边界值全覆盖
数据访问层集成测试TestcontainersSQL 执行正确
缓存/消息队列集成测试Testcontainers + Kafka消息投递/消费
跨服务调用契约测试Pact契约兼容性
完整用户流程E2E 测试RestAssured核心链路 100%
回归防护变异测试PIT变异分数 > 50%

总结

高质量的测试体系遵循以下原则:

  1. 测试金字塔:单元测试为主,集成测试为辅,E2E 测试覆盖核心路径
  2. Mock 止于边界:只模拟外部依赖,不模拟被测对象内部协作
  3. 真实环境集成:Testcontainers 保证测试环境与生产一致
  4. 契约先行:Consumer-Driven Contract 保护微服务间兼容性
  5. 质量门禁:将覆盖率与变异测试纳入 CI/CD,未达标阻断发布
  6. 测试即文档:清晰的命名(should_xxx_when_yyy)和 Given-When-Then 结构

对于 Spring Boot 项目,推荐依赖组合:JUnit 5 + Mockito + AssertJ + Testcontainers + RestAssured + Pact,满足从单元到契约测试的全栈需求。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java-enterprise」更多文章

  1. 限流算法深度解析:令牌桶、漏桶与滑动窗口计数
  2. Java 代码质量:SonarQube、Checkstyle 与 SpotBugs 工程化实践
  3. Spring IoC 容器与依赖注入原理深度剖析