Java 测试金字塔实战——单元/集成/E2E 测试的正确比例与边界
Java 测试金字塔实战——单元/集成/E2E 测试的正确比例与边界
适读人群:不清楚项目里该写多少单元测试、多少集成测试、多少 E2E 测试的工程师 | 阅读时长:约13分钟 | 核心价值:建立清晰的测试分层思维,让每一层测试做正确的事
那个全是 E2E 测试的项目
2020年我接触了一个项目,测试架构让我印象深刻——几乎所有测试都是端到端测试。
他们的 CI 流水线跑一遍要 40 分钟。每次代码合并,大家都要等 40 分钟才知道测试过没过。
测试失败时,要在几十个步骤里找哪一步出了问题,平均定位时间超过 20 分钟。
最致命的是:E2E 测试依赖真实的外部服务(支付网关、短信服务),这些服务偶尔抖动,导致测试随机失败。大家慢慢习惯了看到红色就"再跑一遍",测试失去了真正的保障作用。
这就是"倒金字塔"结构的代价。
测试金字塔是什么
Mike Cohn 在 2009 年提出了"测试金字塔"模型:
/\
/E2E\ 少:数量少,价值高但成本高
/------\
/ 集成 \ 中:适量,验证组件协作
/----------\
/ 单元测试 \ 多:大量,快速、稳定、精确
/______________\单元测试(底层,最多):
- 测试单个类/方法的逻辑
- 不依赖外部系统,全用 Mock
- 运行极快(毫秒级),稳定可靠
- 失败时精确指向问题代码
集成测试(中层,适量):
- 测试多个组件的协作(Controller+Service、Service+Repository)
- 依赖部分真实系统(数据库、缓存,但不依赖第三方服务)
- 运行较慢(秒级),偶有不稳定
- 失败时指向组件协作问题
E2E 测试(顶层,最少):
- 测试完整的业务流程(从 HTTP 请求到数据库)
- 依赖真实的完整系统
- 运行最慢(分钟级),容易不稳定
- 失败时需要大量排查
正确的比例是什么
业界通常说"70% 单元 / 20% 集成 / 10% E2E",但这个比例是对测试时间投入,不是对测试数量。
我的实际建议是按"速度"和"价值"来划分,而不是纠结比例数字:
单元测试写什么:
- 所有包含业务逻辑的方法(分支、计算、判断)
- 所有异常处理路径
- 所有边界条件(null、空集合、边界值)
- 状态机/工作流逻辑
集成测试写什么:
- 数据库 Repository(自定义查询)
- Controller 的请求/响应格式
- 跨 Service 的重要业务流程
- 缓存读写逻辑
E2E 测试写什么:
- 核心业务的完整流程(注册→登录→下单→支付)
- 关键的监管/合规场景
- 跨系统的关键集成点
各层测试的具体实现
单元测试层:精准、快速
// 纯单元测试,不需要 Spring 容器
class OrderPriceCalculatorTest {
private final OrderPriceCalculator calculator = new OrderPriceCalculator();
@Test
void testCalculate_normalUser_noDiscount() {
Order order = Order.builder()
.userId(1L)
.memberLevel(MemberLevel.NORMAL)
.items(List.of(
OrderItem.builder().price(new BigDecimal("100")).quantity(2).build()
))
.build();
BigDecimal finalPrice = calculator.calculate(order);
assertEquals(new BigDecimal("200.00"), finalPrice);
}
@Test
void testCalculate_vipUser_tenPercentDiscount() {
Order order = Order.builder()
.memberLevel(MemberLevel.VIP)
.items(List.of(OrderItem.builder().price(new BigDecimal("100")).quantity(1).build()))
.build();
assertEquals(new BigDecimal("90.00"), calculator.calculate(order));
}
@Test
void testCalculate_withCoupon_stackDiscount() {
Order order = Order.builder()
.memberLevel(MemberLevel.VIP)
.couponDiscount(new BigDecimal("20"))
.items(List.of(OrderItem.builder().price(new BigDecimal("200")).quantity(1).build()))
.build();
// VIP 折后 180,再减 20 元券 = 160
assertEquals(new BigDecimal("160.00"), calculator.calculate(order));
}
@Test
void testCalculate_whenItemsIsEmpty_shouldReturnZero() {
Order order = Order.builder()
.memberLevel(MemberLevel.NORMAL)
.items(Collections.emptyList())
.build();
assertEquals(BigDecimal.ZERO, calculator.calculate(order));
}
}这个测试运行时间:< 10ms。不依赖任何外部资源,任何时候任何地方都能跑。
集成测试层:验证协作
// Controller + Service + Repository 的集成测试
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
class OrderIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@Autowired
private ProductRepository productRepository;
@BeforeEach
void setUp() {
// 准备真实的测试数据
User user = userRepository.save(User.builder()
.username("testuser").memberLevel(MemberLevel.VIP).balance(new BigDecimal("1000")).build());
productRepository.save(Product.builder()
.id(100L).name("测试商品").price(new BigDecimal("200")).stock(10).build());
}
@Test
void testCreateOrder_vipUser_shouldApplyDiscount() {
CreateOrderRequest request = CreateOrderRequest.builder()
.userId(testUser.getId())
.productId(100L)
.quantity(1)
.build();
ResponseEntity<OrderVO> response = restTemplate.postForEntity(
"/api/orders", request, OrderVO.class);
assertEquals(HttpStatus.CREATED, response.getStatusCode());
assertNotNull(response.getBody());
// VIP 折扣 10%
assertEquals(new BigDecimal("180.00"), response.getBody().getFinalAmount());
}
}E2E 测试层:验证完整流程
// E2E 测试:完整的下单支付流程
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderE2ETest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void testCompleteOrderFlow() {
// 步骤1:登录
LoginResponse loginResponse = restTemplate.postForObject(
"/api/auth/login",
new LoginRequest("testuser", "password"),
LoginResponse.class
);
String token = loginResponse.getToken();
// 步骤2:查看商品
ProductVO product = restTemplate.getForObject("/api/products/100", ProductVO.class);
assertNotNull(product);
assertEquals(10, product.getStock());
// 步骤3:创建订单
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
HttpEntity<CreateOrderRequest> createRequest = new HttpEntity<>(
CreateOrderRequest.of(100L, 1), headers
);
OrderVO order = restTemplate.exchange(
"/api/orders", HttpMethod.POST, createRequest, OrderVO.class
).getBody();
assertNotNull(order.getOrderId());
assertEquals(OrderStatus.PENDING, order.getStatus());
// 步骤4:支付
PaymentRequest paymentRequest = PaymentRequest.of(order.getOrderId(), "test-payment-token");
HttpEntity<PaymentRequest> payRequest = new HttpEntity<>(paymentRequest, headers);
PaymentResult payResult = restTemplate.exchange(
"/api/payments", HttpMethod.POST, payRequest, PaymentResult.class
).getBody();
assertTrue(payResult.isSuccess());
// 步骤5:验证最终状态
OrderVO finalOrder = restTemplate.exchange(
"/api/orders/" + order.getOrderId(), HttpMethod.GET,
new HttpEntity<>(headers), OrderVO.class
).getBody();
assertEquals(OrderStatus.PAID, finalOrder.getStatus());
// 验证库存减少了
ProductVO updatedProduct = restTemplate.getForObject("/api/products/100", ProductVO.class);
assertEquals(9, updatedProduct.getStock());
}
}踩坑实录三则
踩坑一:单元测试里 Mock 了太多东西,测试变成了"实现代码的翻译"
现象:
@Test
void testCreateOrder() {
// mock 了 7 个依赖,verify 了 8 个调用
when(userRepo.findById(...)).thenReturn(...);
when(inventoryService.checkStock(...)).thenReturn(true);
when(discountService.calculate(...)).thenReturn(0.9);
when(paymentService.preAuth(...)).thenReturn("txn001");
when(orderRepo.save(...)).thenReturn(savedOrder);
when(notificationService.send(...)).thenReturn(true);
when(auditService.log(...)).thenReturn(null);
// ...
}这个测试完全和实现代码绑定,任何重构都会导致测试失败。
解法:如果一个单元测试需要 Mock 超过 3 个依赖,先思考被测代码是否职责过重。如果是,先重构代码,把职责拆分。如果真的无法拆分,考虑把这个测试提升到集成测试层。
踩坑二:集成测试里事务回滚导致测试验证了错误的结果
现象:
@SpringBootTest
@Transactional // 测试结束后回滚
class PaymentServiceIntegrationTest {
@Test
void testPayment_shouldUpdateOrderStatus() {
paymentService.pay(orderId, paymentToken);
Order order = orderRepo.findById(orderId).orElseThrow();
assertEquals(OrderStatus.PAID, order.getStatus()); // 通过了!
}
}但 paymentService.pay() 内部另起了一个事务(@Transactional(propagation = REQUIRES_NEW)),这个子事务和外层测试事务是独立的。外层事务回滚时,子事务已经提交了。
在这个测试里,外层 @Transactional 和内层 REQUIRES_NEW 实际上验证了不同的事务范围,可能导致测试通过但逻辑有问题。
解法:对涉及事务传播的集成测试,不要用 @Transactional 回滚,而是在 @AfterEach 里手动清理数据:
@AfterEach
void cleanup() {
orderRepository.deleteAll();
paymentRepository.deleteAll();
}踩坑三:E2E 测试依赖第三方服务,随机失败
现象:E2E 测试里调用了真实的短信服务 API,每天有 1-2 次因为短信服务超时而失败。CI 变得不可信,大家开始忽略失败。
解法:E2E 测试的外部依赖(支付网关、短信、邮件等)必须 Mock 掉,或者使用 WireMock 模拟:
@SpringBootTest
@AutoConfigureWireMock(port = 8089)
class OrderE2ETest {
@BeforeEach
void setUpExternalServices() {
// 模拟短信服务
WireMock.stubFor(
WireMock.post(WireMock.urlEqualTo("/sms/send"))
.willReturn(WireMock.aResponse()
.withStatus(200)
.withBody("{\"success\": true}"))
);
}
}金字塔的实际落地策略
从零开始建测试体系,我推荐这个顺序:
- 先从单元测试开始:选最核心的业务逻辑(价格计算、权限校验、状态流转),把这些方法的单元测试写完整。
- 再加 Repository 测试:把所有自定义
@Query都用@DataJpaTest覆盖。 - 加 Controller 测试:核心接口的请求/响应格式用
@WebMvcTest+ MockMvc 覆盖。 - 最后加少量 E2E:选 2-3 个最核心的业务流程做 E2E 测试。
不要试图一步到位,不要追求覆盖率数字,先把最有价值的测试写出来。
