一文学会单元测试之----Junit

一文学会单元测试之----Junit JUnit 简介JUnit 是 Java 生态最主流的单元测试框架用来对单个方法 / 类做自动化测试把测试代码写在项目里一键运行验证逻辑是否符合预期。现在主流版本JUnit 5Jupiter老项目多见 JUnit 4。JUnit 5 整体结构JUnit5 由三部分组成JUnit Jupiter写测试用例APITest等注解JUnit Platform测试运行引擎IDE/Maven/Gradle 底层依赖JUnit Vintage兼容 JUnit4 的旧测试用例常用注解JUnit5注解作用Test标记一个方法是测试方法BeforeEach每个测试方法执行前执行初始化AfterEach每个测试方法执行后执行清理BeforeAll所有测试方法执行前只执行一次方法必须staticAfterAll所有测试方法执行完只执行一次方法必须staticDisabled跳过该测试用例不执行DisplayName(测试名称)自定义测试显示名字可读性更好Nested嵌套测试类用于分组测试JUnit4 对比Before/After/BeforeClass/AfterClassJUnit5 改名了。断言 Assertions核心断言就是预期结果 vs 实际结果不匹配就抛出断言错误测试失败。import static org.junit.jupiter.api.Assertions.*; assertEquals(预期值, 实际值); // 相等 assertNotEquals(a,b); assertTrue(布尔表达式); assertFalse(布尔表达式); assertNull(obj); assertNotNull(obj); assertAll(() - { // 组合断言一次性展示所有失败不是遇到第一个就终止 assertEquals(1, a); assertEquals(2, b); }); // 异常断言验证代码是否抛出指定异常 assertThrows(NullPointerException.class, () - { // 要执行的代码 });完整最小示例JUnit5Maven 依赖 pom.xml!-- JUnit5 -- dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter-api/artifactId version5.9.2/version scopetest/scope /dependency dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter-engine/artifactId version5.9.2/version scopetest/scope /dependency业务类Calculator.javapublic class Calculator { public int add(int a, int b) { return a b; } public int div(int a, int b) { if(b 0){ throw new ArithmeticException(除零); } return a / b; } }测试类CalculatorTest.java放在src/test/javaimport org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; DisplayName(计算器测试) class CalculatorTest { private Calculator calculator; BeforeEach void setUp() { calculator new Calculator(); } Test DisplayName(测试加法) void testAdd() { int res calculator.add(1,2); assertEquals(3, res); } Test DisplayName(测试除零抛异常) void testDivZero() { ArithmeticException e assertThrows(ArithmeticException.class, ()-{ calculator.div(10,0); }); assertEquals(除零, e.getMessage()); } AfterEach void tearDown(){ // 资源释放 } }在 IDEA 可以直接点运行Mavenmvn test自动执行所有测试。测试生命周期BeforeAll BeforeEach Test AfterEach BeforeEach Test AfterEach AfterAll参数化测试JUnit5 很常用同一个方法多组输入自动跑!-- 需要额外引入参数化依赖 -- dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter-params/artifactId version5.9.2/version scopetest/scope /dependencyParameterizedTest ValueSource(ints {1,3,5,7}) void testOdd(int num){ assertTrue(num %2 !0); }JUnit4 vs JUnit5 重点区别包路径JUnit4org.junitJUnit5org.junit.jupiter.api注解改名Before→BeforeEachBeforeClass→BeforeAll断言类JUnit4AssertJUnit5AssertionsJUnit5 支持 Java8 lambda更灵活常见搭配AssertJ流式断言可读性更强assertThat(res).isEqualTo(3)Mockitomock 依赖对象做单元测试时把 DB、RPC 外部依赖模拟掉Maven/GradleCI 流水线自动跑单元测试单元测试原则单一职责一个测试方法只测一个场景独立性测试用例之间互不影响不能共享脏数据覆盖正常场景、边界值、异常场景都要测JUnit5 Mockito示例1. JUnit5 Mockito 完整 Demomock Service场景UserService 依赖 UserDao单元测试时不连真实数据库mock UserDaoMaven 依赖!-- JUnit5 -- dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter-api/artifactId version5.9.2/version scopetest/scope /dependency dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter-engine/artifactId version5.9.2/version scopetest/scope /dependency !-- Mockito JUnit5 扩展 -- dependency groupIdorg.mockito/groupId artifactIdmockito-junit-jupiter/artifactId version4.11.0/version scopetest/scope /dependency业务代码// UserDao public interface UserDao { String getUserNameById(Long id); } // UserService public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao userDao; } public String getUsername(Long userId) { if (userId null || userId 0) { throw new IllegalArgumentException(非法用户id); } return userDao.getUserNameById(userId); } }测试类 UserServiceTestimport org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; ExtendWith(MockitoExtension.class) // 开启Mockito DisplayName(用户服务单元测试) class UserServiceTest { // mock外部依赖不是真实对象 Mock private UserDao userDao; // 将mock的userDao注入到userService InjectMocks private UserService userService; Test DisplayName(正常查询用户名) void testGetUsernameSuccess() { // 打桩stub当userDao调用getUserNameById(1L)返回张三 when(userDao.getUserNameById(1L)).thenReturn(张三); // 执行方法 String name userService.getUsername(1L); // 断言结果 assertEquals(张三, name); // 验证userDao.getUserNameById(1L)被调用了1次 verify(userDao, times(1)).getUserNameById(1L); } Test DisplayName(id0抛出非法参数异常) void testGetUsernameFail() { IllegalArgumentException ex assertThrows(IllegalArgumentException.class, () - { userService.getUsername(-1L); }); assertEquals(非法用户id, ex.getMessage()); // 此时不应该调用dao verify(userDao, never()).getUserNameById(anyLong()); } }核心概念Mock创建模拟对象InjectMocks实例化被测对象自动注入 mock 对象when(...).thenReturn(...)打桩stub模拟返回值verify校验方法是否被调用、调用次数2. JUnit 面试高频题带答案Q1JUnit5 有哪些生命周期注解执行顺序ABeforeAll所有测试方法前执行一次staticBeforeEach每个 Test 前执行Test测试方法AfterEach每个 Test 后执行AfterAll所有测试结束后执行一次static注意测试方法不能是 private返回值 void。Q2JUnit5 和 JUnit4 的主要区别A包名JUnit4org.junitJUnit5org.junit.jupiter.api注解改名Before→BeforeEachBeforeClass→BeforeAllJUnit5 支持 Java8 lambda支持组合断言、参数化测试JUnit5 模块化平台 jupitervintagevintage 用来跑 JUnit4 旧用例JUnit4 只有一个运行器JUnit5 扩展模型 Extension 替代 RunnerQ3什么是断言assertAll 作用A断言用来校验预期结果失败抛出 AssertionError标记测试失败。assertAll分组断言一次性执行全部断言收集所有失败信息不会遇到第一个失败就终止。assertAll(用户信息校验, () - assertEquals(1, user.getId()), () - assertEquals(test, user.getName()) );Q4Disabled 作用和注释掉测试方法区别ADisabled跳过执行该测试测试报告会标记该用例为跳过。 注释掉测试框架看不到这个测试方法报告不会出现这条用例。Q5Mockito 中 Mock、Spy、InjectMocks 区别AMock完全模拟对象所有方法默认返回 null / 默认值需要手动打桩Spy部分模拟真实对象不打桩时执行真实方法打桩后返回 mock 值InjectMocks创建被测类实例把 Mock/Spy 对象注入进去Q6单元测试的原则AAIR 原则Automatic自动化一键执行Independent测试用例互相独立不能依赖执行顺序Repeatable可重复执行每次结果一致还有单一职责覆盖正常、边界、异常场景尽量不依赖外部资源DB/Redis。Q7参数化测试有什么用常用注解A同一测试逻辑多组输入批量执行减少重复代码。ParameterizedTest搭配ValueSource简单值int/stringCsvSource多参数MethodSource自定义方法提供复杂参数示例ParameterizedTest CsvSource({1,2,3,3,4,7}) void testAdd(int a, int b, int res){ assertEquals(res, calculator.add(a,b)); }Q8assertThrows 的作用A断言代码块抛出指定类型异常用于异常场景测试可以拿到异常对象进一步校验 message。3. JUnit5 Extension 扩展自定义扩展JUnit5 使用 Extension 替代 JUnit4 的 Runner可在测试生命周期各个节点插入自定义逻辑。 常用扩展点接口BeforeAllCallbackBeforeEachCallbackAfterEachCallbackAfterAllCallbackTestExecutionExceptionHandler捕获测试异常示例自定义扩展打印测试开始 / 结束日志import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; public class LogExtension implements BeforeEachCallback, AfterEachCallback { Override public void beforeEach(ExtensionContext context) { System.out.println(【测试开始】 context.getDisplayName()); } Override public void afterEach(ExtensionContext context) { System.out.println(【测试结束】 context.getDisplayName()); } }使用扩展import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; ExtendWith(LogExtension.class) class ExtensionDemoTest { Test void testDemo(){ System.out.println(执行测试逻辑); } }常用场景自动初始化 / 清理资源连接池全局捕获测试异常自定义告警自定义注解实现通用能力比如自动生成测试数据埋点统计测试耗时延伸Spy 和 Mock 坑点spy 对象调用真实方法如果方法里面抛异常即使你打桩也要注意spy 打桩推荐用doReturn().when()不要when().thenReturn()UserDao spyDao spy(UserDao.class); // 推荐 doReturn(test).when(spyDao).getUserNameById(1L);单元测试、集成测试区别单元测试测试单个类 / 方法mock 所有外部依赖速度快开发写集成测试多个组件一起跑使用真实 DB / 中间件验证交互Maven surefire 插件Maven 执行mvn test底层靠 surefire 插件用来发现并执行 JUnit 测试。JUnit5 Mockito 面试上机编码题含参考答案面试场景手写单元测试考察Mock、InjectMocks、打桩 stub、异常断言、verify 校验是 Java 后端面试高频手写题题目现有业务代码// 接口 public interface OrderDao { // 根据订单id查询订单金额 Integer getOrderAmount(Long orderId); } // 订单服务 public class OrderService { private final OrderDao orderDao; public OrderService(OrderDao orderDao) { this.orderDao orderDao; } /** * 判断订单是否大额订单金额 1000 为大额订单 * param orderId 订单id * return true大额 / false普通 */ public boolean isBigOrder(Long orderId) { if(orderId null || orderId 0){ throw new IllegalArgumentException(订单ID非法); } Integer amount orderDao.getOrderAmount(orderId); if(amount null){ return false; } return amount 1000; } }要求使用 JUnit5 Mockito 编写 OrderService 的单元测试覆盖下面 4 个场景订单金额 1500返回 true大额订单订单金额 800返回 false普通订单dao 返回 null返回 falseorderId 为 null抛出非法参数异常并校验异常信息参考答案测试类import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; ExtendWith(MockitoExtension.class) DisplayName(订单服务单元测试) class OrderServiceTest { // mock外部依赖 Mock private OrderDao orderDao; // 注入mock对象到被测服务 InjectMocks private OrderService orderService; Test DisplayName(订单金额1500是大额订单返回true) void testIsBigOrderTrue() { // 打桩orderId1001Ldao返回1500 when(orderDao.getOrderAmount(1001L)).thenReturn(1500); boolean result orderService.isBigOrder(1001L); assertTrue(result); // 校验dao方法调用1次 verify(orderDao, times(1)).getOrderAmount(1001L); } Test DisplayName(订单金额800普通订单返回false) void testIsBigOrderFalse() { when(orderDao.getOrderAmount(1002L)).thenReturn(800); boolean result orderService.isBigOrder(1002L); assertFalse(result); verify(orderDao, times(1)).getOrderAmount(1002L); } Test DisplayName(dao返回null返回false) void testOrderAmountNull() { when(orderDao.getOrderAmount(1003L)).thenReturn(null); boolean result orderService.isBigOrder(1003L); assertFalse(result); verify(orderDao, times(1)).getOrderAmount(1003L); } Test DisplayName(orderId为null抛出异常) void testOrderIdNull() { IllegalArgumentException exception assertThrows(IllegalArgumentException.class, ()-{ orderService.isBigOrder(null); }); assertEquals(订单ID非法, exception.getMessage()); // id非法代码不会调用dao校验never调用 verify(orderDao, never()).getOrderAmount(anyLong()); } }配套 Maven 依赖面试写代码可以顺带提!-- JUnit5 -- dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter-api/artifactId version5.9.2/version scopetest/scope /dependency dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter-engine/artifactId version5.9.2/version scopetest/scope /dependency !-- Mockito JUnit5扩展 -- dependency groupIdorg.mockito/groupId artifactIdmockito-junit-jupiter/artifactId version4.11.0/version scopetest/scope /dependency面试追问面试官大概率接着问答案给你备好Q1ExtendWith(MockitoExtension.class)作用AJUnit5 的扩展自动初始化Mock、InjectMocks对象不用手动MockitoAnnotations.openMocks(this)。Q2verify(orderDao, never()).getOrderAmount(anyLong())是什么意思A校验这个方法绝对不能被调用。因为 id 非法时代码直接抛异常不会走到 dao 查询逻辑。Q3anyLong () 是什么AMockito 参数匹配器匹配任意 Long 类型参数。如果用参数匹配器所有参数都要用匹配器。坑when(userDao.getUserNameById(anyLong())).thenReturn(xxx);✔when(userDao.getUserNameById(anyLong(), abc)).thenReturn(xxx);✔when(userDao.getUserNameById(anyLong(), abc)).thenReturn(xxx);❌不能混用常量和匹配器Q4InjectMocks 注入原理AMockito 实例化被测类优先尝试构造器注入没有构造器就 setter 注入再尝试字段注入把标记Mock/Spy的对象注入进去。注意不会注入 static 字段注入失败不会抛异常只会警告。Q5单元测试里什么时候用 SpyA当被测依赖是真实对象只想 mock 其中一部分方法其余方法执行真实逻辑。举个例子OrderDao 是本地工具类大部分方法想用真实逻辑只 mock getOrderAmount。Spy private OrderDao orderDao;Spy 打桩坑不要用when(spyObj.method()).thenReturn()优先doReturn().when(spyObj).method()避免真实方法执行。可选练习新题目新增一个折扣方法/** * 计算折扣金额2000打9折否则原价 */ public BigDecimal calcDiscount(Integer amount){ if(amount null || amount 0){ throw new IllegalArgumentException(金额非法); } if(amount 2000){ return new BigDecimal(amount).multiply(new BigDecimal(0.9)); } return new BigDecimal(amount); }要求写 JUnit5 测试用例覆盖金额 2500、金额 1000、金额 null、金额 - 1。折扣方法 JUnit5 Mockito 测试参考答案业务代码import java.math.BigDecimal; public class DiscountService { /** * 计算折扣金额2000打9折否则原价 */ public BigDecimal calcDiscount(Integer amount){ if(amount null || amount 0){ throw new IllegalArgumentException(金额非法); } if(amount 2000){ return new BigDecimal(amount).multiply(new BigDecimal(0.9)); } return new BigDecimal(amount); } }说明这个类没有外部依赖不需要 Mock / InjectMocks直接 new 对象测试即可。测试类import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.math.BigDecimal; import static org.junit.jupiter.api.Assertions.*; DisplayName(折扣服务测试) class DiscountServiceTest { private final DiscountService discountService new DiscountService(); Test DisplayName(金额2500大于2000打9折结果2250) void testDiscountOver2000(){ BigDecimal result discountService.calcDiscount(2500); assertEquals(new BigDecimal(2250), result); } Test DisplayName(金额1000不大于2000原价1000) void testDiscountNormal(){ BigDecimal result discountService.calcDiscount(1000); assertEquals(new BigDecimal(1000), result); } Test DisplayName(amount为null抛出异常) void testAmountNull(){ IllegalArgumentException ex assertThrows(IllegalArgumentException.class, ()-{ discountService.calcDiscount(null); }); assertEquals(金额非法, ex.getMessage()); } Test DisplayName(金额-1负数抛出异常) void testAmountNegative(){ IllegalArgumentException ex assertThrows(IllegalArgumentException.class, ()-{ discountService.calcDiscount(-1); }); assertEquals(金额非法, ex.getMessage()); } }补充面试小考点BigDecimal 坑不要写assertEquals(new BigDecimal(2250), result)推荐字符串构造new BigDecimal(2250)因为数字构造会有浮点精度问题。追加提问Q这个 DiscountService 为什么不需要 Mock、InjectMocksADiscountService 没有依赖任何外部对象DAO / 远程接口是纯工具类。直接 new 真实对象测试就足够不需要 mock。