提出問題,解答問題!這才是理解程式碼設計的正確方法

一瑜一琂 發表於 2022-05-14

上一篇我們通過呼叫關係,梳理出了TestRunner呼叫核心模型的流程。

本篇是《如何高效閱讀原始碼》專題的第十一篇,我們來回答流程梳理中遇到的一些問題,思考為什麼要這麼設計。

上一篇我們提出了幾個問題:

  • 為什麼使用Statement類?作用是什麼?

  • RunNotifier如何進行監聽的?

  • classBlock方法中,if判斷裡的邏輯是幹什麼用的呢?看方法名好像和BeforeClass、AfterClass註解有關係,它是怎麼處理的呢?

  • 為什麼要用Statement封裝一層來執行測試?所有的方法都在ParentRunner類裡面,直接呼叫不就好了嗎?

  • runChildren方法中為什麼這裡要構建一個Runnable來執行呢?

本節將來回答這些問題。

Statement的作用

其實,如果你熟悉設計模式,你應該能立刻認出來,Statement實現的是個命令模式。

提出問題,解答問題!這才是理解程式碼設計的正確方法

而命令模式的作用是什麼呢?將一個請求封裝為一個物件,從而使你可用不同的請求對客戶進行引數化;對請求排隊或記錄請求日誌,以及支援可取消的操作。
這也就是Statement的作用。Statement通過一個統一的物件來執行不同的測試行為
那為什麼要用命令模式呢?

我們回想一下,我們在執行測試的時候,每個測試的執行流程是不是並不完全相同?有的時候我們執行單個測試方法,而有的時候我們可能執行一個測試類。同時每個測試方法的執行流程也不完全相同,有的方法有Before方法或After方法要執行,而有的沒有;有的方法有BeforeClass和BeforeClass要執行,其它方法則沒有。這些不同的行為都是通過Statement來封裝。

那Statement具體是怎麼封裝呢?我們回過頭來看classBlock方法:

提出問題,解答問題!這才是理解程式碼設計的正確方法

首先childrenInvoker方法(見下圖)直接構建了一個Statement的匿名實現類,來包裝執行類裡所有符合條件的測試方法。
接著通過if判定裡的四個方法新增額外的執行邏輯。

提出問題,解答問題!這才是理解程式碼設計的正確方法

 

 

限於篇幅,我們就只看第一個withBeforeClasses方法,看這個名字,我們應該能猜到這個方法是為了處理被BeforeClass註解的方法的。

在看它的實現之前,我們可以想想,如果是我們來實現的話?我們該怎麼實現呢?或者我們可以換個問法,有沒有什麼方式能夠保證一個方法在另一個方法之前執行?你有沒有什麼思路呢?(在向下看之前,最好自己先思考一下)

比如,我們可以使用一個Statement包裝類,也就是使用裝飾模式。在執行這個Statement之前,先執行BeforeClass;或者我們也可以使用組合模式,構造一個父Statement,BeforeClass和原來的Statement作為葉子節點,不過此處要注意順序。

現在我們來看看JUnit裡面是怎麼實現的呢?

提出問題,解答問題!這才是理解程式碼設計的正確方法

首先,通過TestClass物件的getAnnotatedMethods方法找到所有有BeforeClass註解的方法。如果沒有對應註解的方法就直接返回原Statement,否則就構建一個RunBefores物件返回,很明顯這個RunBefores也是Statement的子類。

我們來看這個RunBefores類是如何實現來保證具有BeforeClass註解的方法先於Test註解的方法執行的。

提出問題,解答問題!這才是理解程式碼設計的正確方法

注意evaluate方法,首先先遍歷執行了BeforeClass註解的方法,然後執行了測試方法Statement物件的evaluate方法。
顯而易見,這裡使用的是裝飾模式。

裝飾模式:動態地給一個物件新增一些額外的職責

也就是說,JUnit通過裝飾模式動態的給測試方法新增了額外的職責。相信其它的方法不需要看你也能大概知道是怎麼實現的了吧?

RunNotifier的作用

RunNotifier的實現就更加的顯而易見了:觀察者模式!

觀察者模式:定義物件間的一種一對多的依賴關係,當一個物件的狀態發生改變時,所有依賴於它的物件都得到通知並被自動更新。

使用觀察者模式的作用就是解耦測試的執行與測試結果的展示。我們來看一下JUnit具體是怎麼做的。

我們接著上面的childrenInvoker方法往下看,childrenInvoker裡構建了一個Statement,實際呼叫的是ParentRunner裡的runChildren方法。

提出問題,解答問題!這才是理解程式碼設計的正確方法

這裡為什麼要用執行緒來執行呢?其實理由很簡單,這裡執行的是一個個的測試方法,每個測試方法之間是沒有關係的,所以這裡使用執行緒,可以提高測試的執行效率。

注意:雖然每個測試方法是獨立的,但是結果Listener是公用的,那這裡就涉及到了競爭問題。JUnit是如何保證執行緒安全的呢?這裡留給大家去原始碼裡查詢答案。提個醒,先了解下CopyOnWriteArrayList

執行緒方法裡執行的是runChild方法,而這是一個抽象方法,由子類實現。它有兩個實現類,一個是BlockJUnit4ClassRunner類,一個是Suite類,很明顯Suite是用來執行一批測試方法的。這個關係是組合模式的套路!Suite和BlockJunit4ClassRunner之間使用的肯定是組合模式。如果不信可自行驗證,這裡就不再梳理了。

我們這裡直接看BlockJUnit4ClassRunner的runChild方法。

提出問題,解答問題!這才是理解程式碼設計的正確方法

注意其中的methodBlock方法,回想一下上面的classBlock方法,能猜出來這裡的邏輯嗎?
最後到runLeaf方法,也就是最終執行的方法,我們來看notifier具體做了哪些工作。

提出問題,解答問題!這才是理解程式碼設計的正確方法

這裡相當於對statement執行的開始、結束和報錯階段進行了監聽,呼叫了不同的方法。而這些方法,最終委託到了註冊到TestNotifier的TestListener了。比如addFailure方法,最終呼叫的是TestListener的testFailure方法。

現在我們只要看一看哪些類繼承了TestListener類,就能知道測試結果有哪些處理方式了。(後文會從此處將整個執行流程串聯起來
我們以最簡單的TextListener為例。

提出問題,解答問題!這才是理解程式碼設計的正確方法

 

 

可以看到,這裡只是簡單的將其輸出到命令列。
如果你想要其它的結果處理方式,你只需要編寫一個類實現TestListener類即可。
我們反過來想一下,如果不使用監聽器模式,這裡的測試執行和結果處理是否就耦合到了一起,且沒有擴充套件性呢?

總結

在本文中,為了回答上文提出來的問題,我們對核心程式碼流程進行了程式碼級別的梳理,並理解了為什麼要這麼設計,如果不這麼設計又會帶來什麼問題。

同時,你應該也體會到了,熟悉設計模式能極大的提高閱讀原始碼的效率。假設你不瞭解設計模式,你就需要先理出類之間的關係,比如上面的RunNotifier和TestListener之間的關係,然後思考為什麼要這麼設計,同時可以到網上找資料確認這兩者的關係,以學習觀察者模式。當下次再看到類似結構的時候,能快速的理清邏輯。

下文我們將結合Spring來梳理JUnit的Runner,完成整個測試流程的最後一塊拼圖。