新聞中心
本文轉(zhuǎn)載自微信公眾號(hào)「碼農(nóng)私房話」,作者Liew 。轉(zhuǎn)載本文請(qǐng)聯(lián)系碼農(nóng)私房話公眾號(hào)。

目前創(chuàng)新互聯(lián)公司已為1000多家的企業(yè)提供了網(wǎng)站建設(shè)、域名、雅安服務(wù)器托管、網(wǎng)站托管、企業(yè)網(wǎng)站設(shè)計(jì)、河北網(wǎng)站維護(hù)等服務(wù),公司將堅(jiān)持客戶導(dǎo)向、應(yīng)用為本的策略,正道將秉承"和諧、參與、激情"的文化,與客戶和合作伙伴齊心協(xié)力一起成長(zhǎng),共同發(fā)展。
事出有因
在日常的開發(fā)中,很多人習(xí)慣性地寫完需求代碼后,嗖的一聲用 Postman 模擬真實(shí)請(qǐng)求或?qū)憥讉€(gè) JUnit 的單元測(cè)試跑功能點(diǎn),只要沒有問題就上線了,但其實(shí)這存在很大風(fēng)險(xiǎn),一方面無法驗(yàn)證業(yè)務(wù)邏輯的不同分支,另外一方面需嚴(yán)重依賴中間件資源才能運(yùn)行測(cè)試用例,占用大量資源。
秣馬厲兵
Mockito是一個(gè)非常優(yōu)秀的模擬框架,可以使用它簡(jiǎn)潔的API來編寫漂亮的測(cè)試代碼,它的測(cè)試代碼可讀性高同時(shí)會(huì)產(chǎn)生清晰的錯(cuò)誤日志。
添加 maven 依賴
org.mockito mockito-core 3.3.3 test
注意:Mockito 3.X 版本使用了 JDK8 API,但功能與 2.X 版本并沒有太大的變化。
指定 MockitoJUnitRunner
- @RunWith(MockitoJUnitRunner.class)
- public class MockitoDemoTest {
- //注入依賴的資源對(duì)象
- @Mock
- private MockitoTestService mockitoTestService;
- @Before
- public void before(){
- MockitoAnnotations.initMocks(this);
- }
- }
從代碼中觀察到,使用 @Mock 注解標(biāo)識(shí)哪些對(duì)象需要被 Mock,同時(shí)在執(zhí)行測(cè)試用例前初始化 MockitoAnnotations.initMocks(this) 告訴框架使 Mock 相關(guān)注解生效。
驗(yàn)證對(duì)象行為 Verify
- @Test
- public void testVerify(){
- //創(chuàng)建mock
- List mockedList = mock(List.class);
- mockedList.add("1");
- mockedList.clear();
- //驗(yàn)證list調(diào)用過add的操作行為
- verify(mockedList).add("1");
- //驗(yàn)證list調(diào)用過clear的操作行為
- verify(mockedList).clear();
- //使用內(nèi)建anyInt()參數(shù)匹配器,并存根
- when(mockedList.get(anyInt())).thenReturn("element");
- System.out.println(mockedList.get(2)); //此處輸出為element
- verify(mockedList).get(anyInt());
- }
存根 stubbing
stubbing 完全是模擬一個(gè)外部依賴、用來提供測(cè)試時(shí)所需要的數(shù)據(jù)。
- @Test
- public void testStub(){
- //可以mock具體的類,而不僅僅是接口
- LinkedList mockedList = mock(LinkedList.class);
- //存根(stubbing)
- when(mockedList.get(0)).thenReturn("first");
- when(mockedList.get(1)).thenThrow(new RuntimeException());
- //下面會(huì)打印 "first"
- System.out.println(mockedList.get(0));
- //下面會(huì)拋出運(yùn)行時(shí)異常
- System.out.println(mockedList.get(1));
- //下面會(huì)打印"null" 因?yàn)間et(999)沒有存根(stub)
- System.out.println(mockedList.get(999));
- doThrow(new RuntimeException()).when(mockedList).clear();
- //下面會(huì)拋出 RuntimeException:
- mockedList.clear();
- }
- 存根(stub)可以覆蓋,測(cè)試方法可以覆蓋全局設(shè)置的通用存根。
- 一旦做了存根,無論這個(gè)方法被調(diào)用多少次,方法將總是返回存根的值。
存根的連續(xù)調(diào)用
- @Test
- public void testStub() {
- when(mock.someMethod("some arg"))
- .thenThrow(new RuntimeException())
- .thenReturn("foo");
- mock.someMethod("some arg"); //第一次調(diào)用:拋出運(yùn)行時(shí)異常
- //第二次調(diào)用: 打印 "foo"
- System.out.println(mock.someMethod("some arg"));
- //任何連續(xù)調(diào)用: 還是打印 "foo" (最后的存根生效).
- System.out.println(mock.someMethod("some arg"));
- //可供選擇的連續(xù)存根的更短版本:
- when(mock.someMethod("some arg")).thenReturn("one", "two", "three");
- when(mock.someMethod(anyString())).thenAnswer(new Answer() {
- Object answer(InvocationOnMock invocation) {
- Object[] args = invocation.getArguments();
- Object mock = invocation.getMock();
- return "called with arguments: " + args;
- }
- });
- // "called with arguments: foo
- System.out.println(mock.someMethod("foo"));
- }
在做方法存根時(shí),可以指定不同時(shí)機(jī)需要提供的測(cè)試數(shù)據(jù),例如第一次調(diào)用返回 xxx,第二次調(diào)用時(shí)拋出異常等。
參數(shù)匹配器
- @Test
- public void testArugument{
- //使用內(nèi)建anyInt()參數(shù)匹配器
- when(mockedList.get(anyInt())).thenReturn("element");
- System.out.println(mockedList.get(999)); //打印 "element"
- //同樣可以用參數(shù)匹配器做驗(yàn)證
- verify(mockedList).get(anyInt());
- //注意:如果使用參數(shù)匹配器,所有的參數(shù)都必須通過匹配器提供。
- verify(mock)
- .someMethod(anyInt(), anyString(), eq("third argument"));
- //上面是正確的 - eq(0也是參數(shù)匹配器),而下面的是錯(cuò)誤的
- verify(mock)
- .someMethod(anyInt(), anyString(), "third argument");
- }
驗(yàn)證調(diào)用次數(shù)
- @Test
- public void testVerify{
- List
mockedList = new ArrayList(); - mockedList.add("once");
- mockedList.add("twice");
- mockedList.add("twice");
- mockedList.add("three times");
- mockedList.add("three times");
- mockedList.add("three times");
- //下面兩個(gè)驗(yàn)證是等同的 - 默認(rèn)使用times(1)
- verify(mockedList).add("once");
- verify(mockedList, times(1)).add("once");
- verify(mockedList, times(2)).add("twice");
- verify(mockedList, times(3)).add("three times");
- //使用using never()來驗(yàn)證. never()相當(dāng)于 times(0)
- verify(mockedList, never()).add("never happened");
- //使用 atLeast()/atMost()來驗(yàn)證
- verify(mockedList, atLeastOnce()).add("three times");
- verify(mockedList, atLeast(2)).add("five times");
- verify(mockedList, atMost(5)).add("three times");
- }
驗(yàn)證調(diào)用順序
- @Test
- public void testOrder()
- {
- // A. 單個(gè)Mock,方法必須以特定順序調(diào)用
- List singleMock = mock(List.class);
- //使用單個(gè)Mock
- singleMock.add("was added first");
- singleMock.add("was added second");
- //為singleMock創(chuàng)建 inOrder 檢驗(yàn)器
- InOrder inOrder = inOrder(singleMock);
- //確保add方法第一次調(diào)用是用"was added first",然后是用"was added second"
- inOrder.verify(singleMock).add("was added first");
- inOrder.verify(singleMock).add("was added second");
- }
以上是 Mockito 框架常用的使用方式,但 Mockito 有一定的局限性, 它只能 Mock 類或者接口,對(duì)于靜態(tài)、私有及final方法的 Mock 則無能為力了。
而 PowerMock 正是彌補(bǔ)這塊的缺陷,它的實(shí)現(xiàn)原理如下:
- 當(dāng)某個(gè)測(cè)試方法被注解 @PrepareForTest 標(biāo)注后,在運(yùn)行測(cè)試用例時(shí)會(huì)創(chuàng)建一個(gè)新的 MockClassLoader 實(shí)例并加載該測(cè)試用例使用到的類(系統(tǒng)類除外)。
- PowerMock 會(huì)根據(jù)你的 mock 要求,去修改寫在注解 @PrepareForTest 里的 class 文件內(nèi)容(調(diào)用非系統(tǒng)的靜態(tài)、Final方法),若是包含調(diào)用系統(tǒng)的方法則修改調(diào)用系統(tǒng)方法的類的 class 文件內(nèi)容達(dá)到滿足需求 。
但值得高興的是在 Mockito2.7.2 及更高版本添加了對(duì) final 類及方法支持[1] 。
同樣, Mockito3.4.0 及更高版本支持對(duì)靜態(tài)方法的 Mock[2],雖然是處于孵化階段,但對(duì)于我們做單元測(cè)試而言是已經(jīng)足夠了。
決勝之機(jī)
大多數(shù)項(xiàng)目使用了 Spring 或 Spring Boot 作為基礎(chǔ)框架,研發(fā)只需要關(guān)心業(yè)務(wù)邏輯即可。
在代碼例子中將使用 Junit5 的版本,因此要求 Spring boot版本必須是2.2.0版本或以上,采用 Mockito3.5.11 的版本作為 Mock 框架,減少項(xiàng)目對(duì) PowerMock 的依賴,另外還有一個(gè)重要原因是因?yàn)槟壳癙owerMock不支持 Junit5,無法在引入 PowerMock 后使用Junit5 的相關(guān)功能及API,本文項(xiàng)目代碼地址:https://github.com/GoQeng/spring-mockito3-demo。
maven 配置
1.8 3.5.11 1.10.15 3.13.4 5.1.48 0.8.6 5.6.2 1.1.1 2.1.3 3.8.1 2.12.4 1.4.197 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-test test org.mockito mockito-core org.junit.vintage junit-vintage-engine org.mockito mockito-core ${mockito.version} compile net.bytebuddy byte-buddy net.bytebuddy byte-buddy-agent net.bytebuddy byte-buddy ${byte-buddy.version} net.bytebuddy byte-buddy-agent ${byte-buddy.version} test org.mockito mockito-inline ${mockito.version} test org.mybatis.spring.boot mybatis-spring-boot-starter ${mybatis-spring.version} org.redisson redisson-spring-boot-starter ${redisson-spring.version} junit junit compile mysql mysql-connector-java ${mysql.version} org.jacoco jacoco-maven-plugin ${jacoco.version} org.junit.jupiter junit-jupiter ${junit-jupiter.version} test org.junit.platform junit-platform-runner ${junit-platform.version} junit junit com.h2database h2 ${h2.version} test junit junit org.apache.maven.plugins maven-surefire-plugin ${maven-surefire.version} test test once false **/SuiteTest.java org.apache.maven.plugins maven-compiler-plugin ${maven-compiler.version} 8 8 org.jacoco jacoco-maven-plugin ${jacoco.version} prepare-agent report test report org.jacoco jacoco-maven-plugin report
maven 運(yùn)行測(cè)試用例是通過調(diào)用 maven 的 surefire 插件并 fork 一個(gè)子進(jìn)程來執(zhí)行用例的。
forkMode 屬性指明是為每個(gè)測(cè)試創(chuàng)建一個(gè)進(jìn)程還是所有測(cè)試共享同一個(gè)進(jìn)程完成,forkMode 設(shè)置值有 never、once、always 、pertest 。
- pretest:每一個(gè)測(cè)試創(chuàng)建一個(gè)新進(jìn)程,為每個(gè)測(cè)試創(chuàng)建新的JVM進(jìn)程是單獨(dú)測(cè)試的最徹底方式,但也是最慢的,不適合持續(xù)回歸。
- once:在一個(gè)進(jìn)程中進(jìn)行所有測(cè)試。once 為默認(rèn)設(shè)置,在持續(xù)回歸時(shí)建議使用默認(rèn)設(shè)置。
- always:在一個(gè)進(jìn)程中并行的運(yùn)行腳本,Junit4.7 以上版本才可以使用,surefire 的版本要在 2.6 以上提供這個(gè)功能,其中 threadCount 執(zhí)行時(shí),指定可分配的線程數(shù)量,只和參數(shù) parallel 配合使用有效,默認(rèn)為 5。
- never:從不創(chuàng)建新進(jìn)程進(jìn)行測(cè)試。
環(huán)境準(zhǔn)備
在項(xiàng)目中 test 目錄下建立測(cè)試入口類 TestApplication.java,將外部依賴 Redis 單獨(dú)配置到 DependencyConfig.java 中,同時(shí)需要在 TestApplication.class 中排除對(duì) Redis 或 Mongodb 的自動(dòng)注入配置等。
注意:將外部依賴配置到DependencyConfig并不是必要的,此步驟的目的是為了避免每個(gè)單元測(cè)試類運(yùn)行時(shí)都會(huì)重啟 Spring 上下文,可采用 @MockBean 的方式在代碼中引入外部依賴資源替代此方法。
- @Configuration
- public class DependencyConfig {
- @Bean
- public RedissonClient getRedisClient() {
- return Mockito.mock(RedissonClient.class);
- }
- @Bean
- public RestTemplate restTemplate() {
- return Mockito.mock(RestTemplate.class);
- }
- }
接著在測(cè)試入口類中通過 @ComponentScan 對(duì)主入口啟動(dòng)類 Application.class 及 RestClientConfig.class 進(jìn)行排除。
- @SpringBootApplication
- @ComponentScan(excludeFilters = @ComponentScan.Filter(
- type = FilterType.ASSIGNABLE_TYPE,
- classes = {Application.class, RestClientConfig.class}))
- @MapperScan("com.example.mockito.demo.mapper")
- public class TestApplication {
- }
為了不單獨(dú)寫重復(fù)的代碼,我們一般會(huì)把單獨(dú)的代碼抽取出來作為一個(gè)公共基類,其中 @ExtendWith(SpringExtension.class) 注解目的是告訴 Spring boot 將使用 Junit5 作為運(yùn)行平臺(tái),如果想買中使用 Junit4 的話,則需要使用 @RunWith(SpringRunner.class) 注解告知用 SpringRunner 運(yùn)行啟動(dòng)。
- @SpringBootTest(classes = TestApplication.class)@ExtendWith(SpringExtension.class)
- public abstract class SpringBaseTest {}
準(zhǔn)備好配置環(huán)境后,我們便可以開始對(duì)項(xiàng)目的 Mapper、Service、Web 層進(jìn)行測(cè)試了。
Mapper層測(cè)試
對(duì) Mapper 層的測(cè)試主要是驗(yàn)證 SQL 語句及 Mybatis 傳參等準(zhǔn)確性。
- server:
- port: 8080
- spring:
- test:
- context:
- cache:
- max-size: 42
- main:
- allow-bean-definition-overriding: true
- datasource:
- url: jdbc:h2:mem:test;MODE=MYSQL;DB_CLOSE_DELAY=-1;INIT=runscript from 'classpath:init.sql'
- username: sa
- password:
- driverClassName: org.h2.Driver
- hikari:
- minimum-idle: 5
- maximum-pool-size: 15
- auto-commit: true
- idle-timeout: 30000
- pool-name: DatebookHikariCP
- max-lifetime: 1800000
- connection-timeout: 10000
- connection-test-query: SELECT 1
- mybatis:
- type-aliases-package: com.example.mockito.demo.domain
- mapper-locations:
- - classpath:mapper/*.xml
對(duì) Mapper 層的測(cè)試并沒有采取 Mock 的方式,而是采用 H2 內(nèi)存數(shù)據(jù)庫的方式模擬真實(shí)數(shù)據(jù)庫,同時(shí)也避免由于測(cè)試數(shù)據(jù)給真實(shí)數(shù)據(jù)庫帶來的影響。
- jdbc:h2:mem:test;MODE=MYSQL;DB_CLOSE_DELAY=-1;INIT=runscript from 'classpath:init.sql'
配置 H2 數(shù)據(jù)庫信息,同時(shí) INIT 指定在創(chuàng)建連接時(shí)會(huì)執(zhí)行類路徑下的 init.sql 即建表 SQL 。
- public class DemoMapperTest extends SpringBaseTest {
- @Resource
- private DemoMapper demoMapper;
- @Test
- public void testInsert() {
- Demo demo = new Demo();
- demo.setName("test");
- demoMapper.insert(demo);
- Integer id = demo.getId();
- Demo model = demoMapper.getDetail(id);
- Assert.assertNotNull(model);
- Assert.assertEquals(demo.getName(), model.getName());
- }
- @Test
- public void testGetList() {
- Demo demo = new Demo();
- demo.setName("test");
- demoMapper.insert(demo);
- List
demoList = demoMapper.getList(); - Assert.assertNotNull(demoList);
- Assert.assertEquals(1, demoList.size());
- }
- }
Service層測(cè)試
一般項(xiàng)目的業(yè)務(wù)邏輯寫在 service 層,需要寫更多的測(cè)試用例驗(yàn)證業(yè)務(wù)代碼邏輯性及準(zhǔn)確性,盡可能的覆蓋到業(yè)務(wù)代碼的分支邏輯。
- public class DemoServiceTest extends SpringBaseTest {
- @Resource
- private DemoService demoService;
- @Resource
- private RedissonClient redissonClient;
- @Resource
- private RestTemplate restTemplate;
- @BeforeEach
- public void setUp() {
- MockitoAnnotations.openMocks(this);
- }
- @Test
- public void testGetList() {
- //測(cè)試第一個(gè)分支邏輯
- RAtomicLong rAtomicLong = Mockito.mock(RAtomicLong.class);
- Mockito.when(redissonClient.getAtomicLong(ArgumentMatchers.anyString())).thenReturn(rAtomicLong);
- long count = 4L;
- Mockito.when(rAtomicLong.incrementAndGet()).thenReturn(count);
- List
demoList = demoService.getList(); - Assert.assertTrue(demoList != null && demoList.size() == 1);
- Demo demo = demoList.get(0);
- Assert.assertNotNull(demo);
- Assert.assertEquals(Integer.valueOf(4), demo.getId());
- Assert.assertEquals("testCount4", demo.getName());
- //測(cè)試第二個(gè)分支邏輯
- Mockito.when(redissonClient.getAtomicLong(ArgumentMatchers.anyString())).thenReturn(rAtomicLong);
- count = 1L;
- Mockito.when(rAtomicLong.incrementAndGet()).thenReturn(count);
- MockedStatic
aesUtilMockedStatic = Mockito.mockStatic(AESUtil.class); - aesUtilMockedStatic.when(() -> AESUtil.encrypt(ArgumentMatchers.eq("test"), ArgumentMatchers.eq("1234567890123456")))
- .thenReturn("demo");
- demoList = demoService.getList();
- Assert.assertTrue(demoList != null && demoList.size() == 1);
- Demo encryptDemo = demoList.get(0);
- Assert.assertNotNull(encryptDemo);
- Assert.assertEquals(Integer.valueOf(1), encryptDemo.getId());
- Assert.assertEquals("testEncrypt", encryptDemo.getName());
- //測(cè)試第三個(gè)分支邏輯
- Mockito.when(redissonClient.getAtomicLong(ArgumentMatchers.anyString())).thenReturn(rAtomicLong);
- count = 1L;
- Mockito.when(rAtomicLong.incrementAndGet()).thenReturn(count);
- //執(zhí)行真實(shí)方法
- aesUtilMockedStatic.when(() -> AESUtil.encrypt(ArgumentMatchers.eq("test"), ArgumentMatchers.eq("1234567890123456")))
- .thenCallRealMethod();
- String mobileUrl = "https://tcc.taobao.com/cc/json/mobile_tel_segment.htm?tel=";
- MobileInfoDTO mobileInfoDTO = new MobileInfoDTO();
- mobileInfoDTO.setName("testMobile");
- mobileInfoDTO.setLocation("testLocation");
- Mockito.when(restTemplate.getForObject(mobileUrl, MobileInfoDTO.class)).thenReturn(mobileInfoDTO);
- demoList = demoService.getList();
- Assert.assertNotNull(demoList);
- Assert.assertEquals(1, demoList.size());
- Demo demo1 = demoList.get(0);
- Assert.assertNotNull(demo1);
- Assert.assertEquals(mobileInfoDTO.getName(), demo1.getName());
- }
- }
WEB層測(cè)試
- public class DemoControllerTest extends SpringBaseTest {
- private MockMvc mockMvc;
- @Mock
- p
文章名稱:外部依賴太多,如何寫 Java 單元測(cè)試?
轉(zhuǎn)載來源:http://fisionsoft.com.cn/article/ccsgdco.html


咨詢
建站咨詢
