Clean Code & Unit Test

本文部分內(nèi)容及示例來自Google Testing Blog,有興趣可以在文末點擊鏈接查看原文。

Preface

本篇的內(nèi)容是單元測試,看標(biāo)題可能會奇怪,為什么是Clean Code在前面,因為我要說的并不是怎么寫單元測試,寫單元測試的方法在網(wǎng)上隨便一搜就有很多,并且因為各種框架的關(guān)系,寫起來非常簡單,可以說基本上是沒有任何難度,但是可能你看了很多文檔或者教程之后,覺得單元測試很簡單了,然后打開項目準(zhǔn)備寫,卻發(fā)現(xiàn)不知道從何下手,或者寫出一下看起來像是單元測試,其實卻不太一樣的代碼,或者覺得很難就放棄了。然而單元測試其實非常簡單,事實上這正是本篇內(nèi)容要解決的問題,如何寫出可測試的代碼,雖說是為了測試,實質(zhì)上即是clean code,注意這里clean是動詞。

那我們先說clean code,什么叫clean code,簡單理解就是如何寫出整潔的代碼,或者說如何寫出高質(zhì)量的代碼,好代碼。那么,首先說什么樣的代碼是好的代碼?

  1. 易于閱讀

  2. 易于修改

  3. 沒有bug

  4. 魯棒性

  5. ...

上面只是舉一些例子,實際上不只這些優(yōu)點。

先看一張經(jīng)典圖片:

wtfm.jpg

我所接觸到的大部分項目,很明顯是右邊。
那么如何解決這個問題呢?
答案是不斷的學(xué)習(xí)和實踐。寫出高質(zhì)量代碼是相當(dāng)困難的事情,首先要學(xué)習(xí)各種理論知識,但是這就如同騎自行車,哪怕給你講的再好,第一次騎總是會摔倒,還需要實踐,閱讀大量的代碼,觀察并思考他人的失敗和成功,甚至是從自己的失敗中的出經(jīng)驗,當(dāng)你因為自己混亂的代碼而付出代碼,一定會牢記在心。二者缺一不可。這里要描述的只是其中的一小部分。

寫出好代碼,關(guān)注軟件的內(nèi)部質(zhì)量是很重要的,如果只關(guān)注外部質(zhì)量而不關(guān)注內(nèi)部質(zhì)量,隨著項目不斷的開發(fā),需求日益復(fù)雜,或因時間而遺忘細(xì)節(jié),會導(dǎo)致代碼越來越難以理解,維護,修改,積攢的bug越來越多,逐漸變成人人避之不及的大坑。

一開始就關(guān)注代碼質(zhì)量是很有效的解決辦法,

Later equals never

這一點我們在實際開發(fā)中應(yīng)該深有體會,所有想要留到以后解決的基本上都會隨著時間變多,然后被遺忘。

這里引用一下代碼整潔之道里面的一段總結(jié)

代碼能工作還不夠。能工作的代碼經(jīng)常會嚴(yán)重崩潰,滿足于僅僅讓代碼能工作的程序員不夠?qū)I(yè)。他們會害怕沒時間改進(jìn)代碼的結(jié)構(gòu)和設(shè)計。沒什么能比糟糕的代碼給開發(fā)項目帶來更深遠(yuǎn)和長期的損害了。進(jìn)度可以重訂,需求可以重新定義,團隊動態(tài)可以修正,但糟糕的代碼只是一直腐敗發(fā)酵,無情的拖著團隊的后腿。

當(dāng)然,糟糕的代碼可以清理。不過成本高昂,隨著代碼腐敗下去,模塊之間互相滲透,出現(xiàn)大量隱藏糾結(jié)的依賴關(guān)系。找到和破處陳舊的依賴關(guān)系又費時間又費勁。另一方面,保持代碼整潔卻相對容易。早晨在模塊中制造出一堆混亂,下午就能輕易清理掉。更好的情況是,5分鐘之前制造出混亂,馬上就能清理掉。

Overview

回到正題,單元測試。

說起單元測試,可能很多人的第一印象是,會寫單元測試的都是大神,單元測試很有用,但是我不會寫,也不需要,我的代碼能跑起來,也沒什么bug,為什么還要花時間寫單元測試。

說了這么多,那么到底為什么要寫單元測試?

Why

首先,單元測試可以給我們最直接的反饋,哪里出錯了,哪里寫的不對,只要運行一下,馬上就會知道,現(xiàn)代開發(fā)工具都提供的對單元測試的集成,點一下就可以運行并知道結(jié)果,這要比代碼在整個項目中實際運行時要簡單的多。

其次,單元測試可以對我們提供一層保護措施,讓你可以隨意的重構(gòu),修改功能,優(yōu)化代碼,非常清晰直觀的讓你知道到底有沒有問題,你的改動到底有沒有破壞原有的功能,是否產(chǎn)生產(chǎn)生了隱藏的問題,一切盡在掌握之中。

最后,單元測試也是對了解業(yè)務(wù)提供了重要的幫助。好的單元測試邏輯簡單,通過單元測試了解代碼中實際實現(xiàn)的業(yè)務(wù)是非常容易的,尤其對很多沒有詳細(xì)需求文檔的項目。在新接手一個項目,或當(dāng)你忘記的以前寫的內(nèi)容時,閱讀單元測試是快速了解業(yè)務(wù)的重要途徑。

What

下面看什么是單元測試,先看一張圖。

pyramid.png

在軟件測試中有這樣一個金字塔概念,可以看到分了三層,每一層代碼一種不同類型的測試。

最底層的就是unit test,是我們最經(jīng)常會大量寫到的功能專一的測試,直接運行在本地環(huán)境;integration test和UI test 則是需要運行在真實的環(huán)境上,也就是手機或虛擬機上。對應(yīng)在Android項目中的是test和andoridTest兩種測試。后兩種可以告訴你你的軟件是不是實際上功能正常,相對來說速度要慢,因為需要打包成apk,安裝到真實環(huán)境中,通過類似于用戶交互的方式來測試。

單元測試,顧名思義是在一個相對獨立的狀態(tài)下對單元(Unit)進(jìn)行測試。
編寫單元測試有幾個特點:

  1. 深入透徹,但是避免過度設(shè)計,缺乏設(shè)計且頻繁變更的功能很難編寫詳盡的單元測試

  2. 隔離外部環(huán)境,可重復(fù)運行,外部因素需要排除,如接口訪問網(wǎng)絡(luò)和數(shù)據(jù)庫,在有無網(wǎng)絡(luò)時測試結(jié)果應(yīng)相同

  3. 功能單一,單元測試需要保持功能簡潔單一,一個測試只專注于單一功能

  4. 驗證行為而非實現(xiàn),避免在實現(xiàn)改動后需要重新編寫測試代碼。在Andorid中,因為大部分單元測試是沒有實際的ui的,這一方法更是尤為重要,我們通常需要使用MVP或MVVM等類似的架構(gòu)來實現(xiàn)。

    看下面的代碼,我們需要測試一個登錄后顯示提示的功能是否正常,這里使用MVP架構(gòu),通過mockito框架輕松mock一個View接口,在登錄方法被調(diào)用之后驗證是否調(diào)用了一次showLoginHint方法。之后無論view的實現(xiàn)如何改變,這段測試代碼都不需要被修改了。

    public class LoginPresenter {
    
        private View view;
    
        public LoginPresenter(View view) {
            this.view = view;
        }
    
        public void login() {
            this.view.showLoginHint();
        }
    
        interface View {
            void showLoginHint();
        }
    }
    
    public class LoginPresenterTest {
    
        public void testLogin() {
            LoginPresenter.View view = mock(LoginPresenter.View.class);
            new LoginPresenter(view).login();
            verify(view).showLoginHint();
        }
    }
    
  5. 快速,編寫測試代碼需要頻繁的運行,所以速度很重要,這也是為什么會使用robolectric來在本地JDK上模擬Android運行環(huán)境的原因

  6. 簡潔,避免復(fù)雜邏輯,測試代碼應(yīng)該一目了然,這也是很多測試庫所實現(xiàn)的目的,雖然編寫單元測試可能會多寫一些代碼,但大多數(shù)應(yīng)該只具備簡單邏輯,即輸入-輸出-校驗

提起單元測試,另一個經(jīng)常被放在一起的就是Test-driven development (TDD)

TDD簡單的說就是邊寫測試邊寫代碼,流程如下圖

testing-workflow.png
  1. 增加測試
  2. 運行測試找出錯誤
  3. 編寫代碼使測試能通過
  4. 運行測試
  5. 重構(gòu)代碼,提高代碼質(zhì)量
  6. 重復(fù)上述操作

通過快速迭代的開發(fā)流程,來確保每一步的正確性,其實和我們平時寫幾行代碼就運行一下程序試一試效果是差不多的,把上面的編寫測試和運行測試的步驟,換成"在手機上運行一下程序,點一點新功能或看控制臺的日志,確定功能是否正確"是不是很像平時的操作,當(dāng)然大部分人可能省略了第5條。還記得上面單元測試的幾個特點么,點一點測試和看日志可不具備這么多功能。

科普時間

behavior-driven development (BDD)

行為驅(qū)動開發(fā),由TDD衍生而來,鼓勵讓開發(fā)人員和非技術(shù)人員協(xié)作,通過自然語言實現(xiàn)非技術(shù)人員也能看懂的測試流程。

BDD的測試用例就像講故事,開發(fā)人員和測試,項目經(jīng)理等非技術(shù)人員經(jīng)過討論將一系列需求寫成故事中的一個個場景。首先定義一套模板,讓非技術(shù)人員通過模板寫出要測試的內(nèi)容,然后有開發(fā)人員完成實現(xiàn)的代碼。測試的流程大致是這樣:

Feature: Book Search
    Scenario: Search books by author
      Given there's a book called "Tips for job interviews" written by "John Smith"
        And there's a book called "Bananas and their many colors" written by "James Doe"
        And there's a book called "Mama look I'm a rock star" written by "John Smith"
      When an employee searches by author "John Smith"
      Then 2 books should be found
        And Book 1 has the title "Tips for job interviews"
        And Book 2 has the title "Mama look I'm a rock star"

How to write testable code part 1 相關(guān)知識補充

在開始說如何寫出可測試的代碼之前,先補充一些理論知識。

S.O.L.I.D

軟件開發(fā)中的5個原則,當(dāng)這些原則被一起應(yīng)用時,它們使得一個程序員開發(fā)一個容易進(jìn)行軟件維護和擴展的系統(tǒng)變得更加可能。這一概念由 Robert C. Martin 提出。

Robert_Cecil_Martin.png

Robert C. Martin 應(yīng)該是非常著名的專注于面向?qū)ο?,敏捷開發(fā),提高代碼質(zhì)量的專家,還有一個名字是Uncle Bob,他寫的書有很多經(jīng)典著作,比如我最近在看的代碼整潔之道。

  1. 單一職責(zé)原則 Single responsibility principle

    單一職責(zé)模式告訴我們,A class should have only one reason to change. 一個類有且僅有一個原因使其被修改。說人話是我簡單,我快樂。該原則說明了兩點,一是一個類只能有一個職責(zé),二是只能有一個修改的理由。舉個例子,我們常用的日志模塊,如果一個類既包含打印日志內(nèi)容的功能,又包含日志格式的功能,那么在修改其中任何一個功能時,勢必會影響到另外一個功能,在復(fù)雜的真實環(huán)境中,這種影響可能會造成很嚴(yán)重的潛在問題。

    單一職責(zé)原則是面向?qū)ο笾袠O為重要的一條,非常容易理解和遵循,然而卻時常遭到破壞的原則。我們經(jīng)常忙于應(yīng)付多變的需求和即將臨近deadline,而忽略對代碼的組織結(jié)構(gòu)的保持。有的人會覺得過多的類會導(dǎo)致系統(tǒng)過于復(fù)雜,想要找一個功能需要在好多類之間跳來跳去。如果你的項目只有很少的代碼,的確放在一起要更容易找到自己的目標(biāo),然而大部分真實項目都具備相當(dāng)復(fù)雜的邏輯,包含各種龐雜的功能,把多個功能放在一起并不會簡化你的代碼,問題在于,在你有很多東西時,你想要用僅有的幾個大抽屜裝下所有的東西,在找的時候翻的亂七八糟還不一定能找到,還是想要有多個只裝一類東西并且具有良好的分類信息的小抽屜呢?大部分時間我們開發(fā)或者維護某個功能時,僅僅只需要關(guān)心相關(guān)的一些邏輯而已,并不需要知道其余任何無關(guān)功能。每個達(dá)到一定規(guī)模的系統(tǒng)都具有相當(dāng)?shù)膹?fù)雜性,對這種復(fù)雜性的良好管理使我們能快速準(zhǔn)確的找到需要的類,而不是在一大堆無關(guān)的功能中反復(fù)尋找自己需要的代碼。

    遵循單一職責(zé)原則要求我們在開發(fā)前就要對職責(zé)進(jìn)行劃分,分而治之,無論是提前規(guī)劃好,還是當(dāng)發(fā)現(xiàn)一個類有多個職責(zé)時進(jìn)行修改,都會幫助我們更好的理解和創(chuàng)建抽象,我們常說高內(nèi)聚低耦合,單一職責(zé)模式就是對高內(nèi)聚很好的詮釋。

  2. 開閉原則 Open/closed principle

    開閉原則規(guī)定“軟件中的對象(類,模塊,函數(shù)等等)應(yīng)該對于擴展是開放的,但是對于修改是封閉的"

    這個原則個人感覺是最不好實現(xiàn)的,也是最難理解的,對修改封閉不代表完全沒有修改,那么什么情況應(yīng)該允許修改,什么情況需要去擴展,首先對于設(shè)計是一個巨大的考驗,前期設(shè)計的失敗可能導(dǎo)致后期擴展時花費大量時間修改,當(dāng)然沒有一蹴而就的代碼,開發(fā)過程中需要權(quán)衡利弊,想要達(dá)成最優(yōu)效果,需要具備良好的抽象能力,掌握大量的設(shè)計模式,其他幾條原則也同樣可以幫助我們更好的實現(xiàn)開閉原則。

    其實開閉原則的關(guān)鍵,是用你對各種設(shè)計模式的理解程度和抽象能力,符合其他原則及各種設(shè)計模式,自然也就符合開閉原則,其他的原則及設(shè)計模式恰恰是告訴你如何實現(xiàn)開閉模式的細(xì)節(jié)。

  3. 里氏替換原則 Liskov substitution principle

    子類對象使用的地方都可以替換為父類對象,且能保持邏輯不變。

    作為5個原則里唯一以人名命名的,是由兩個非常厲害的人于1993年提出的。

    Barbara_Liskov.jpg

    Barbara Liskov, 美國計算機科學(xué)家,2008年圖靈獎得主,2004年約翰·馮諾依曼獎得主?,F(xiàn)任麻省理工學(xué)院電子電氣與計算機科學(xué)系教授。

    Jeannette_Wing.jpg

    周以真,微軟全球資深副總裁,美國計算機科學(xué)家。卡內(nèi)基梅隆大學(xué)教授。美國國家自然基金會計算與信息科學(xué)工程部助理部長。ACM和IEEE會士。

    繼承,多態(tài)等是面向?qū)ο笳Z言的特性,都是經(jīng)常使用的。符合里氏替換原則的實現(xiàn)包含這樣一層含義,父類中實現(xiàn)好的方法,都是符合一定的契約和規(guī)范的,子類實現(xiàn)的時候不能違反,否則會對整個繼承體系造成破壞。難點在于既要提前規(guī)劃好后續(xù)可能發(fā)生的情況,對類的功能進(jìn)行抽象,又要防止過度設(shè)計,避免設(shè)計的過于復(fù)雜影響后續(xù)開發(fā)維護。比較穩(wěn)妥的方式通常是只實現(xiàn)父類的抽象方法,這要求我們在初期設(shè)計時就要考慮到這些,或者干脆不使用繼承,而是組合的方式來實現(xiàn)某些功能來避免耦合。

    使用繼承的優(yōu)點在于減少重復(fù)代碼,直接給子類提供了父類的功能,正所謂龍生龍,鳳生鳳,老鼠的兒子會打洞,然而有點也即是缺點,子類同樣耦合了父類的功能,降低了靈活性,子類要考慮是否違反了父類的規(guī)范,父類修改時要考慮是否對子類造成影響,稍有不慎,可能就會搞出龍生耗子這樣的問題,屆時就需要對原有代碼大量的重構(gòu),尤其在缺乏規(guī)范的情況下更是如此。

    一個經(jīng)典例子是長方形和正方形,看下面你的代碼,正方形繼承了長方形,為了保持正方形的寬高一致,我們重寫了父類的方法,看起來是沒什么問題。但是如果我們基于"長方形的面積=長*寬"這一規(guī)則來測試正方形,在設(shè)置了長或?qū)捴?,area方法就不符合預(yù)期結(jié)果了。

    public class Rectangle {
        private int width;
        private int height;
    
        public void setWidth(int width) {
            this.width = width;
        }
    
        public void setHeight(int height) {
            this.height = height;
        }
    
        public int area() {
            return this.height * this.width;
        }
    }
    
    public class Square extends Rectangle {
        @Override
        public void setWidth(int width) {
            super.setWidth(width);
            super.setHeight(width);
        }
    
        @Override
        public void setHeight(int height) {
            super.setWidth(height);
            super.setHeight(height);
        }
    }
    

    下面我們來進(jìn)行修改,一個可行的辦法是像下面這樣修改,將width和height的set方法去掉,由constructo直接傳入,這樣就符合了父類的契約。

      public class Rectangle {
        private int width;
        private int height;
    
        public Rectangle(int width, int height) {
            this.width = width;
            this.height = height;
        }
    
        public int getHeight() {
            return height;
        }
    
        public int getWidth() {
            return width;
        }
    
        public int area() {
            return this.height * this.width;
        }
    }
    
    public class Square extends Rectangle {
        public Square(int width) {
            super(width, width);
        }
    }
    

    Java中實現(xiàn)的非常經(jīng)典的例子:

    Collection

    如使用List的,不管實現(xiàn)類是ArrayList,還是LinkedList,變量類型全用List就可以。

    Stream

    很多方法參數(shù)或返回值給一個OutpuStream或InputStream,什么實現(xiàn)類并不影響代碼邏輯,你甚至都不知道實現(xiàn)類是什么

  4. 接口隔離原則 Interface segregation principle

    接口隔離原則規(guī)定,使用者不應(yīng)該被強制依賴不需要的方法。大而全的接口應(yīng)當(dāng)被拆分長更細(xì)粒度的接口,這樣使用者可以僅僅依賴需要的部分,減少耦合,使其更易于重構(gòu)和修改。當(dāng)然使用的時候同樣需要權(quán)衡,拆分接口并不意味著越細(xì)粒度越好,應(yīng)該根據(jù)需要,選擇合適的方法進(jìn)行組合。

    從來自wiki的說明我們得知,

    這條原則最初的構(gòu)想來自Robert C. Martin在施樂公司期間的一個打印機程序,在他們發(fā)現(xiàn)這個程序幾乎無法被維護時,試圖找出原因。有一個Job類貫穿了整個系統(tǒng),打印機需要執(zhí)行的每個任務(wù),都要調(diào)用Job類,這導(dǎo)致一個fat class 包含了大量的各種功能不同功能的方法。由于這個設(shè)計,任何一個任務(wù)都要知道全部的方法,即使根本不需要。解決辦法是按照依賴倒置原則,增加了一層接口層,把巨大的Job類替換為一個個接口,對應(yīng)每個任務(wù),任務(wù)去調(diào)用對應(yīng)的接口來實現(xiàn)功能,當(dāng)然這些接口的實現(xiàn)都在Job類中。

    我根據(jù)這個例子畫了個圖:

    改動前:

    printer1.png

    改動后:

    printer2.png

    可以看出后面的改動很好的通過接口將巨大的job類分割成了一個一個小功能,屏蔽掉了調(diào)用者不需要知道的功能。

  5. 依賴倒置原則 Dependency inversion principle

    依賴抽象而非實現(xiàn),這個在依賴注入里面說過了,這里就不再重復(fù)了。

迪米特法則 / 最少知識原則 Law of Demeter (LoD) or principle of least knowledge

得墨忒耳定律(Law of Demeter,縮寫LoD)亦稱為“最少知識原則(Principle of Least Knowledge)”。
在198x年,有一群程序員開發(fā)了一個系統(tǒng)來研究如何使面向?qū)ο笳Z言開發(fā)的軟件更易于維護和修改,名字是The Demeter Project,一個關(guān)于Aspect-Oriented Software Development (AOSD)的項目,在此期間發(fā)現(xiàn)了得墨忒爾定律。Law of Demeter 的名字由此而來。Demeter是希臘神話中的農(nóng)業(yè)女神,得墨忒耳。

遵循得墨忒耳定律可以使你的代碼松耦合,提高代碼的復(fù)用程度,更易于維護,更易于測試。關(guān)鍵點是,只和自己直接的朋友交流。

簡單的來說就是:

  • 你的方法只能調(diào)用自己相同類中的方法(Java: this)
  • 你的方法只能調(diào)用自己所屬對象中的屬性的方法(Java: fields)
  • 如果你的方法有參數(shù)傳遞進(jìn)來,可以調(diào)用參數(shù)的方法
  • 如果你的方法創(chuàng)建了一個本地對象,可以調(diào)用這個對象的方法
  • 不應(yīng)該調(diào)用全局對象,但是可以作為參數(shù)傳遞進(jìn)來
  • 不應(yīng)該有鏈?zhǔn)秸{(diào)用,a.getB().getC().doSomething()應(yīng)該放在a對象里

注意最后一條,如果把a.getB().getC().doSomething()改成a.doSomething(),仍然違反了得墨忒耳定律,因為a里面會有b.getC().doSomething(),所以應(yīng)該有一個doSomething方法在b類中,a.doSomething()調(diào)用b.doSomethine()。

按照上述方式,一個對象的內(nèi)部結(jié)構(gòu),運作方式只有它自己才知道,因此也稱最少知識原則。通過封裝的方式顯著降低了代碼的耦合程度,類與類之間的影響降到了最低,降低了修改的風(fēng)險。

在分層架構(gòu)中,遵循得墨忒耳定律意味著每一層的代碼僅能調(diào)用自己本層或下一層中的方法。越俎代庖這個詞用來形容違反了LoD原則最合適不過了。

然而,遵循得墨忒耳定律可能會產(chǎn)生大量的wrapper類和方法用來傳遞跨越多個多個對象間方法的調(diào)用(propagation patterns),增加開發(fā)維護的成本。一個相關(guān)的技術(shù)是AOP,這里簡單的說一下aspect-oriented programming (AOP),AOP框架可以在你的代碼中插入一些代碼片段,而這個過程都是自動生成的,避免了手工書寫代碼的繁瑣。因此,得墨忒爾定律配合AOP食用更佳。

另一個和得墨忒爾定律相關(guān)的是訪問者模式(VisitorPattern),當(dāng)你需要深入一個對象的內(nèi)部結(jié)構(gòu)去調(diào)用一系列方法時,用訪問者模式對其進(jìn)行封裝是一個很好的方式。

如果看了上面的不是很理解,某Android群大神給我說了一個更通俗的例子:

你和你未來的老婆不認(rèn)識,但是你認(rèn)識她的閨蜜,你和你未來的老婆不能直接交流,因為你不認(rèn)識她。有一天你突然想和你未來的老婆認(rèn)識認(rèn)識,那么你首先要通過她的閨蜜把你介紹給她,然后有兩個方案,一個是始終通過閨蜜給她傳話,或者把她變成你的老婆,這樣她就是和你具有直接關(guān)系的對象了,也就可以直接和她交流了。

Summary

上面的6個規(guī)則只是程序設(shè)計中的一部分,想要寫出高質(zhì)量的代碼,除了學(xué)習(xí)設(shè)計模式之外,還需要大量的實踐,理論畢竟只是理論,在實際應(yīng)用中達(dá)成的效果可能千差萬別,任何模式的應(yīng)用,都需要仔細(xì)權(quán)衡利弊,避免過度設(shè)計,也要防止設(shè)計過于簡陋或干脆不進(jìn)行任何設(shè)計,導(dǎo)致代碼難以維護和擴展。

其實我們平時所做的程序設(shè)計,大都離不開三步,抽象,分解,組合,也既是計算思維。上述6個原則及各種設(shè)計模式,只不過是過去無數(shù)人對以往經(jīng)驗的總結(jié)提煉而出的幾種最優(yōu)解,讓你站在巨人的肩膀上,更好的實現(xiàn)這三步而已。

How to write testable code part 2 Best practice

很多時候我們看著代碼卻不知道怎么寫單元測試,想要測試一個單一的方法,卻有無數(shù)的耦合對象影響,下面列舉一些讓我們更難進(jìn)行單元測試的關(guān)鍵點。

  1. 區(qū)分創(chuàng)建對象代碼和邏輯代碼
    編寫測試代碼通常是,初始化應(yīng)用的一部分功能,然后執(zhí)行某個操作,判斷這個功能的現(xiàn)象是否符合預(yù)期。對單元測試而言,簡單的說就是創(chuàng)建一個對象,調(diào)用一個方法,根據(jù)返回結(jié)果或某些現(xiàn)象進(jìn)行斷言,以確定功能是否符合預(yù)期結(jié)果。前面我們說過,單元測試的是在隔離的環(huán)境中對類(Unit)進(jìn)行測試,換言之就是不能讓其他對象對我們的運行結(jié)果產(chǎn)生隨機影響,這就需要我們要測試的對象沒有在內(nèi)部自行創(chuàng)建(new)其他對象,否則就可能會對結(jié)果造成未知的影響。

    還記得上面的單一職責(zé)原則么,我們可以將代碼按內(nèi)容分為兩種職責(zé),一種是創(chuàng)建對象(因為大部分情況我們不會創(chuàng)建一個完全獨立的對象,大多數(shù)對象都存在一些依賴關(guān)系,因此稱之為 object graph)的代碼,一種是功能邏輯的代碼。將這兩種代碼分別封裝在兩種類中,負(fù)責(zé)創(chuàng)建對象的類,根據(jù)應(yīng)用的設(shè)計模式不同,可能為factory/provider等,和負(fù)責(zé)掌管功能邏輯類。當(dāng)然也可以使用其他手段,如依賴注入框架來輔助我們實現(xiàn)這樣的功能。經(jīng)過這樣修改之后,我們就可以自由的創(chuàng)建需要測試的對象,把其他對象可以輕易的被替換為"假"的對象,使其的行為符合我們預(yù)設(shè)的邏輯。使用mock框架會使這一步驟更為簡單。

  2. 遵循Law of Demeter

    如果你按照上面第一條的進(jìn)行改造,避免自己new新對象,就會發(fā)現(xiàn)一個問題,我不在類里面自己new對象了,那我要用到其他的類怎么辦呢?當(dāng)然是伸手要(作為構(gòu)造參數(shù)傳進(jìn)來)。如果你使用過依賴注入,這個思路就很熟悉了:我們不創(chuàng)建對象,而是像別人要對象。

    注意別忘了德墨忒爾定律,

    違反得墨忒爾定律就像在干草堆里尋找一根針。

    只能使用自己有直接接觸的對象,不要傳遞一個萬能的對象進(jìn)來(kitchen sink / fat class / god class),否則你可能需要創(chuàng)造大量的"假"對象來實現(xiàn)單元測試。還記得單元測試的特點么,簡潔,簡潔,簡潔,重要的事情說三遍,復(fù)雜的邏輯和冗余的代碼會極大破壞單元測試的效果。違反得墨忒爾定律可能會使你的創(chuàng)造"假"對象的代碼量翻上好幾倍。

    雖然mockito之類的mock框架提供了非常簡單的方法來mock對象一個context之類的god class,然而

    1. 即便使用框架,每個mock對象仍然要寫幾行代碼,越深入object graph,mock對象代碼就要翻倍。

    2. 由于這些對象的耦合關(guān)聯(lián)很強,導(dǎo)致測試代碼非常不健壯,一旦對這些對象之間的內(nèi)部聯(lián)系做了某些修改,很可能就要重寫單元測試

    3. 即時只需要context中的某一兩個類,仍然需要mock整個類,會存在大量冗余代碼,且遍布各個測試中,嚴(yán)重降低單元測試的可讀性。

    學(xué)一個單詞kitchen sink,直譯時廚房水槽,在這里表示任何東西,可能的出處是第二次世界大戰(zhàn)期間軍隊中使用的俚語,原文是'Out for blood, our Navy throws everything but the kitchen sink at Jap vessels, warships and transports alike.',表示除了廚房水槽之外的其他東西。

    上述內(nèi)容都是為了構(gòu)建一個隔離的環(huán)境供我們實現(xiàn)單元測試,我們來看下面的例子,創(chuàng)建一個House并進(jìn)行測試是非常簡單的,只需要new一個對象出來,然后調(diào)用方法,對結(jié)果進(jìn)行斷言,就可以了。

    class House {
        private boolean isLocked;
        private boolean isLocked() {
            return isLocked;
        }
        private void lock() {
            isLocked = true;
        }
    }
    

    測試House類如此簡單是因為這是一個葉子類,葉子類的意思就是這個類處于依賴樹的終點,它不依賴任何類。所有的葉子類都可以非常方便的進(jìn)行單元測試,但是其他類就不這么輕松了,尤其是在內(nèi)部自行new對象的類,因為它使單元測試不再處于一個隔離的環(huán)境中。

    這樣修改還有另一個好處,可以使你構(gòu)建object graph的過程更加清晰直觀。

  3. 不要在Constructor中做太多事

    經(jīng)過上面兩條的修改,我們已經(jīng)去除了可能存在于Constructor中的new對象的邏輯,但是還不止于此。

    一個類的單元測試可能多達(dá)幾十個,測試中我們做的事情就是,每次創(chuàng)建一個對象不太一樣的對象,執(zhí)行一個操作,斷言結(jié)果。可以看到,最常做的的是創(chuàng)建對象,為了使測試更快,應(yīng)該避免在Constructor中做除了賦值之外的任何事,有的時候可能無所謂,但如果在其中做了過于復(fù)雜的事情,如從硬盤或者網(wǎng)絡(luò)讀取一些初始化設(shè)置,一方面我們無法排除這些外部因素造成的不穩(wěn)定影響,另一方也會花費大量的時間。

    如果我們的類像這樣,就無法排除Door對測試帶來的影響。

    class House {
        private Door door;
    
        public House() {
            this.door = new Door();
        }
        // ...
    }
    

    可行的改進(jìn)辦法是:

    class House {
        private Door door;
    
        public House(Door door) {
            this.door = door;
        }
        // ...
    }
    

    這種方式我們就可以在測試中mock一個假door,并通過其行為來進(jìn)行判斷。

    同樣一些其他類似的操作也應(yīng)當(dāng)杜絕,如initialization block,static block。如果真的需要一個復(fù)雜的初始化流程,可以增加一個方法來手動調(diào)用。不過這里偶爾會有一些極為個別的特例,通常是對一些read-only或write-only的對象或功能做簡單的初始化,例如調(diào)用System.loadLibrary來加載so庫,或者日志類的初始化,需要注意的是,這種場景極為罕見,大部分情況當(dāng)你試圖寫在這里的代碼,都不是如此

    還需要注意的是,避免在初始化時訪問全局狀態(tài),具體的原因可以看下面一條。

  4. 全局狀態(tài)

    全局狀態(tài)的作用域是整個應(yīng)用,我們上面講了這么多的模式,其目的最終都是為了達(dá)到高內(nèi)聚低耦合的效果,這就要求我們限制變量的作用域減少對全局狀態(tài)的使用。此外,全局狀態(tài)經(jīng)常是難以維護的,可能會增加閱讀理解代碼的難度。在單元測試是,多個測試如果訪問同一個全局狀態(tài),可能會導(dǎo)致非預(yù)期效果,產(chǎn)生偶然變化。雖然我們也可以將相關(guān)的測試按順序一個一個執(zhí)行,并且在每個測試開始前重置全局狀態(tài),但是這無疑極大降低了測試的速度,增加了無關(guān)代碼和復(fù)雜程度,而且也不能保證測試的準(zhǔn)確性,例如某個功能依賴于全局狀態(tài)變化后的值,但是測試開始前全局狀態(tài)被重置了。

    常量作為例外,是可以被允許存在的,因為常量永遠(yuǎn)不會被修改。

  5. 單例

    其實單例也屬于全局狀態(tài)之一,屬于披著羊皮的狼。既然我們不提倡使用全局狀態(tài),如果你讀懂了上面一條,自然也就明白使用單例的問題所在了。可能有些人覺得單例和全局變量不一樣,那么換個思路,關(guān)鍵點是單例中保存這可修改的變量,這實際上等同于單個全局變量的聚合體!

    同樣有例外,不可變對象(immutable object)是被允許的,因為就和常量一樣,都是不會被改變的。還有一種情況read-only/write-only的對象,注意上面的說的情況,全局狀態(tài)可能在任何位置被改變也可以在任何位置被讀取,這意味這讀取和修改是不被控制的,而此種對象的單例只存在讀或?qū)懸环N操作,并不存在這種現(xiàn)象,一個常見的例子是日志類,日志類只輸出日志,我們在程序中不關(guān)心輸出的內(nèi)容,除非你要根據(jù)輸出的日志內(nèi)容做某些其他的操作,否則無論輸出是什么,都不影響代碼的邏輯功能。

  6. static method

    可以發(fā)現(xiàn),單元測試大多是針對一個類的方法進(jìn)行測試,多個類通過依賴關(guān)系互相協(xié)作使我們可以從中截取一部分邏輯進(jìn)行單元測試。靜態(tài)方法則破壞了這種方式。我們無法分離某一部分的邏輯進(jìn)行測試?;叵胍幌旅嫦?qū)ο笳Z言和傳統(tǒng)過程式語言的優(yōu)劣,不難理解為什么不提倡使用靜態(tài)方法。如果你的代碼有一大堆的復(fù)雜邏輯完全使用靜態(tài)方法實現(xiàn),使用傳統(tǒng)方式幾乎無法進(jìn)行單元測試。mockito,easymock等框架均不支持mock靜態(tài)方法,雖然powermock、dexmaker等框架的出現(xiàn),通過修改編譯后生成的字節(jié)碼或android的dex文件使mock靜態(tài)方法成為可能,但考慮到面向?qū)ο笳Z言提供的種種優(yōu)勢,將靜態(tài)方法中的邏輯封裝到一個或幾個對象中的做法無疑具有更好的擴展性,同時減少了可能存在的對全局狀態(tài)的訪問,降低了維護的成本。

    對于葉子類,靜態(tài)方法是不存在任何問題的,因為葉子類不依賴任何對象,可以輕易編寫單元測試。

  7. 使用組合而非繼承

    組合提供了比繼承更靈活的方式來進(jìn)行擴展,參考里氏替換原則,實現(xiàn)繼承無疑更具有挑戰(zhàn)性,缺乏足夠清晰的契約約束,或者存在錯誤的設(shè)計,繼承可能會導(dǎo)致更混亂的代碼。對單元測試而言,子類耦合了父類的功能,錯誤的繼承可能將不同的功能混在一起,導(dǎo)致我們無法單獨對某一個功能進(jìn)行單元測試。

  8. 使用多態(tài)而非條件語句

    有時我們會在一個方法中大量使用條件語句,if/else/switch,如果一個類中有很多類似的功能,應(yīng)該考慮使用多態(tài),將一個類將其分成幾個小一些的具有類似行為的類,這樣我們可以針對每種狀態(tài)編寫更簡單更詳細(xì)的單元測試。

  9. 混合Service-objects和Value-objects類

    通常代碼中會存在兩種類型的對象,一種存儲數(shù)據(jù)的類,如bean類,也叫Value-objects,pojo,dto等,或者是Map,List等,通常不需要實現(xiàn)接口,很容易被創(chuàng)建,也不需要被mock。另一種是封裝業(yè)務(wù)邏輯的類,我們稱之為Service-Objects,這種類在單元測試經(jīng)常需要被mock,經(jīng)常需要實現(xiàn)接口,使用各種復(fù)雜的設(shè)計模式來設(shè)計結(jié)構(gòu)。

    Value-objects也即是上面提到的葉子類,這些類在測試時不需要被mock,直接new一個就可以,因此它們不能在內(nèi)部使用Service-objects。Service-objects通常更難以創(chuàng)建,因為它們有更復(fù)雜的邏輯關(guān)系,像上面第一條所描述的,這種類不應(yīng)該在其內(nèi)部被自行創(chuàng)建新對象,應(yīng)當(dāng)通過構(gòu)造函數(shù)傳遞進(jìn)來,可以使用factory或者依賴注入框架實現(xiàn)。從測試的角度來看,我們更喜歡Value-objects,因為可以自由創(chuàng)建且易于測試,Service-objects則由于復(fù)雜的依賴關(guān)系而變得難以測試,使得我們必須使用mock框架來模擬所有的依賴。將這兩種類的功能混合在一起對兩者都沒有任何的好處。

  10. 小細(xì)節(jié)

    大部分時候如果我們的代碼遵循前面的6個原則,編寫單元測試都會相對更容易。如果你的類或方法包含過多功能(違反單一職責(zé)原則),假如你的方法名字叫 xxxAndxxx,那就要注意了,既無法讓他人快速的讀懂,也會增加測試的難度。同樣,簡潔的代碼也更有利于單元測試,還要再說一遍,單元測試,顧名思義,是要對 單元 進(jìn)行的獨立的,無干擾的測試,我們所有的目的,最終都是為了構(gòu)筑功能單一的單元,和排除其他不確定因素的干擾。

按照上面的小技巧來修改代碼,可以讓代碼變的更容易進(jìn)行單元測試,更多的詳細(xì)的代碼示例可以看AngularJS的作者M(jìn)i?ko Hevery大神的一篇blog,其中詳細(xì)列舉了各種會影響單元測試的代碼范例。

擴展知識

Code Smells

如果覺得上面的各種原則還是有點抽象,不好理解,那么直接學(xué)習(xí)Code Smells是一個很好的辦法。

Code Smells 表示代碼中可能會導(dǎo)致潛在問題的某些特征。這里簡單列舉一些典型的Code Smell。

方法名過長,單個類代碼過多,嗜好基本類型,參數(shù)列表過長,無用的Field,等。其實使用代碼檢查工具就可以有效減少Code Smells。

思考:為什么我們鐘愛依賴注入框架

因為依賴注入框架可以幫助我們更好的實現(xiàn)幾乎上述所有內(nèi)容,既能提高代碼質(zhì)量,又能輕松的編寫單元測試,何樂而不為。

Reference:

http://wiki.c2.com/?LawOfDemeter

https://testing.googleblog.com/2008/08/by-miko-hevery-so-you-decided-to.html

https://testing.googleblog.com/2008/07/how-to-think-about-new-operator-with.html

https://testing.googleblog.com/2008/07/breaking-law-of-demeter-is-like-looking.html

https://youtu.be/pK7W5npkhho

http://misko.hevery.com/attachments/Guide-Writing%20Testable%20Code.pdf

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,663評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,125評論 3 414
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,506評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,614評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 71,402評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,934評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,021評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,168評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,690評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,596評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,288評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,027評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,404評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,662評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,398評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 47,743評論 2 370

推薦閱讀更多精彩內(nèi)容