通過框架極簡主義和軟件架構使測試變得更容易
已發表: 2022-03-10像許多其他 Android 開發人員一樣,我最初嘗試在該平台上進行測試使我立即面對令人沮喪的行話。 此外,我當時(大約 2015 年)遇到的幾個例子並沒有提供實際用例,這可能讓我認為學習像Espresso這樣的工具以驗證TextView.setText( ...)工作正常,是一項合理的投資。
更糟糕的是,我在理論或實踐上對軟件架構沒有實際的理解,這意味著即使我費心去學習這些框架,我也會為由幾個god
類組成的單體應用程序編寫測試,編寫在意大利麵條代碼中。 關鍵是,無論您的框架專業知識如何,構建、測試和維護此類應用程序都是一種自我破壞的練習; 然而,只有在構建了一個模塊化、鬆散耦合和高度內聚的應用程序之後,這種認識才會變得清晰。
從這裡我們到達本文討論的主要觀點之一,我將在這裡用通俗易懂的語言總結一下:應用軟件架構的黃金原則的主要好處之一(別擔心,我將通過簡單的示例和語言),是你的代碼可以變得更容易測試。 應用這些原則還有其他好處,但軟件架構和測試之間的關係是本文的重點。
然而,為了那些希望了解我們為什麼以及如何測試我們的代碼的人,我們將首先探索類比測試的概念; 無需您記住任何行話。 在深入探討主要主題之前,我們還將研究為什麼存在如此多的測試框架的問題,因為在研究這一點時,我們可能會開始看到它們的好處、局限性,甚至可能是替代解決方案。
測試:為什麼以及如何
對於任何經驗豐富的測試人員來說,本節都不是新信息,但也許您仍然會喜歡這個類比。 當然,我是一名軟件工程師,而不是火箭工程師,但我將藉用一個類比,它與在物理空間和計算機內存空間中設計和構建對像有關。 事實證明,雖然介質發生了變化,但過程原則上是完全一樣的。
假設我們是火箭工程師,我們的工作是建造航天飛機的第一級*火箭助推器。 假設我們已經為第一階段提出了一個可使用的設計,以便在各種條件下開始構建和測試。
“第一級”是指火箭首次發射時發射的助推器
在我們開始這個過程之前,我想指出為什麼我更喜歡這個類比:你不應該有任何困難來回答這個問題,即為什麼我們在將設計置於危及人類生命的情況之前要費心測試我們的設計。 雖然我不會試圖說服您在發布之前測試您的應用程序可以挽救生命(儘管這可能取決於應用程序的性質),但它可以節省評級、評論和您的工作。 在最廣泛的意義上,測試是我們確保單個部件、幾個組件和整個系統在我們將它們用於不發生故障至關重要的情況之前工作的方式。
回到這個類比的方式方面,我將介紹工程師測試特定設計的過程:冗餘。 冗餘原則上很簡單:將要測試的組件的副本構建為與您希望在啟動時使用的設計規範相同的設計規範。 在嚴格控制前提條件和變量的隔離環境中測試這些副本。 雖然這並不能保證火箭助推器在集成到整個航天飛機中時能夠正常工作,但可以肯定的是,如果它不能在受控環境中工作,那麼它根本不可能工作。
假設火箭設計的副本已針對數百個或數千個變量進行了測試,它歸結為火箭助推器將被測試發射的環境溫度。 在 35°C 下進行測試後,我們看到一切正常運行。 再次,火箭在大致室溫下進行測試,沒有失敗。 最終測試將在發射場的最低記錄溫度下進行,即 -5° 攝氏度。 在最後一次測試中,火箭發射了,但在很短的一段時間後,火箭突然爆發,不久後猛烈爆炸; 但幸運的是在受控和安全的環境中。
在這一點上,我們知道溫度的變化似乎至少與失敗的測試有關,這讓我們考慮火箭助推器的哪些部分可能會受到低溫的不利影響。 隨著時間的推移,人們發現一個關鍵部件,即用於阻止燃料從一個隔間流向另一個隔間的橡膠O 形環,在暴露於接近或低於冰點的溫度時變得僵硬且無效。
您可能已經註意到,他的類比大致是基於挑戰者號航天飛機災難的悲慘事件。 對於那些不熟悉的人來說,可悲的事實(就調查得出的結論而言)是有很多失敗的測試和工程師發出的警告,但行政和政治問題促使發射繼續進行。 無論如何,不管你是否記住了冗餘這個術語,我希望你已經掌握了測試任何系統部件的基本過程。
關於軟件
雖然前面的類比解釋了測試火箭的基本過程(同時對細節進行了大量的自由化),但我現在將以一種可能與您和我更相關的方式進行總結。雖然可以通過僅發射來測試軟件一旦它處於任何類型的可部署狀態,它就可以應用於設備,我想我們可以先將冗餘原則應用於應用程序的各個部分。
這意味著我們創建整個應用程序的較小部分(通常稱為軟件單元)的副本,設置一個隔離的測試環境,並根據可能發生的任何變量、參數、事件和響應查看它們的行為在運行時。 測試確實和理論上一樣簡單,但要達到這個過程的關鍵在於構建可測試的應用程序。 這歸結為兩個問題,我們將在接下來的兩節中討論。 第一個問題與測試環境有關,第二個問題與我們構建應用程序的方式有關。
為什麼我們需要框架?
為了測試一個軟件(以下稱為Unit ,儘管這個定義故意過度簡化),有必要有某種測試環境,允許您在運行時與您的軟件交互。 對於那些構建純粹在JVM ( Java 虛擬機)環境中執行的應用程序而言,編寫測試所需的只是JRE ( Java 運行時環境)。 以這個非常簡單的Calculator類為例:
class Calculator { private int add(int a, int b){ return a + b; } private int subtract(int a, int b){ return a - b; } }
在沒有任何框架的情況下,只要我們有一個包含實際執行代碼的main
函數的測試類,我們就可以對其進行測試。 您可能還記得, main
函數表示一個簡單 Java 程序的執行起點。 至於我們要測試的內容,我們只是將一些測試數據輸入到計算器的函數中,並驗證它是否正確執行基本算術:
public class Main { public static void main(String[] args){ //create a copy of the Unit to be tested Calculator calc = new Calculator(); //create test conditions to verify behaviour int addTest = calc.add(2, 2); int subtractTest = calc.subtract(2, 2); //verify behaviour by assertion if (addTest == 4) System.out.println("addTest has passed."); else System.out.println("addTest has failed."); if (subtractTest == 0) System.out.println("subtractTest has passed."); else System.out.println("subtractTest has failed."); } }
測試 Android 應用程序當然是一個完全不同的過程。 儘管在ZygoteInit.java文件的源代碼中隱藏了一個main
函數(其細節在此不重要),它在 Android 應用程序在JVM上啟動之前被調用,但即使是初級 Android 開發人員也應該知道系統本身負責調用這個函數; 不是開發商。 相反,Android 應用程序的入口點恰好是Application
類,以及系統可以通過AndroidManifest.xml文件指向的任何Activity
類。
所有這一切都只是一個事實,即在 Android 應用程序中測試單元呈現出更高級別的複雜性,嚴格來說是因為我們的測試環境現在必須考慮到 Android 平台。
解決緊耦合問題
緊耦合是描述依賴於特定平台、框架、語言和庫的函數、類或應用程序模塊的術語。 這是一個相對術語,意味著我們的Calculator.java示例與 Java 編程語言和標準庫緊密耦合,但這就是它的耦合程度。 同樣,測試與 Android 平台緊密耦合的類的問題是,您必須找到一種使用或圍繞平台工作的方法。
對於與 Android 平台緊密耦合的類,您有兩種選擇。 第一個是簡單地將您的類部署到 Android 設備(物理或虛擬)。 雖然我確實建議您在將應用程序代碼交付到生產環境之前對其進行測試部署,但在開發過程的早期和中期階段,這種方法效率極低。
Unit ,無論您喜歡哪種技術定義,通常都被認為是類中的單個函數(儘管有些擴展定義以包括由初始單個函數調用在內部調用的後續輔助函數)。 無論哪種方式,單位都應該很小。 構建、編譯和部署整個應用程序來測試單個單元是完全忽略了孤立測試的要點。
緊耦合問題的另一個解決方案是使用測試框架與平台依賴項進行交互,或模擬(模擬)平台依賴項。 與以前的方法相比, Espresso和Robolectric等框架為開發人員提供了更有效的單元測試方法; 前者對於在設備上運行的測試很有用(稱為“儀器測試”,因為顯然稱它們為設備測試並不夠含糊),後者能夠在 JVM 上本地模擬 Android 框架。
在我開始反對這些框架而不是我將很快討論的替代方案之前,我想明確一點,我並不是要暗示你永遠不應該使用這些選項。 開發人員用來構建和測試他們的應用程序的過程應該結合個人喜好和對效率的關注。
對於那些不喜歡構建模塊化和鬆散耦合應用程序的人來說,如果您希望獲得足夠的測試覆蓋率,您將別無選擇,只能熟悉這些框架。 許多精彩的應用程序都是以這種方式構建的,而且我經常被指責使我的應用程序過於模塊化和抽象。 無論您採用我的方法還是決定嚴重依賴框架,我都感謝您投入時間和精力來測試您的應用程序。
讓你的框架保持一定距離
對於本文核心課程的最後序言,值得討論一下為什麼在使用框架時您可能希望保持極簡主義的態度(這不僅適用於測試框架)。 上面的副標題是對軟件最佳實踐的寬宏大量的老師的釋義:羅伯特“鮑勃叔叔”C.馬丁。 自從我第一次研究他的作品以來,他給我的許多寶石中,這一個需要幾年的直接經驗才能掌握。
就我理解這句話的含義而言,使用框架的成本在於學習和維護它們所需的時間投資。 其中一些變化非常頻繁,而一些變化不夠頻繁。 功能被棄用,框架停止維護,每 6 到 24 個月就會有一個新框架出現以取代最後一個。 因此,如果你能找到一個可以作為平台或語言特性實現的解決方案(這往往會持續更長時間),它往往更能抵抗上述各種類型的變化。
從技術角度講, Espresso和較小程度的Robolectric等框架永遠無法像簡單的JUnit測試,甚至是早期的無框架測試那樣高效地運行。 雖然JUnit確實是一個框架,但它與JVM緊密耦合,它的變化速度往往比 Android 平臺本身要慢得多。 更少的框架幾乎總是意味著代碼在執行和編寫一個或多個測試所需的時間方面更有效率。
由此,您可能會了解到,我們現在將討論一種方法,該方法將利用一些技術,使我們能夠與 Android 平台保持一定距離; 同時讓我們有足夠的代碼覆蓋率、測試效率,以及在需要時仍然在這里或那裡使用框架的機會。
建築藝術
用一個愚蠢的類比,人們可能會認為框架和平台就像霸道的同事,除非你與它們設置適當的界限,否則它們將接管你的開發過程。 軟件架構的黃金原則可以為您提供創建和實施這些邊界所必需的一般概念和特定技術。 正如我們稍後將看到的,如果您曾經想知道在代碼中應用軟件架構原則的真正好處是什麼,一些直接和間接地使您的代碼更容易測試。
關注點分離
在我看來,關注點分離是整個軟件架構中最普遍適用和最有用的概念(並不是說其他應該被忽略)。 關注點分離 (SOC) 可以在我所知道的軟件開發的每個角度應用或完全忽略。 為了簡要總結這個概念,我們將研究 SOC 應用於類時,但請注意,SOC 可以通過廣泛使用輔助函數來應用於函數,並且可以外推到應用程序的整個模塊(“模塊”在Android/Gradle 的上下文)。
如果您花了很多時間研究 GUI 應用程序的軟件架構模式,您可能會遇到至少以下一種:模型-視圖-控制器 (MVC)、模型-視圖-演示者 (MVP) 或模型-視圖-視圖模型(MVVM)。 在構建了各種風格的應用程序之後,我會先說我不認為它們中的任何一個是所有項目(甚至單個項目中的功能)的最佳選擇。 具有諷刺意味的是,Android 團隊幾年前提出的作為他們推薦方法的模式,MVVM,在沒有 Android 特定測試框架的情況下似乎是最難測試的(假設您希望使用 Android 平台的 ViewModel 類,我承認這是一個粉絲的)。
無論如何,這些模式的細節不如它們的普遍性重要。 所有這些模式只是 SOC 的不同風格,它們強調三種代碼的基本分離,我稱之為:數據、用戶界面、邏輯。
那麼,分離Data 、 User Interface和Logic究竟如何幫助您測試應用程序? 答案是,通過將必須處理平台/框架依賴關係的類中的邏輯提取到具有很少或沒有平台/框架依賴關係的類中,測試變得容易並且框架最小化。 為了清楚起見,我一般說的是必須呈現用戶界面、將數據存儲在 SQL 表中或連接到遠程服務器的類。 為了演示它是如何工作的,讓我們看一個假設的 Android 應用程序的簡化的三層架構。
第一類將管理我們的用戶界面。 為了簡單起見,我為此使用了一個Activity ,但我通常選擇Fragments作為用戶界麵類。 無論哪種情況,這兩個類都與Android平台呈現出類似的緊密耦合:
public class CalculatorUserInterface extends Activity implements CalculatorContract.IUserInterface { private TextView display; private CalculatorContract.IControlLogic controlLogic; private final String INVALID_MESSAGE = "Invalid Expression."; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); controlLogic = new DependencyProvider().provideControlLogic(this); display = findViewById(R.id.textViewDisplay); Button evaluate = findViewById(R.id.buttonEvaluate); evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } }); //..bindings for the rest of the calculator buttons } @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } }
如您所見, Activity有兩個工作:首先,由於它是Android應用程序給定功能的入口點,因此它充當該功能其他組件的一種容器。 簡單來說,可以將容器視為一種根類,其他組件最終通過引用(或本例中的私有成員字段)連接到該根類。 它還擴展、綁定引用並將偵聽器添加到 XML 佈局(用戶界面)。
測試控制邏輯
我們不是讓Activity在後端擁有對具體類的引用,而是讓它與CalculatorContract.IControlLogic.
我們將在下一節討論為什麼這是一個接口。 現在,只需了解該界面另一側的任何內容都應該是Presenter或Controller之類的東西。 由於此類將控制前端 Activity和後端 Calculator之間的交互,因此我選擇將其稱為CalculatorControlLogic
:
public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } }
public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } }
關於這個類的設計方式有很多微妙的地方使它更容易測試。 首先,它的所有引用要么來自 Java 標準庫,要么來自應用程序中定義的接口。 這意味著在沒有任何框架的情況下測試這個類絕對是一件輕而易舉的事,並且可以在JVM上本地完成。 另一個小而有用的提示是,這個類的所有不同交互都可以通過一個通用的handleInput(...)
函數來調用。 這提供了一個單一的入口點來測試這個類的每一個行為。
另請注意,在evaluateExpression()
函數中,我從後端返回了一個Optional<String>
類型的類。 通常我會使用函數式程序員所說的Either Monad ,或者我更喜歡稱之為Result Wrapper 。 無論您使用什麼愚蠢的名稱,它都是一個能夠通過單個函數調用表示多個不同狀態的對象。 Optional
是一個更簡單的構造,它可以表示null或提供的泛型類型的某個值。 在任何情況下,由於後端可能會給出一個無效的表達式,我們希望給ControlLogic
類一些方法來確定後端操作的結果; 對成功和失敗都有解釋。 在這種情況下, null將表示失敗。
下面是一個使用JUnit編寫的示例測試類,一個在測試術語中稱為Fake的類:
public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } }
public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } }
如您所見,這個測試套件不僅可以非常快速地執行,而且編寫起來根本不需要太多時間。 無論如何,我們現在將看一些更微妙的東西,它們使編寫這個測試類變得非常容易。
抽象和依賴倒置的力量
還有兩個重要的概念已應用於CalculatorControlLogic
,這使得測試變得非常容易。 首先,如果您想知道在 Java 中使用接口和抽像類(統稱為抽象)有什麼好處,上面的代碼就是一個直接的演示。 由於要測試的類引用抽象而不是具體類,我們能夠從我們的測試類中為用戶界面和後端創建假測試替身。 只要這些測試替身實現了適當的接口, CalculatorControlLogic
就不會在意它們不是真實的東西。
其次, CalculatorControlLogic
已經通過構造函數(是的,這是依賴注入的一種形式)獲得了它的依賴關係,而不是創建自己的依賴關係。 因此,在生產或測試環境中使用時不需要重新編寫,這對效率來說是一個加分項。
依賴注入是控制反轉的一種形式,這是一個用簡單語言定義的棘手概念。 無論您使用依賴注入還是服務定位器模式,它們都實現了 Martin Fowler(我最喜歡的此類主題的老師)所描述的“配置與使用分離的原則”。 這導致類更容易測試,並且更容易彼此隔離構建。
測試計算邏輯
最後,我們來到ComputationLogic
類,它應該近似於IO 設備,例如遠程服務器或本地數據庫的適配器。 由於我們不需要一個簡單的計算器,它只負責封裝驗證和評估我們給它的表達式所需的邏輯:
public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }
public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }
public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }
public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }
關於這個類沒有太多要說的,因為通常會與特定的後端庫有一些緊密耦合,這會出現與與 Android 緊密耦合的類類似的問題。 稍後我們將討論如何處理這些類,但這個類很容易測試,我們不妨試一試:
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
最容易測試的類是那些簡單地被賦予一些值或對象,並且期望返回結果而不需要調用一些外部依賴項的類。 無論如何,無論你應用了多少軟件架構魔法,你仍然需要擔心無法與平台和框架分離的類。 幸運的是,我們仍然有一種方法可以使用軟件架構:最壞的情況是使這些類更容易測試,最好的情況是簡單到可以一目了然地完成測試。
卑微的對象和被動的觀點
上述兩個名稱指的是一種模式,其中必須與低級依賴項對話的對像被簡化到可以說不需要測試的程度。 我是通過 Martin Fowler 的關於 Model-View-Presenter 變體的博客首次向我介紹這種模式的。 後來,通過 Robert C. Martin 的作品,我了解到將某些類視為Humble Objects的想法,這意味著這種模式不需要僅限於用戶界麵類(儘管我並不是說 Fowler 曾經暗示了這樣的限制)。
無論您選擇如何稱呼這種模式,它都非常易於理解,並且在某種意義上我相信它實際上只是將 SOC 嚴格應用到您的課程中的結果。 雖然這種模式也適用於後端類,但我們將使用我們的用戶界麵類來演示這一原則。 分離非常簡單:與平台和框架依賴項交互的類不會自己思考(因此有Humble和Passive的綽號)。 當事件發生時,他們唯一要做的就是將此事件的詳細信息轉發給恰好正在偵聽的任何邏輯類:
//from CalculatorActivity's onCreate() function: evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } });
邏輯類應該很容易測試,然後負責以非常細粒度的方式控制用戶界面。 與其在user interface
類上調用單個通用updateUserInterface(...)
函數並讓其完成批量更新的工作, user interface
(或其他此類)將擁有應該易於使用的小而特定的函數名稱和實現:
//Interface functions of CalculatorActivity: @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } //…
原則上,這兩個示例應該足以讓您了解如何實現此模式。 擁有邏輯的對像是松耦合的,而與討厭的依賴關係緊密耦合的對象幾乎沒有邏輯。
Now, at the start of this subsection, I made the statement that these classes become arguably unnecessary to test, and it is important we look at both sides of this argument. In an absolute sense, it is impossible to achieve 100% test coverage by employing this pattern, unless you still write tests for such humble / passive classes. It is also worth noting that my decision to use a Calculator as an example App, means that I cannot escape having a gigantic mass of findViewById(...)
calls present in the Activity. Giant masses of repetitive code are a common cause of typing errors, and in the absence of some Android UI testing frameworks, my only recourse for testing would be via deploying the feature to a device and manually testing each interaction. 哎喲。
It is at this point that I will humbly say that I do not know if 100% code coverage is absolutely necessary. I do not know many developers who strive for absolute test coverage in production code, and I have never done so myself. One day I might, but I will reserve my opinions on this matter until I have the reference experiences to back them up. In any case, I would argue that applying this pattern will still ultimately make it simpler and easier to test tightly coupled classes; if for no reason other than they become simpler to write.
Another objection to this approach, was raised by a fellow programmer when I described this approach in another context. The objection was that the logic
class (whether it be a Controller
, Presenter
, or even a ViewModel
depending on how you use it), becomes a God
class.
While I do not agree with that sentiment, I do agree that the end result of applying this pattern is that your Logic classes become larger than if you left more decisions up to your user interface class.
This has never been an issue for me as I treat each feature of my applications as self-contained components, as opposed to having one giant controller for managing multiple user interface screens. In any case, I think this argument holds reasonably true if you fail to apply SOC to your front end or back end components. Therefore, my advice is to apply SOC to your front end and back end components quite rigorously.
進一步的考慮
After all of this discussion on applying the principles of software architecture to reduce the necessity of using a wide-array of testing frameworks, improve the testability of classes in general, and a pattern which allows classes to be tested indirectly (at least to some degree), I am not actually here to tell you to stop using your preferred frameworks.
For those curious, I often use a library to generate mock classes for my Unit tests (for Java I prefer Mockito , but these days I mostly write Kotlin and prefer Mockk in that language), and JUnit is a framework which I use quite invariably. Since all of these options are coupled to languages as opposed to the Android platform, I can use them quite interchangeably across mobile and web application development. From time to time (if project requirements demand it), I will even use tools like Robolectric , MockWebServer , and in my five years of studying Android, I did begrudgingly use Espresso once.
My hope is that in reading this article, anyone who has experienced a similar degree of aversion to testing due to paralysis by jargon analysis , will come to see that getting started with testing really can be simple and framework minimal .
關於 SmashingMag 的進一步閱讀:
- Sliding In And Out Of Vue.js
- Designing And Building A Progressive Web Application Without A Framework
- CSS 框架或 CSS 網格:我的項目應該使用什麼?
- 使用 Google 的 Flutter 進行真正的跨平台移動開發