一、百變怪 Mockito
Mockito可謂是Java世界的百變怪,使用它,可以輕易的復制出各種類型的對象,并與之進行交互。
1.1 對象“復制”
// 列表
List mockList = mock(List.class);
mockList.add(1);
mockList.clear();
// Socket對象
Socket mockSocket = mock(Socket);
mockSocket.connect(new InetSocketAddress(8080));
mockSocket.close();
1.2 技能復制
List mockList = mock(List.class);
mockList.add(1); // 簡單交互
mockList.get(1); // 返回值為null
mockList.size(); // 返回值為0
雖然復制出來的對象上的所有方法都能被調用,但好像這個百變怪的技能有點弱呢...
其實,是使用方式不對,這個百變怪掌握的僅僅是基礎技能,對于有返回值的調用,只會返回默認的返回值,在需要返回對象的場合,返回null
,需要返回int
的場合,返回0
。其他的默認返回值,見下表:
// todo 默認返回值表
要讓它能按我們的需要展現技能(方法),需要事先“教會”它。
List mockList = mock(List.class);
when(mockList.get(anyInt()).thenReturn(1);
when(mockList.size()).thenReturn(1, 2, 3);
assertEquals("預期返回1", 1, mockList.get(1)); // pass
assertEquals("預期返回1", 1, mockList.get(2)); // pass
assertEquals("預期返回1", 1, mockList.get(3)); // pass
assertEquals("預期返回1", 1, mockList.size()); // pass
assertEquals("預期返回2", 2, mockList.size()); // pass
assertEquals("預期返回3", 3, mockList.size()); // pass
上面的代碼,我們教會了這個百變怪:
- 只要調用
get
方法,不管參數是什么,都返回1
; - 對于
size
方法調用,第一次返回1
,第二次調用返回2
,第三次開始,則返回3
。
是的,這個百變怪就是這么的笨,只會有樣學樣。看起來一點用都沒有。
1.3 驗證
但是呢,雖然它笨,但是它卻具備一些“笨方法”,也不算沒有用。
verify(mockList, never()).clear(); // 從未調用過clear方法
verify(mockList, times(2)).get(1); // get(1)方法調用了2次
verify(mockList, times(3)).get(anyInt()); // get(任意數字)調用了3次
verfiy(mockList, times(4)).size(); // 這里會失敗,因為上面我們只調用了size方法3次
可以看到,這個百變怪雖然笨,但不傻,對于它自己做過了什么,它是記得一清二楚的。至于它還有什么其他技能,可以到官網看下他的使用說明書詳細了解。
1.4 小結
可以看到,雖然Mockito在正式的場合(生產環境)下派不上什么用場,但在訓練場(測試環境)上,卻能夠成為一個相當不錯的陪練。
所以,Mockito是一個適用于單元測試的mock庫。在單元測試中,可以通過它來方便的生成模擬對象。便于進行測試。
二、Mockito與單元測試
2.1 例
假設我們有一段業務邏輯,需要對給定的請求做處理,在這種情況下,倘若要手工構造發起一個請求,那想必是很麻煩蛋疼。首先我們需要把代碼編譯部署到測試服務器上,然后構造并發起一個請求,等待服務器接收到請求后,交給我們的業務進行處理。如下:
// 業務代碼
public boolean handleRequest(HttpServletRequest request) {
String module = request.getParameter("module");
if ("live".equals(module)) {
// handle module live request
return true;
} else if ("user".equals(module)) {
// handle module user request
return true;
}
return false;
}
為了測試這么一點點代碼,就需要我們額外付出那么多的操作,對于追求效率的程序員來說,這種重復操作&等待簡直就是慢性自殺。這里的代碼還是相對簡單的,要是請求的內容更加復雜,難道還要花上大把時間研究如何構造出這么一個Http請求嗎?
其實,測試這段邏輯,我們想要做的事情其實很簡單,給定一個特定的輸入,驗證其輸出結果是否正確。也就是,驗證的過程,應該盡可能的簡單方便,把大部分的時間耗費在驗證過程上絕對是有問題的。
如果我們使用單元測試,搭配Mockito,完全可以寫出如下測試,在代碼提交之前,先在本地的JVM上過一遍測試。
@Test
public void handleRequestTestLive() throws Exception {
HttpServletRequest request = mock(HttpServletRequest);
when(request.getParameter("module")).thenReturn("live");
boolean ret = handleRequest(request);
assertEquals(true, ret)
}
@Test
public void handleRequestTestUser() throws Exception {
HttpServletRequest request = mock(HttpServletRequest);
when(request.getParameter("module")).thenReturn("user");
boolean ret = handleRequest(request);
assertEquals(true, ret)
}
@Test
public void handleRequestTestNone() throws Exception {
HttpServletRequest request = mock(HttpServletRequest);
when(request.getParameter("module")).thenReturn(null);
boolean ret = handleRequest(request);
assertEquals(false, ret)
}
首先,我們模擬出一個假對象,并設定這個假對象的行為,這個假對象的行為會影響我們業務邏輯的結果,所以我們可以在不同的測試用例里,設定假對象返回不同的行為,這樣我們就能驗證各種輸入下,我們的業務邏輯是不是能夠按我們的設想正常工作。
2.2 Mockito 原理剖析
Ok,到現在為止,我們通過幾個例子簡單的展示了Mockito,以及它在單元測試中起到作用。從例子中可以看到,Mockito的使用是很直觀的,使用起來行云流水,就跟說話一樣自然。某種程度上,也可以看做代碼即注釋
的一種表現。當然這有點扯遠了。
Mockito的這種神乎其技的使用方式,使得我在一開始見到它的時候,感到驚訝,驚訝之余又感到不解。
如mock(List.class)
,怎么就能夠從List.class
這個接口搞出一個可以用的對象?when(mockList.size()).thenReturn(20)
這種,竟然就能干預到mock對象的執行,插樁返回了20。mockList.size()
本身不就是一個方法調用嗎?verify(mockList, never()).add(10)
,這種驗證方式又是通過什么黑科技實現的???
看著Mockito
的使用文檔的我,當時真是一臉黑人問號。
后來,從我有限的知識儲備里,我想到了mock
的實現方式可能是使用泛型 + 動態代理
實現,當想到這種組合的時候,我不禁感慨庫作者的思維的精妙,所以我決定研究下Mockito的源碼,看看作者是怎么做到的。
當然,后來我發現,泛型是用到了(廢話),動態代理技術卻沒有用到。好了,閑話不多說,下面來講講Mockito
的實現。由于在座同學,平時使用Java應該不多,所以這里我就不深入講解細節,會比較偏向原理性的東西。
2.3 Mock
讓我們來分析一下,要mock一個對象,我們需要做什么。
- 首先需要知道要Mock的對象的類型,這樣我們才能生成這個類型的對象
- 為了生成這個類型的對象,那么這個類型需要是能實例化的,但如果這個類型是抽象類或者一個接口?要怎么辦?我們知道,抽象類和接口需要被實現,才能實例化,因此,最自然的方式就是,繼承自這個類型,然后給這些方法一個空實現。
- 有了可以實例化的類型,接下來就好辦了:實例化這個類型,并上轉型成我們的目標類,返回。
總結起來就是:給到要mock的類型、生成一個繼承這個類型的類、實例化生成的類、得到mock對象。
Mockito的源碼里正是這么做的:
- 暴露出Mockito.mock接口給使用者
- 得到要mock的類型,進行一些設置,然后一路傳遞到
SubclassBytecodeGenerator
,由它來生成mock類型的子類 - 得到這個類型后,
SubclassByteBuddyMockMaker
將其實例化
第二步的實現借助了ByteBuddy
這個框架,這個框架可以直接生成Java的類,然后通過ClassLoader加載進來使用。這里就不深入了。
第三步實例化,實例化使用了objenesis
,一個能在不同平臺上實例化一個類的庫。
經過這幾步,就得到了一個可以用來操作的模擬對象。
實現的思路大致是這樣,代碼里的處理還有很多細節性的部分,這里不進行源碼探究,就不多講了
2.4 打樁
when這一步要實現的功能是打樁。
那么,對于when(mockType.someMethod()).thenReturn(value)
這樣的方法調用,該怎么實現?
一開始我以為方法調用的返回值有貓膩,返回值唯一標識一次方法調用,通過在內部記錄這個值,來返回特定的值。但對于每個方法調用,返回一個特定的返回值并不可能,何況有的方法調用并沒有返回值。
這個功能Mockito是這么實現的:
在mock那一步,我們知道了Mockito生成了一個派生類,派生類里的所有方法調用,也已經被hook掉,即所有的方法調用,并不會執行到原有的實現邏輯里,而是會返回一個默認值。
所有的方法調用最終都會交由MockHandlerImpl.handle
來執行。這個類很重要,可以說是Mockito整個功能的核心所在。
在進行方法調用的時候,Mockito會假定這個方法調用需要被打樁,生成一個和這個方法調用相對應的OngoingStubbing
對象,將這個對象暫時存起來。
當when
方法執行的時候,就會取出這個暫存的OngoingStubbing
對象返回,這樣我們就能在這上面打樁(調用thenReturn等方法),返回我們需要的值了。打樁完畢會生成一個Answer
對象,存放到一個鏈表里。后面調用對應的方法的時候,就會從這個鏈表內找到對應的Answer
對象,從中獲取對應的值返回。
2.5 驗證
方法的執行都被我們攔截了,要驗證方法的執行也就不是什么難事了。但還是過一下。
回憶下,驗證的代碼verify(mockList, times(2)).get(anyInt())
。為了達成這樣的效果,實現里必須:
- 在verify方法的執行過程里,記錄下要驗證的對象,以及要驗證的參數
- 在執行方法調用的時候,取出要驗證的對象、驗證的參數,執行驗證。
當了解了Mockito的設計之后,這一切都順理成章。這里就不詳細說了,如果大家有興趣,可以去看下Mockito的源碼。
Mockito這個庫的設計思路很特別,它的功能的實現并不是在一個執行過程里干完,而是分階段分步驟的執行。但Mockito又很好的保證了這些在不同時空里執行的步驟能夠準確的結合起來,共同完成這一個過程。更重要的是,在這種情況下,它所暴露出來的API依舊簡潔優雅,對使用者來說幾乎是無感的。
三、單元測試
再好的工具,如果沒有使用起來,也只是一個擺設。那么介紹完了Mockito,接下來我們回過頭來聊聊單元測試。
首先是幾個概念:
3.1 Mock
Mock一詞指效仿、模仿,在單元測試里,使用mock來構造一個“替身”。這個替身主要用于作為被測類的依賴關系的替代。
依賴關系 – 依賴關系是指在應用程序中一個類基于另一個類來執行其預定的功能.依賴關系通常都存在于所依賴的類的實例變量中.
被測類 – 在編寫單元測試的時候, “單元”一詞通常代表一個單獨的類及為其編寫的測試代碼. 被測類指的就是其中被測試的類.
為什么需要mock呢?
真實對象具有不可確定的行為,產生不可預測的效果,(如:股票行情,天氣預報
真實對象很難被創建的
真實對象的某些行為很難被觸發
真實對象實際上還不存在的(和其他開發小組或者和新的硬件打交道)等等
在這些情形下,使用Mock能大大簡化我們的測試難度。舉個例子:
假定我們有如上的關系圖:
類A依賴于類B和類C
類B又依賴于類D和類E
為了測試A,我們需要整個依賴樹都構造出來,這未免太麻煩
使用Mock,就能將結構分解,像這樣。從圖中可以清晰的看出,我們的依賴樹被大大的簡化了。Mock對象就是在測試的過程中,用來作為真實對象的替代品。使用了Mock技術的測試,也就能稱為Mock測試了。
3.2 Stub
Stub就是打樁。
Stubbing就是告訴模擬對象當與之交互時執行何種行為過程。通常它可以用來提供那些測試所需的公共屬性(像getters和setters)和公共方法。
使用Stub,可以根據我們的需要返回一個特殊的值、拋出一個錯誤、觸發一個事件,或者,自定義方法在不同參數下的不同行為。
而這并不會增大我們的工作量,相反,減少了我們的工作量。使用Stub甚至能讓我們在實現被模擬的對象的方法之前去測試我們的代碼。
Stub進一步增強了Mock對象的能力。Mock本質上是對依賴的模擬,它使得我們擁有了一個依賴。但在測試中,除了依賴,我們還需要對這個依賴的行為進行控制,這就是Stub要做的事情。
Stub讓我們能對依賴的行為進行模擬,省略具體的實現邏輯,直接控制行為的結果,一般用來提供測試時所需的測試數據,驗證交互是否符合預期。
3.3 使用Mock和Stub的好處
- 提前創建測試,比如進行TDD
- 團隊可以并行工作
- 創建演示demo
- 為無法/難以獲取的資源編寫測試
- 隔離系統
- 作為模擬數據交付給用戶(假數據)
3.4 測試流程
進行單元測試時,我們只需關心三樣東西: 設置測試數據,設定預期結果,驗證結果。并不是所有的測試都包含著三樣,有的只涉及設置測試數據,有的只涉及設定預期結果和驗證.
模擬替換外部依賴、執行測試代碼、驗證執行結果是否符合預期。簡稱3A原則:Arrange、Act、Assert
3.5 單元測試不是集成測試
剛接觸單元測試的時候,一直很迷惑,我的業務邏輯那么多那么復雜,這要怎么做單元測試呢?比如說一個登陸功能,雖然它僅僅是一個登陸功能,但它背后要干的事情可不少:驗證用戶名,驗證密碼,判斷網絡,發起網絡請求,等待請求結果,根據結果執行不同的邏輯。
想想都頭大,這樣的單元測試要怎么寫?
答:這樣的單元測試不用寫。
我們給這個東西做測試的時候,不是測整個登陸流程。這種測試在測試領域里稱為集成測試,而不是單元測試。集成測試并不是我們(程序員)花精力的地方,而的是測試同事的業務范圍。
關于測試,有一個Test Pyramid理論,叫測試的金字塔模型。
Test Pyramid理論基本大意是,單元測試是基礎,是我們應該花絕大多數時間去寫的部分,而集成測試等應該是冰山上面能看見的那一小部分。
為什么是這樣呢?因為集成測試設置起來很麻煩,運行起來很慢,發現的bug少,在保證代碼質量、改善代碼設計方面更起不到任何作用,因此它的重要程度并不是那么高,也無法將它納入我們正常的工作流程中。
而單元測試則剛好相反,它運行速度超快,能發現的bug更多,在開發時能引導更好的代碼設計,在重構時能保證重構的正確性,因此它能保證我們的代碼在一個比較高的質量水平上。同時因為運行速度快,我們很容易把它納入到我們正常的開發流程中。
至于為什么集成測試發現的bug少,而單元測試發現的bug多,這里也稍作解釋,因為集成測試不能測試到其中每個環節的每個方面,某一個集成測試運行正確了,不代表另一個集成測試也能運行正確。而單元測試會比較完整的測試每個單元的各種不同的狀況、臨界條件等等。一般來說,如果每一個環節是對的,那么在很大的概率上,整個流程就是對的。雖然不能保證整個流程100%一定是對的。所以,集成測試需要有,但應該是少量,單元測試是我們應該花重點去做的事情。
3.6 為什么要進行單元測試
常見的理由有:
- 對軟件質量的提升
- 方便重構
- 節約時間
- 提升代碼設計
- ...
但以上的理由卻很難得到證明。軟件質量的提升,如何通過數據來表明?方便重構,這個必要性很大嗎?尤其是在工期緊張,功能優先的情況下。需求都做不完,哪有時間寫測試,更何談節約時間。至于代碼設計提升,更多的不是工程師的素養問題嗎。
那么單元測試有沒有別的作用?
當我們參與到新項目,接手維護舊模塊,其實挺讓人驚恐的。對項目結構的不熟悉、各模塊各部分之間的關聯也難以理清,有些還不一定能理清。經常改動一個地方,結果莫名其妙的引起了別的地方的問題,如果改動的是框架層上的東西,那更讓人蛋疼了。業務用法千千萬,一個一個手動測試,哪里來得及,就算來得及,重復幾遍也讓人蛋疼。
對于用戶量大的應用,如QQ音樂、全民K歌,一天幾千萬的DAU,出一個bug,crash率上漲、外網投訴量蹭蹭蹭的漲,遇上這種時候肯定是內心十萬個草泥馬...要是遇上一個特殊的場景,非必現,用戶復現路徑復雜,定位調試也要耗費大量時間。
這種情況下,單元測試才是一枚更好的解藥。單元測試僅是對一個代碼單元進行測試,保證一個代碼單元的正確可比保證整個APP的準確容易,遍歷這個代碼單元的所有參數輸入和輸出,也比驗證所有的用戶場景容易,重點是,跑一次單元測試,比運行一次手動測試快!而且還可以交給程序自動化。人的天性總是懶惰的。
另外一個,如果代碼中有一些陳年代碼,如果想要對其進行重構,如果沒有單元測試,想要動手去重構想必也是需要一定勇氣。而單元測試,可以成為我們的一道保障,讓我們在改動代碼的時候不需要顧慮太多,正確性由單元測試來驗證和保障。這也是<重構>一書里不斷強調的。
節省時間:
上面提到了Mock可以用來協同工作。這里舉個例子:
我們做需求的時候,對于有一定經驗,有一定代碼思想的人來說,當他拿到一個新的需求,他會先想想代碼的結構,應該有那些類,那些組件,什么責任應該劃分到哪里去,然后才開始動手寫代碼,這個是很自然的一個思維過程。
但這樣一來,我們要驗證我們的代碼正確性的時候,就只能等到每個部分都搞定在驗證了?這顯然是低效的,有的部分還涉及了前后臺聯動等外部條件的制約,每個部分都搞定了也不一定能測試。而且,每個未經測試的代碼整合在一起,出錯的時候往往還要花上相當的時間卻定位問題出在哪部分上,然后修改,部署/安裝,重復驗證。
如果有單元測試,結合Mock,我們就能在編寫每個小功能塊的同時,對其進行驗證。
使用單元測試,能夠給我們:
- 更快的結果反饋
- 帶來更少的bug(開發自測),也更容易發現bug(回歸測試)
- 節約時間(不在受限于外部條件的制約無法驗證)
- 更好的設計(為了寫出便于測試的代碼,會開始思考程序的架構是否合理,保持單一責任,減低耦合,傾向于組合,而不是繼承)
3.7 如何開展單元測試
- 從現在開始,一點一點的寫,有總好過沒有
- 在測試過程中,逐漸建立自己的工具箱,相似的場合測試大同小異,抽公共部分作為輔助類,便于測試
- 如果當前項目里沒有單元測試,引入起來有點困難,那么先在新的代碼里引入,后面慢慢調整項目結構,將測試覆蓋開去
四、參考資料
反模式的經典 - Mockito設計解析
JUnit + Mockito 單元測試(二)
Android單元測試(四):Mock以及Mockito的使用
5分鐘了解Mockito
Mockito 簡明教程
Mockito源碼解析
[譯] 使用強大的 Mockito 測試框架來測試你的代碼
Mockito:一個強大的用于 Java 開發的模擬測試框架
Android單元測試: 首先,從是什么開始
Android單元測試(二):再來談談為什么