Java測試框架Mockito源碼分析


1.Mockito簡介

測試驅動的開發(Test Driven Design, TDD)要求我們先寫單元測試,再寫實現代碼。在寫單元測試的過程中,一個很普遍的問題是,要測試的類會有很多依賴,這些依賴的類/對象/資源又會有別的依賴,從而形成一個大的依賴樹,要在單元測試的環境中完整地構建這樣的依賴,是一件很困難的事情。
所幸,我們有一個應對這個問題的辦法:Mock。簡單地說就是對測試的類所依賴的其他類和對象,進行mock - 構建它們的一個假的對象,定義這些假對象上的行為,然后提供給被測試對象使用。被測試對象像使用真的對象一樣使用它們。用這種方式,我們可以把測試的目標限定於被測試對象本身,就如同在被測試對象周圍做了一個划斷,形成了一個盡量小的被測試目標。Mock的框架有很多,最為知名的一個是Mockito,這是一個開源項目,使用廣泛。官網:http://site.mockito.org/

2.Mockito框架設計

首先我們要知道,Mock對象這件事情,本質上是一個Proxy模式的應用。Proxy模式說的是,在一個真實對象前面,提供一個proxy對象,所有對真實對象的調用,都先經過proxy對象,然后由proxy對象根據情況,決定相應的處理,它可以直接做一個自己的處理,也可以再調用真實對象對應的方法。示例:

代碼中的注釋描述了代碼的邏輯:先創建mock對象,然后設置mock對象上的方法get,指定當get方法被調用,並且參數為0的時候,返回”one”;然后,調用被測試方法(被測試方法會調用mock對象的get方法);最后進行驗證。邏輯很好理解,但是初次看到這個代碼的人,會覺得有點兒奇怪,總感覺這個代碼跟一般的代碼不太一樣。讓我們仔細想想看,下面這個代碼:

// 設置mock對象的行為 - 當調用其get方法獲取第0個元素時,返回”one”
Mockito.when(mockedList.get(0)).thenReturn(“one”);

public class MockDemo { // 創建mock對象 List<String> mockedList = Mockito.mock(List.class); @Before public void setUp(){ // 設置mock對象的行為 - 當調用其get方法獲取第0個元素時,返回"one" Mockito.when(mockedList.get(0)).thenReturn("one"); } @Test public void mockDemoTest(){ // 使用mock對象 - 會返回前面設置好的值"one",即便列表實際上是空的 String str = mockedList.get(0); Assert.assertTrue("one".equals(str)); Assert.assertTrue(mockedList.size() == 0); } }

 

如果按照一般代碼的思路去理解,是要做這么一件事:調用mockedList.get方法,傳入0作為參數,然后得到其返回值(一個object),然后再把這個返回值傳給when方法,然后針對when方法的返回值,調用thenReturn。好像有點不通?mockedList.get(0)的結果,語義上是mockedList的一個元素,這個元素傳給when是表示什么意思?所以,我們不能按照尋常的思路去理解這段代碼。實際上這段代碼要做的是描述這么一件事情:當mockedList的get方法被調用,並且參數的值是0的時候,返回”one”。很不尋常,對嗎?如果用平常的面向對象的思想來設計API來做同樣的事情,估計結果是這樣的:

Mockito.returnValueWhen(“one”, mockedList, “get”, 0);
第一個參數描述要返回的結果,第二個參數指定mock對象,第三個參數指定mock方法,后面的參數指定mock方法的參數值。這樣的代碼,更符合我們看一般代碼時候的思路。

但是,把上面的代碼跟Mockito的代碼進行比較,我們會發現,我們的代碼有幾個問題:
1.不夠直觀
2.對重構不友好
第二點尤其重要。想象一下,如果我們要做重構,把get方法改名叫fetch方法,那我們要把”get”字符串替換成”fetch”,而字符串替換沒有編譯器的支持,需要手工去做,或者查找替換,很容易出錯。而Mockito使用的是方法調用,對方法的改名,可以用編譯器支持的重構來進行,更加方便可靠。

3.實現分析

明確了Mockito的方案更好之后,我們來看看Mockito的方案是如何實現的。首先我們要知道,Mock對象這件事情,本質上是一個Proxy模式的應用。Proxy模式說的是,在一個真實對象前面,提供一個proxy對象,所有對真實對象的調用,都先經過proxy對象,然后由proxy對象根據情況,決定相應的處理,它可以直接做一個自己的處理,也可以再調用真實對象對應的方法。Proxy對象對調用者來說,可以是透明的,也可以是不透明的。

Java本身提供了構建Proxy對象的API:Java Dynamic Proxy API,而Mockito是用Cglib來實現的。
下面看下運行時期Cglib生成的Mock代理對象的.class文件是怎么樣的

public class List$$EnhancerByMockitoWithCGLIB$$d85c0201 implements List, Factory { ........ private static final Method CGLIB$get$9$Method; ........ public final boolean removeAll(Collection var1) { MethodInterceptor var10000 = this.CGLIB$CALLBACK_0; if(this.CGLIB$CALLBACK_0 == null) { CGLIB$BIND_CALLBACKS(this); var10000 = this.CGLIB$CALLBACK_0; } if(var10000 != null) { Object var2 = var10000.intercept(this, CGLIB$removeAll$26$Method, new Object[]{var1}, CGLIB$removeAll$26$Proxy); return var2 == null?false:((Boolean)var2).booleanValue(); } else { return super.removeAll(var1); } } final boolean CGLIB$retainAll$27(Collection var1) { return super.retainAll(var1); } .......... case -208030418: if(var10000.equals("get(I)Ljava/lang/Object;")) { return CGLIB$get$9$Proxy; } break; ......... } 

 

可以看到Mokito利用Cglib為List的所有方法都做了Mock實現,但是我們只對get方法做了Stub,所以只用關注這些代碼

CGLIB$get$9$Proxy = MethodProxy.create(var1, var0, "(I)Ljava/lang/Object;", "get", "CGLIB$get$9"); case -208030418: if(var10000.equals("get(I)Ljava/lang/Object;")) { return CGLIB$get$9$Proxy; } break;

 

 

看到第一句是不是和我上面說的面向對象的寫法很像
這里寫圖片描述
下面我們來看看,到底如何實現文章開頭的示例中的API。如果我們仔細分析,就會發現,示例代碼最難理解的部分是建立Mock對象(proxy對象),並配置好mock方法(指定其在什么情況下返回什么值)。只要設置好了這些信息,后續的驗證是比較容易理解的,因為所有的方法調用都經過了proxy對象,proxy對象可以記錄所有調用的信息,供驗證的時候去檢查。下面我們重點關注stub配置的部分,也就是我們前面提到過的這一句代碼:

// 設置mock對象的行為 - 當調用其get方法獲取第0個元素時,返回"one" Mockito.when(mockedList.get(0)).thenReturn("one");

 

當when方法被調用的時候,它實際上是沒有辦法獲取到mockedList上調用的方法的名字(get),也沒有辦法獲取到調用時候的參數(0),它只能獲得mockedList.get方法調用后的返回值,而根本無法知道這個返回值是通過什么過程得到的。這就是普通的java代碼。為了驗證我們的想法,我們實際上可以把它重構成下面的樣子,不改變它的功能:

  // 設置mock對象的行為 - 當調用其get方法獲取第0個元素時,返回"one" String str = mockedList.get(0); Mockito.when(str).thenReturn("one");

 

這對Java開發者來說是常識,那么這個常識對Mockito是否還有效呢。我們把上面的代碼放到Mockito測試中實際跑一遍,結果跟前面的寫法是一樣的,證明了常識依然有效。

有了上面的分析,我們基本上可以猜出來Mockito是使用什么方式來傳遞信息了 —— 不是用方法的返回值,而是用某種全局的變量。當get方法被調用的時候(調用的實際上是proxy對象的get方法),代碼實際上保存了被調用的方法名(get),以及調用時候傳遞的參數(0),然后等到thenReturn方法被調用的時候,再把”one”保存起來,這樣,就有了構建一個stub方法所需的所有信息,就可以構建一個stub方法了。

上面的設想是否正確呢?Mockito是開源項目,我們可以從代碼當中驗證我們的想法。下面是MockHandlerImpl.handle()方法的代碼。代碼來自Mockito在Github上的代碼。

public Object handle(Invocation invocation) throws Throwable {
     if (invocationContainerImpl.hasAnswersForStubbing()) { ... } ... InvocationMatcher invocationMatcher = matchersBinder.bindMatchers( mockingProgress.getArgumentMatcherStorage(), invocation ); mockingProgress.validateState(); // if verificationMode is not null then someone is doing verify() if (verificationMode != null) { ... } // prepare invocation for stubbing invocationContainerImpl.setInvocationForPotentialStubbing(invocationMatcher); OngoingStubbingImpl<T> ongoingStubbing = new OngoingStubbingImpl<T>(invocationContainerImpl); mockingProgress.reportOngoingStubbing(ongoingStubbing); ... }

 

注意第1行,第6-9行,可以看到方法調用的信息(invocation)對象被用來構造invocationMatcher對象,然后在第19-21行,invocationMatcher對象最終傳遞給了ongoingStubbing對象。完成了stub信息的保存。這里我們忽略了thenReturn部分的處理。有興趣的同學可以自己看代碼研究。

看到這里,我們可以得出結論,mockedList對象的get方法的實際處理函數是一個proxy對象的方法(最終調用MockHandlerImpl.handle方法),這個handle方法除了return返回值之外,還做了大量的處理,保存了stub方法的調用信息,以便之后可以構建stub。

4.總結

通過以上的分析我們可以看到,Mockito在設計時實際上有意地使用了方法的“副作用”,在返回值之外,還保存了方法調用的信息,進而在最后利用這些信息,構建出一個mock。而這些信息的保存,是對Mockito的用戶完全透明的。“模式”告訴我們,在設計方法的時候,應該避免副作用,一個方法在被調用時候,除了return返回值之外,不應該產生其他的狀態改變,尤其不應該有“意料之外”的改變。但Mockito完全違反了這個原則,Mockito的靜態方法Mockito.anyString(), mockInstance.method(), Mockito.when(), thenReturn(),這些方法,在背后都有很大的“副作用” —— 保存了調用者的信息,然后利用這些信息去完成任務。這就是為什么Mockito的代碼一開始會讓人覺得奇怪的原因,因為我們平時不這樣寫代碼。

然而,作為一個Mocking框架,這個“反模式”的應用實際上是一個好的設計。就像我們前面看到的,它帶來了非常簡單的API,以及編譯安全,可重構等優良特性。違反直覺的方法調用,在明白其原理和一段時間的熟悉之后,也顯得非常的自然了。設計的原則,終究是為設計目標服務的,原則在總結出來之后,不應該成為僵硬的教條,根據需求靈活地應用這些原則,才能達成好的設計。在這方面,Mockito堪稱一個經典案例。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM