测试是软件质量的最后一道防线。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 | 边界值全覆盖 |
| 数据访问层 | 集成测试 | Testcontainers | SQL 执行正确 |
| 缓存/消息队列 | 集成测试 | Testcontainers + Kafka | 消息投递/消费 |
| 跨服务调用 | 契约测试 | Pact | 契约兼容性 |
| 完整用户流程 | E2E 测试 | RestAssured | 核心链路 100% |
| 回归防护 | 变异测试 | PIT | 变异分数 > 50% |
总结
高质量的测试体系遵循以下原则:
- 测试金字塔:单元测试为主,集成测试为辅,E2E 测试覆盖核心路径
- Mock 止于边界:只模拟外部依赖,不模拟被测对象内部协作
- 真实环境集成:Testcontainers 保证测试环境与生产一致
- 契约先行:Consumer-Driven Contract 保护微服务间兼容性
- 质量门禁:将覆盖率与变异测试纳入 CI/CD,未达标阻断发布
- 测试即文档:清晰的命名(
should_xxx_when_yyy)和 Given-When-Then 结构
对于 Spring Boot 项目,推荐依赖组合:JUnit 5 + Mockito + AssertJ + Testcontainers + RestAssured + Pact,满足从单元到契约测试的全栈需求。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。