外部依赖太多,如何写 Java 单元测试?

本文转载自微信公众号「码农私房话」,作者Liew 。转载本文请联系码农私房话公众号。

目前创新互联公司已为1000多家的企业提供了网站建设、域名、雅安服务器托管网站托管、企业网站设计、河北网站维护等服务,公司将坚持客户导向、应用为本的策略,正道将秉承"和谐、参与、激情"的文化,与客户和合作伙伴齐心协力一起成长,共同发展。

事出有因

在日常的开发中,很多人习惯性地写完需求代码后,嗖的一声用 Postman 模拟真实请求或写几个 JUnit 的单元测试跑功能点,只要没有问题就上线了,但其实这存在很大风险,一方面无法验证业务逻辑的不同分支,另外一方面需严重依赖中间件资源才能运行测试用例,占用大量资源。

秣马厉兵

Mockito是一个非常优秀的模拟框架,可以使用它简洁的API来编写漂亮的测试代码,它的测试代码可读性高同时会产生清晰的错误日志。

添加 maven 依赖

 
 
 
 
  1.  
  2.     org.mockito 
  3.     mockito-core 
  4.     3.3.3 
  5.     test 
  6.  

注意:Mockito 3.X 版本使用了 JDK8 API,但功能与 2.X 版本并没有太大的变化。

指定 MockitoJUnitRunner

 
 
 
 
  1. @RunWith(MockitoJUnitRunner.class) 
  2. public class MockitoDemoTest { 
  3.  
  4.     //注入依赖的资源对象 
  5.     @Mock 
  6.     private MockitoTestService mockitoTestService; 
  7.     @Before 
  8.     public void before(){ 
  9.         MockitoAnnotations.initMocks(this); 
  10.     } 

从代码中观察到,使用 @Mock 注解标识哪些对象需要被 Mock,同时在执行测试用例前初始化 MockitoAnnotations.initMocks(this) 告诉框架使 Mock 相关注解生效。

验证对象行为 Verify

 
 
 
 
  1. @Test 
  2. public void testVerify(){ 
  3.     //创建mock 
  4.     List mockedList = mock(List.class); 
  5.     mockedList.add("1"); 
  6.     mockedList.clear(); 
  7.     //验证list调用过add的操作行为 
  8.     verify(mockedList).add("1"); 
  9.     //验证list调用过clear的操作行为 
  10.     verify(mockedList).clear(); 
  11.     //使用内建anyInt()参数匹配器,并存根 
  12.     when(mockedList.get(anyInt())).thenReturn("element"); 
  13.     System.out.println(mockedList.get(2)); //此处输出为element 
  14.     verify(mockedList).get(anyInt()); 

存根 stubbing

stubbing 完全是模拟一个外部依赖、用来提供测试时所需要的数据。

 
 
 
 
  1. @Test 
  2. public void testStub(){ 
  3.     //可以mock具体的类,而不仅仅是接口 
  4.     LinkedList mockedList = mock(LinkedList.class); 
  5.     //存根(stubbing) 
  6.     when(mockedList.get(0)).thenReturn("first"); 
  7.     when(mockedList.get(1)).thenThrow(new RuntimeException()); 
  8.     //下面会打印 "first" 
  9.     System.out.println(mockedList.get(0)); 
  10.     //下面会抛出运行时异常 
  11.     System.out.println(mockedList.get(1)); 
  12.     //下面会打印"null" 因为get(999)没有存根(stub) 
  13.     System.out.println(mockedList.get(999)); 
  14.     doThrow(new RuntimeException()).when(mockedList).clear(); 
  15.     //下面会抛出 RuntimeException: 
  16.     mockedList.clear(); 
  • 存根(stub)可以覆盖,测试方法可以覆盖全局设置的通用存根。
  • 一旦做了存根,无论这个方法被调用多少次,方法将总是返回存根的值。

存根的连续调用

 
 
 
 
  1. @Test 
  2. public void testStub() { 
  3.     when(mock.someMethod("some arg")) 
  4.     .thenThrow(new RuntimeException()) 
  5.     .thenReturn("foo"); 
  6.     mock.someMethod("some arg"); //第一次调用:抛出运行时异常 
  7.     //第二次调用: 打印 "foo" 
  8.     System.out.println(mock.someMethod("some arg")); 
  9.     //任何连续调用: 还是打印 "foo" (最后的存根生效). 
  10.     System.out.println(mock.someMethod("some arg")); 
  11.     //可供选择的连续存根的更短版本: 
  12.     when(mock.someMethod("some arg")).thenReturn("one", "two", "three"); 
  13.     when(mock.someMethod(anyString())).thenAnswer(new Answer() { 
  14.         Object answer(InvocationOnMock invocation) { 
  15.             Object[] args = invocation.getArguments(); 
  16.             Object mock = invocation.getMock(); 
  17.             return "called with arguments: " + args; 
  18.         } 
  19.     }); 
  20.     // "called with arguments: foo 
  21.     System.out.println(mock.someMethod("foo")); 

在做方法存根时,可以指定不同时机需要提供的测试数据,例如第一次调用返回 xxx,第二次调用时抛出异常等。

参数匹配器

 
 
 
 
  1. @Test 
  2. public void testArugument{ 
  3.     //使用内建anyInt()参数匹配器 
  4.     when(mockedList.get(anyInt())).thenReturn("element"); 
  5.     System.out.println(mockedList.get(999)); //打印 "element" 
  6.     //同样可以用参数匹配器做验证 
  7.     verify(mockedList).get(anyInt()); 
  8.  
  9.     //注意:如果使用参数匹配器,所有的参数都必须通过匹配器提供。 
  10.     verify(mock) 
  11.     .someMethod(anyInt(), anyString(), eq("third argument")); 
  12.     //上面是正确的 - eq(0也是参数匹配器),而下面的是错误的 
  13.     verify(mock) 
  14.     .someMethod(anyInt(), anyString(), "third argument"); 

验证调用次数

 
 
 
 
  1. @Test 
  2. public void testVerify{ 
  3.     List mockedList = new ArrayList(); 
  4.     mockedList.add("once"); 
  5.     mockedList.add("twice"); 
  6.     mockedList.add("twice"); 
  7.     mockedList.add("three times"); 
  8.     mockedList.add("three times"); 
  9.     mockedList.add("three times"); 
  10.     //下面两个验证是等同的 - 默认使用times(1) 
  11.     verify(mockedList).add("once"); 
  12.     verify(mockedList, times(1)).add("once"); 
  13.     verify(mockedList, times(2)).add("twice"); 
  14.     verify(mockedList, times(3)).add("three times"); 
  15.     //使用using never()来验证. never()相当于 times(0) 
  16.     verify(mockedList, never()).add("never happened"); 
  17.     //使用 atLeast()/atMost()来验证 
  18.     verify(mockedList, atLeastOnce()).add("three times"); 
  19.     verify(mockedList, atLeast(2)).add("five times"); 
  20.     verify(mockedList, atMost(5)).add("three times"); 

验证调用顺序

 
 
 
 
  1. @Test 
  2. public void testOrder() 
  3.     // A. 单个Mock,方法必须以特定顺序调用 
  4.     List singleMock = mock(List.class); 
  5.  
  6.     //使用单个Mock 
  7.     singleMock.add("was added first"); 
  8.     singleMock.add("was added second"); 
  9.  
  10.     //为singleMock创建 inOrder 检验器 
  11.     InOrder inOrder = inOrder(singleMock); 
  12.  
  13.     //确保add方法第一次调用是用"was added first",然后是用"was added second" 
  14.     inOrder.verify(singleMock).add("was added first"); 
  15.     inOrder.verify(singleMock).add("was added second"); 

以上是 Mockito 框架常用的使用方式,但 Mockito 有一定的局限性, 它只能 Mock 类或者接口,对于静态、私有及final方法的 Mock 则无能为力了。

而 PowerMock 正是弥补这块的缺陷,它的实现原理如下:

  • 当某个测试方法被注解 @PrepareForTest 标注后,在运行测试用例时会创建一个新的 MockClassLoader 实例并加载该测试用例使用到的类(系统类除外)。
  • PowerMock 会根据你的 mock 要求,去修改写在注解 @PrepareForTest 里的 class 文件内容(调用非系统的静态、Final方法),若是包含调用系统的方法则修改调用系统方法的类的 class 文件内容达到满足需求 。

但值得高兴的是在 Mockito2.7.2 及更高版本添加了对 final 类及方法支持[1] 。

同样, Mockito3.4.0 及更高版本支持对静态方法的 Mock[2],虽然是处于孵化阶段,但对于我们做单元测试而言是已经足够了。

决胜之机

大多数项目使用了 Spring 或 Spring Boot 作为基础框架,研发只需要关心业务逻辑即可。

在代码例子中将使用 Junit5 的版本,因此要求 Spring boot版本必须是2.2.0版本或以上,采用 Mockito3.5.11 的版本作为 Mock 框架,减少项目对 PowerMock 的依赖,另外还有一个重要原因是因为目前PowerMock不支持 Junit5,无法在引入 PowerMock 后使用Junit5 的相关功能及API,本文项目代码地址:https://github.com/GoQeng/spring-mockito3-demo。

maven 配置

 
 
 
 
  1.  
  2.     1.8 
  3.     3.5.11 
  4.     1.10.15 
  5.     3.13.4 
  6.     5.1.48 
  7.     0.8.6 
  8.     5.6.2 
  9.     1.1.1 
  10.     2.1.3 
  11.     3.8.1 
  12.     2.12.4 
  13.     1.4.197 
  14.  
  15.  
  16.  
  17.      
  18.      
  19.         org.springframework.boot 
  20.         spring-boot-starter-web 
  21.      
  22.  
  23.      
  24.         org.springframework.boot 
  25.         spring-boot-starter-test 
  26.         test 
  27.          
  28.              
  29.                 org.mockito 
  30.                 mockito-core 
  31.              
  32.              
  33.                 org.junit.vintage 
  34.                 junit-vintage-engine 
  35.              
  36.          
  37.      
  38.  
  39.      
  40.      
  41.         org.mockito 
  42.         mockito-core 
  43.         ${mockito.version} 
  44.         compile 
  45.          
  46.              
  47.                 net.bytebuddy 
  48.                 byte-buddy 
  49.              
  50.              
  51.                 net.bytebuddy 
  52.                 byte-buddy-agent 
  53.              
  54.          
  55.      
  56.      
  57.      
  58.         net.bytebuddy 
  59.         byte-buddy 
  60.         ${byte-buddy.version} 
  61.      
  62.  
  63.      
  64.         net.bytebuddy 
  65.         byte-buddy-agent 
  66.         ${byte-buddy.version} 
  67.         test 
  68.      
  69.  
  70.      
  71.         org.mockito 
  72.         mockito-inline 
  73.         ${mockito.version} 
  74.         test 
  75.      
  76.  
  77.      
  78.      
  79.         org.mybatis.spring.boot 
  80.         mybatis-spring-boot-starter 
  81.         ${mybatis-spring.version} 
  82.      
  83.  
  84.      
  85.      
  86.         org.redisson 
  87.         redisson-spring-boot-starter 
  88.         ${redisson-spring.version} 
  89.          
  90.              
  91.                 junit 
  92.                 junit 
  93.              
  94.          
  95.         compile 
  96.      
  97.  
  98.      
  99.      
  100.         mysql 
  101.         mysql-connector-java 
  102.         ${mysql.version} 
  103.      
  104.  
  105.      
  106.      
  107.         org.jacoco 
  108.         jacoco-maven-plugin 
  109.         ${jacoco.version} 
  110.      
  111.  
  112.      
  113.      
  114.         org.junit.jupiter 
  115.         junit-jupiter 
  116.         ${junit-jupiter.version} 
  117.         test 
  118.      
  119.  
  120.      
  121.         org.junit.platform 
  122.         junit-platform-runner 
  123.         ${junit-platform.version} 
  124.          
  125.              
  126.                 junit 
  127.                 junit 
  128.              
  129.          
  130.      
  131.  
  132.      
  133.      
  134.         com.h2database 
  135.         h2 
  136.         ${h2.version} 
  137.         test 
  138.          
  139.              
  140.                 junit 
  141.                 junit 
  142.              
  143.          
  144.      
  145.  
  146.  
  147.  
  148.      
  149.          
  150.             org.apache.maven.plugins 
  151.             maven-surefire-plugin 
  152.             ${maven-surefire.version} 
  153.              
  154.                  
  155.                  
  156.                     test 
  157.                      
  158.                         test 
  159.                      
  160.                  
  161.              
  162.              
  163.                 once 
  164.                 false 
  165.                  
  166.                     **/SuiteTest.java 
  167.                  
  168.              
  169.          
  170.          
  171.             org.apache.maven.plugins 
  172.             maven-compiler-plugin 
  173.             ${maven-compiler.version} 
  174.              
  175.                 
  176.                 8 
  177.              
  178.          
  179.          
  180.             org.jacoco 
  181.             jacoco-maven-plugin 
  182.             ${jacoco.version} 
  183.              
  184.                  
  185.                      
  186.                         prepare-agent 
  187.                      
  188.                  
  189.                  
  190.                  
  191.                     report 
  192.                     test 
  193.                      
  194.                         report 
  195.                      
  196.                  
  197.              
  198.          
  199.      
  200.  
  201.  
  202.      
  203.          
  204.             org.jacoco 
  205.             jacoco-maven-plugin 
  206.              
  207.                  
  208.                      
  209.                          
  210.                         report 
  211.                      
  212.                  
  213.              
  214.          
  215.      
  216.  

maven 运行测试用例是通过调用 maven 的 surefire 插件并 fork 一个子进程来执行用例的。

forkMode 属性指明是为每个测试创建一个进程还是所有测试共享同一个进程完成,forkMode 设置值有 never、once、always 、pertest 。

  • pretest:每一个测试创建一个新进程,为每个测试创建新的JVM进程是单独测试的最彻底方式,但也是最慢的,不适合持续回归。
  • once:在一个进程中进行所有测试。once 为默认设置,在持续回归时建议使用默认设置。
  • always:在一个进程中并行的运行脚本,Junit4.7 以上版本才可以使用,surefire 的版本要在 2.6 以上提供这个功能,其中 threadCount 执行时,指定可分配的线程数量,只和参数 parallel 配合使用有效,默认为 5。
  • never:从不创建新进程进行测试。

环境准备

在项目中 test 目录下建立测试入口类 TestApplication.java,将外部依赖 Redis 单独配置到 DependencyConfig.java 中,同时需要在 TestApplication.class 中排除对 Redis 或 Mongodb 的自动注入配置等。

注意:将外部依赖配置到DependencyConfig并不是必要的,此步骤的目的是为了避免每个单元测试类运行时都会重启 Spring 上下文,可采用 @MockBean 的方式在代码中引入外部依赖资源替代此方法。

 
 
 
 
  1. @Configuration 
  2. public class DependencyConfig { 
  3.  
  4.     @Bean 
  5.     public RedissonClient getRedisClient() { 
  6.         return Mockito.mock(RedissonClient.class); 
  7.     } 
  8.  
  9.     @Bean 
  10.     public RestTemplate restTemplate() { 
  11.         return Mockito.mock(RestTemplate.class); 
  12.     } 

接着在测试入口类中通过 @ComponentScan 对主入口启动类 Application.class 及 RestClientConfig.class 进行排除。

 
 
 
 
  1. @SpringBootApplication 
  2. @ComponentScan(excludeFilters = @ComponentScan.Filter( 
  3.         type = FilterType.ASSIGNABLE_TYPE, 
  4.         classes = {Application.class, RestClientConfig.class})) 
  5. @MapperScan("com.example.mockito.demo.mapper") 
  6. public class TestApplication { 
  7.  

为了不单独写重复的代码,我们一般会把单独的代码抽取出来作为一个公共基类,其中 @ExtendWith(SpringExtension.class) 注解目的是告诉 Spring boot 将使用 Junit5 作为运行平台,如果想买中使用 Junit4 的话,则需要使用 @RunWith(SpringRunner.class) 注解告知用 SpringRunner 运行启动。

 
 
 
 
  1. @SpringBootTest(classes = TestApplication.class)@ExtendWith(SpringExtension.class) 
  2. public abstract class SpringBaseTest {} 

准备好配置环境后,我们便可以开始对项目的 Mapper、Service、Web 层进行测试了。

Mapper层测试

对 Mapper 层的测试主要是验证 SQL 语句及 Mybatis 传参等准确性。

 
 
 
 
  1. server: 
  2.   port: 8080 
  3. spring: 
  4.   test: 
  5.     context: 
  6.       cache: 
  7.         max-size: 42 
  8.   main: 
  9.     allow-bean-definition-overriding: true 
  10.   datasource: 
  11.     url: jdbc:h2:mem:test;MODE=MYSQL;DB_CLOSE_DELAY=-1;INIT=runscript from 'classpath:init.sql' 
  12.     username: sa 
  13.     password: 
  14.     driverClassName: org.h2.Driver 
  15.     hikari: 
  16.       minimum-idle: 5 
  17.       maximum-pool-size: 15 
  18.       auto-commit: true 
  19.       idle-timeout: 30000 
  20.       pool-name: DatebookHikariCP 
  21.       max-lifetime: 1800000 
  22.       connection-timeout: 10000 
  23.       connection-test-query: SELECT 1 
  24.  
  25. mybatis: 
  26.   type-aliases-package: com.example.mockito.demo.domain 
  27.   mapper-locations: 
  28.     - classpath:mapper/*.xml 

对 Mapper 层的测试并没有采取 Mock 的方式,而是采用 H2 内存数据库的方式模拟真实数据库,同时也避免由于测试数据给真实数据库带来的影响。

 
 
 
 
  1. jdbc:h2:mem:test;MODE=MYSQL;DB_CLOSE_DELAY=-1;INIT=runscript from 'classpath:init.sql' 

配置 H2 数据库信息,同时 INIT 指定在创建连接时会执行类路径下的 init.sql 即建表 SQL 。

 
 
 
 
  1. public class DemoMapperTest extends SpringBaseTest { 
  2.  
  3.     @Resource 
  4.     private DemoMapper demoMapper; 
  5.  
  6.     @Test 
  7.     public void testInsert() { 
  8.         Demo demo = new Demo(); 
  9.         demo.setName("test"); 
  10.         demoMapper.insert(demo); 
  11.  
  12.         Integer id = demo.getId(); 
  13.         Demo model = demoMapper.getDetail(id); 
  14.         Assert.assertNotNull(model); 
  15.         Assert.assertEquals(demo.getName(), model.getName()); 
  16.     } 
  17.  
  18.     @Test 
  19.     public void testGetList() { 
  20.         Demo demo = new Demo(); 
  21.         demo.setName("test"); 
  22.         demoMapper.insert(demo); 
  23.  
  24.         List demoList = demoMapper.getList(); 
  25.         Assert.assertNotNull(demoList); 
  26.         Assert.assertEquals(1, demoList.size()); 
  27.     } 

Service层测试

一般项目的业务逻辑写在 service 层,需要写更多的测试用例验证业务代码逻辑性及准确性,尽可能的覆盖到业务代码的分支逻辑。

 
 
 
 
  1. public class DemoServiceTest extends SpringBaseTest { 
  2.  
  3.   @Resource 
  4.   private DemoService demoService; 
  5.   @Resource 
  6.   private RedissonClient redissonClient; 
  7.  
  8.   @Resource 
  9.   private RestTemplate restTemplate; 
  10.  
  11.   @BeforeEach 
  12.   public void setUp() { 
  13.       MockitoAnnotations.openMocks(this); 
  14.   } 
  15.  
  16.   @Test 
  17.   public void testGetList() { 
  18.       //测试第一个分支逻辑 
  19.       RAtomicLong rAtomicLong = Mockito.mock(RAtomicLong.class); 
  20.       Mockito.when(redissonClient.getAtomicLong(ArgumentMatchers.anyString())).thenReturn(rAtomicLong); 
  21.       long count = 4L; 
  22.       Mockito.when(rAtomicLong.incrementAndGet()).thenReturn(count); 
  23.       List demoList = demoService.getList(); 
  24.       Assert.assertTrue(demoList != null && demoList.size() == 1); 
  25.       Demo demo = demoList.get(0); 
  26.       Assert.assertNotNull(demo); 
  27.       Assert.assertEquals(Integer.valueOf(4), demo.getId()); 
  28.       Assert.assertEquals("testCount4", demo.getName()); 
  29.  
  30.       //测试第二个分支逻辑 
  31.       Mockito.when(redissonClient.getAtomicLong(ArgumentMatchers.anyString())).thenReturn(rAtomicLong); 
  32.       count = 1L; 
  33.       Mockito.when(rAtomicLong.incrementAndGet()).thenReturn(count); 
  34.  
  35.       MockedStatic aesUtilMockedStatic = Mockito.mockStatic(AESUtil.class); 
  36.       aesUtilMockedStatic.when(() -> AESUtil.encrypt(ArgumentMatchers.eq("test"), ArgumentMatchers.eq("1234567890123456"))) 
  37.               .thenReturn("demo"); 
  38.  
  39.       demoList = demoService.getList(); 
  40.       Assert.assertTrue(demoList != null && demoList.size() == 1); 
  41.       Demo encryptDemo = demoList.get(0); 
  42.       Assert.assertNotNull(encryptDemo); 
  43.       Assert.assertEquals(Integer.valueOf(1), encryptDemo.getId()); 
  44.       Assert.assertEquals("testEncrypt", encryptDemo.getName()); 
  45.  
  46.       //测试第三个分支逻辑 
  47.       Mockito.when(redissonClient.getAtomicLong(ArgumentMatchers.anyString())).thenReturn(rAtomicLong); 
  48.       count = 1L; 
  49.       Mockito.when(rAtomicLong.incrementAndGet()).thenReturn(count); 
  50.  
  51.       //执行真实方法 
  52.       aesUtilMockedStatic.when(() -> AESUtil.encrypt(ArgumentMatchers.eq("test"), ArgumentMatchers.eq("1234567890123456"))) 
  53.               .thenCallRealMethod(); 
  54.  
  55.       String mobileUrl = "https://tcc.taobao.com/cc/json/mobile_tel_segment.htm?tel="; 
  56.       MobileInfoDTO mobileInfoDTO = new MobileInfoDTO(); 
  57.       mobileInfoDTO.setName("testMobile"); 
  58.       mobileInfoDTO.setLocation("testLocation"); 
  59.       Mockito.when(restTemplate.getForObject(mobileUrl, MobileInfoDTO.class)).thenReturn(mobileInfoDTO); 
  60.       demoList = demoService.getList(); 
  61.       Assert.assertNotNull(demoList); 
  62.       Assert.assertEquals(1, demoList.size()); 
  63.       Demo demo1 = demoList.get(0); 
  64.       Assert.assertNotNull(demo1); 
  65.       Assert.assertEquals(mobileInfoDTO.getName(), demo1.getName()); 
  66.     } 

WEB层测试

 
 
 
 
  1. public class DemoControllerTest extends SpringBaseTest { 
  2.  
  3.   private MockMvc mockMvc; 
  4.  
  5.   @Mock 
  6.   p

    新闻标题:外部依赖太多,如何写 Java 单元测试?
    网站链接:http://www.36103.cn/qtweb/news35/10835.html

    网站建设、网络推广公司-创新互联,是专注品牌与效果的网站制作,网络营销seo公司;服务项目有等

    广告

    声明:本网站发布的内容(图片、视频和文字)以用户投稿、用户转载内容为主,如果涉及侵权请尽快告知,我们将会在第一时间删除。文章观点不代表本网站立场,如需处理请联系客服。电话:028-86922220;邮箱:631063699@qq.com。内容未经允许不得转载,或转载时需注明来源: 创新互联