Java 异常测试深度实战——assertThrows、异常链验证、自定义断言
Java 异常测试深度实战——assertThrows、异常链验证、自定义断言
适读人群:测试里只会
try-catch验证异常、或者不知道怎么验证异常信息、异常链的工程师 | 阅读时长:约12分钟 | 核心价值:掌握完整的异常测试技术,让异常处理代码也有可靠保障
那个被忽视的异常测试
我做过很多项目的代码 Review,有一个现象很普遍:业务逻辑的测试写得挺详细,但异常路径的测试几乎是空的。
团队觉得"异常嘛,就是抛出去的,不用怎么测"。
然后有一天,用户调用接口传了一个非法参数,系统抛出了异常,但异常被全局处理器捕获,返回了一个完全没有意义的错误信息:"系统内部错误,请稍后重试"。
前端工程师和用户完全不知道为什么失败,只能反复重试,最后打客服电话。客服也搞不清楚,升级到技术支持,技术支持查了半天日志才找到原因——用户传的日期格式不对。
如果我们的测试验证了异常信息的内容,这个问题在开发阶段就能发现。
assertThrows:基础但重要
JUnit 5 的 assertThrows 替代了 JUnit 4 的 @Test(expected = ...) 和 ExpectedException。
@Test
void testTransfer_whenBalanceInsufficient_shouldThrowException() {
Account fromAccount = Account.builder()
.id(1L).balance(new BigDecimal("100")).build();
Account toAccount = Account.builder()
.id(2L).balance(BigDecimal.ZERO).build();
// 基础用法:验证抛出了正确的异常类型
assertThrows(InsufficientBalanceException.class,
() -> transferService.transfer(fromAccount, toAccount, new BigDecimal("200")));
}
@Test
void testTransfer_shouldReturnExceptionWithCorrectInfo() {
Account fromAccount = Account.builder()
.id(1L).balance(new BigDecimal("100")).build();
// 进阶用法:捕获异常对象,做详细验证
InsufficientBalanceException exception = assertThrows(
InsufficientBalanceException.class,
() -> transferService.transfer(fromAccount, toAccount, new BigDecimal("200"))
);
// 验证异常信息
assertEquals("ACC001", exception.getAccountId());
assertEquals(0, new BigDecimal("100").compareTo(exception.getCurrentBalance()));
assertEquals(0, new BigDecimal("200").compareTo(exception.getRequiredAmount()));
assertTrue(exception.getMessage().contains("余额不足"),
"异常信息应该包含'余额不足',实际:" + exception.getMessage());
}验证异常不被抛出
@Test
void testTransfer_whenBalanceSufficient_shouldNotThrow() {
Account fromAccount = Account.builder()
.id(1L).balance(new BigDecimal("1000")).build();
// assertDoesNotThrow:验证不抛出异常
assertDoesNotThrow(
() -> transferService.transfer(fromAccount, toAccount, new BigDecimal("100"))
);
}异常链验证:cause 的重要性
异常链(exception chain)在复杂系统里非常常见,特别是跨层调用时:底层抛了一个数据库异常,上层把它包装成业务异常,再抛出。
测试里不仅要验证外层异常,还要验证 cause(根因):
@Test
void testUserService_whenDatabaseDown_shouldWrapException() {
// 模拟数据库异常
when(userRepository.findById(1L))
.thenThrow(new DataAccessException("Connection timeout") {});
ServiceException serviceException = assertThrows(
ServiceException.class,
() -> userService.getUser(1L)
);
// 验证外层异常
assertEquals("USER_SERVICE_ERROR", serviceException.getErrorCode());
assertTrue(serviceException.getMessage().contains("获取用户失败"));
// 验证异常链:cause 应该是原始的数据库异常
assertNotNull(serviceException.getCause(),
"ServiceException 应该包含原始异常作为 cause");
assertInstanceOf(DataAccessException.class, serviceException.getCause(),
"cause 应该是 DataAccessException");
}
@Test
void testDeepExceptionChain() {
// 验证多层异常链
when(paymentGateway.charge(any())).thenThrow(new NetworkException("连接超时"));
BusinessException businessException = assertThrows(
BusinessException.class,
() -> paymentService.processPayment(paymentRequest)
);
// 验证第一层 cause
assertInstanceOf(PaymentFailedException.class, businessException.getCause());
// 验证第二层 cause(根因)
Throwable rootCause = businessException.getCause().getCause();
assertNotNull(rootCause);
assertInstanceOf(NetworkException.class, rootCause);
assertTrue(rootCause.getMessage().contains("连接超时"));
}使用 AssertJ 的异常断言
AssertJ 提供了更流畅的异常断言语法:
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
@Test
void testWithAssertJ_thatThrownBy() {
assertThatThrownBy(() -> orderService.createOrder(invalidRequest))
.isInstanceOf(OrderValidationException.class)
.hasMessageContaining("商品ID不能为空")
.hasFieldOrPropertyWithValue("errorCode", "INVALID_PRODUCT_ID");
}
@Test
void testWithAssertJ_assertThatExceptionOfType() {
assertThatExceptionOfType(PaymentException.class)
.isThrownBy(() -> paymentService.pay(orderId, invalidToken))
.withMessageMatching(".*支付.*失败.*")
.withCauseInstanceOf(TokenExpiredException.class);
}
@Test
void testWithAssertJ_catchThrowable() {
// catchThrowable 不指定类型,验证任意异常
Throwable thrown = catchThrowable(() -> service.doSomething(badInput));
assertThat(thrown)
.isNotNull()
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("输入参数不合法:" + badInput);
}
@Test
void testWithAssertJ_catchThrowableOfType() {
// catchThrowableOfType 指定类型
InvalidOrderException exception =
catchThrowableOfType(() -> orderService.validate(invalidOrder),
InvalidOrderException.class);
assertThat(exception).isNotNull();
assertThat(exception.getViolations()).hasSize(3);
assertThat(exception.getViolations()).extracting("field")
.containsExactlyInAnyOrder("userId", "productId", "amount");
}自定义异常断言
当项目里有复杂的业务异常,验证逻辑重复时,可以写自定义断言:
// 自定义断言类,继承 AbstractAssert
public class BusinessExceptionAssert extends AbstractAssert<BusinessExceptionAssert, BusinessException> {
public BusinessExceptionAssert(BusinessException actual) {
super(actual, BusinessExceptionAssert.class);
}
public static BusinessExceptionAssert assertThat(BusinessException exception) {
return new BusinessExceptionAssert(exception);
}
public BusinessExceptionAssert hasErrorCode(String errorCode) {
isNotNull();
if (!errorCode.equals(actual.getErrorCode())) {
failWithMessage("期望 errorCode 是 <%s>,实际是 <%s>",
errorCode, actual.getErrorCode());
}
return this;
}
public BusinessExceptionAssert hasHttpStatus(int statusCode) {
isNotNull();
if (actual.getHttpStatus() != statusCode) {
failWithMessage("期望 HTTP 状态码是 <%d>,实际是 <%d>",
statusCode, actual.getHttpStatus());
}
return this;
}
public BusinessExceptionAssert hasFieldError(String field, String message) {
isNotNull();
boolean found = actual.getFieldErrors().stream()
.anyMatch(e -> field.equals(e.getField()) && e.getMessage().contains(message));
if (!found) {
failWithMessage("期望字段 <%s> 有错误信息包含 <%s>,实际错误列表:%s",
field, message, actual.getFieldErrors());
}
return this;
}
}
// 在测试里使用
@Test
void testCreateOrder_multipleValidationErrors() {
BusinessException exception = catchThrowableOfType(
() -> orderService.createOrder(invalidRequest),
BusinessException.class
);
// 流畅的链式断言
BusinessExceptionAssert.assertThat(exception)
.hasErrorCode("VALIDATION_FAILED")
.hasHttpStatus(400)
.hasFieldError("userId", "不能为空")
.hasFieldError("productId", "不存在");
}踩坑实录三则
踩坑一:assertThrows 捕获了不期望的异常类型(父类)
现象:
// 期望抛出 SpecificException,但实际抛出了父类 BaseException
assertThrows(BaseException.class,
() -> service.doSomething(input)); // 通过了!但测试意图是验证 SpecificException原因:assertThrows 验证的是"是否抛出了该类型或其子类型的异常",所以子类也会通过父类的 assertThrows。
解法:用更精确的类型,或者捕获后用 assertInstanceOf 验证精确类型:
BaseException exception = assertThrows(BaseException.class, () -> service.doSomething(input));
assertInstanceOf(SpecificException.class, exception,
"期望的是 SpecificException,实际是:" + exception.getClass().getSimpleName());踩坑二:异常在错误的地方被抛出,assertThrows 通过了,但业务逻辑有问题
现象:
@Test
void testCreateOrder_whenProductNotFound() {
// 测试通过了!
assertThrows(ProductNotFoundException.class,
() -> orderService.createOrder(requestWithInvalidProductId));
}但排查后发现:ProductNotFoundException 是在 @BeforeEach 里的数据准备代码里抛出的,不是在 orderService.createOrder() 里!
原因:assertThrows 只验证 lambda 里有异常抛出,不管是哪一行。
解法:把数据准备和被测代码分开,数据准备如果报错会直接让测试失败(不会被 assertThrows 捕获):
@BeforeEach
void setUp() {
// 数据准备放在外面,异常直接导致测试失败
when(productRepository.findById(999L)).thenReturn(Optional.empty());
}
@Test
void testCreateOrder_whenProductNotFound() {
assertThrows(ProductNotFoundException.class,
() -> orderService.createOrder(requestWithProductId(999L))); // 被测代码在 lambda 里
}踩坑三:异常消息国际化后,测试里硬编码了中文消息导致失败
现象:测试里 assertTrue(exception.getMessage().contains("余额不足")),项目切换语言环境后,消息变成了英文 "Insufficient balance",测试失败。
解法:不要验证异常的自然语言文字,验证异常的错误码:
// 不好:验证文字
assertTrue(exception.getMessage().contains("余额不足"));
// 好:验证错误码(错误码不会因为国际化改变)
assertEquals("INSUFFICIENT_BALANCE", exception.getErrorCode());异常测试不是可选项。每一个 throw 语句背后都有一个应该被测试的业务规则。把这些规则用测试表达出来,当规则被意外改变时,测试会告诉你。
