天天看點

Java單元測試之 單元測試規範

前言&背景

說起單元測試,每個開發人員都很熟悉,但很多人卻不重視。發現很多IT公司裡對于單測都沒有規範,最多也隻規定了一個覆寫率。很多開發人員認為單測屬于可有可無,意義不大。或者有時間就寫,沒時間就算了的情況,甚至認為:“反正有測試同學幫忙把控代碼品質,為什麼還要開發浪費時間寫單測呢?難道不是重複工作麼?”。這個問題其實很有代表性,很多開發因為有這個想法,就算寫了單測,可能也隻是敷衍了事或者随意發揮,寫出來的單測五花八門,沒有規範可言,也就沒有任何實際價值,純粹是為了完成任務而已。

Java單元測試之 單元測試規範

單元測試目的

正是因為目前業内對單測沒有統一的标準和要求,對于單測的意義和價值也有這樣那樣的疑惑,是以希望探讨一下對單元測試的一些想法,求同存異。

首先要解釋一下前言中開發同學的問題:

  • 第一,職責的不同。開發人員的職責是什麼?是完成一個功能的開發并保證其品質,而測試人員的職責則驗收開發的成果,為産品的品質把關,保證項目傳遞的品質。很明顯,保證代碼品質是開發這一環節的工作職責之一,你自己寫的代碼的品質自己都不把控,又怎麼能寄希望于别人能幫你把控呢?換句話說,在測試階段發現了你代碼裡的很多缺陷,其實就說明了你的開發職責沒有履行到位,本職工作都沒有完成。
  • 第二,成本的不同。對于同一個缺陷,在越早期發現,修複的成本就越小,這個道理想必大家都知道。如果很多的品質問題,都需要到測試階段才能發現,才來返工修複,這對整體項目的時間和資源來說絕對是不小的浪費。
  • 第三,角度的不同。對于開發人員來說,寫單測更多的是針對單個方法的邏輯的測試,而對于測試人員來說,更多的是針對功能的黑盒測試,很多方法内部的邏輯其實并沒有辦法測到,這些邏輯是必須用單測才能覆寫到的。

然後談談個人對單測的看法。單測的作用到底是什麼?意義究竟如何展現?

  • 第一,單測可以很好保證代碼品質,進而增加開發人員的信心,這點就不再贅述了。
  • 第二,單測可以一定程度提高代碼合理性。當我們發現給一個方法寫單測非常困難,比如單測需要覆寫的分支非常多,那可能說明方法可以拆分;又比如單測需要mock的調用非常多,那可能說明方法違背了單一責任原則,處理了太多的邏輯,也可以拆分等等。
  • 第三,單測能夠有效防止回溯問題(regression issue)的出現。所謂回溯問題,指的就在之前版本沒有在新版本才出現的問題。這種問題的嚴重程度是最高的,影響也是最惡劣的。原因很簡單:使用者可以接受一個本來在老版本就不存在的功能不可用,但是一定無法接受一個本來在老版本用地好好的功能突然失效了。在新功能開發完後,運作老功能單測,如果發現未變更邏輯的老功能單測報錯,則很有可能是出現了回溯問題。
  • 第四,單測能夠幫助測試人員确定回歸範圍。這一點其實是第三點擴充。在新功能提測的時候,開發人員需要提供測試範圍,畢竟随着功能的不停增加,全量回歸已經變得越來越不可能了。有些開發同學,為了安全起見,随意增加回歸範圍,這無疑增加了測試人員不必要的工作,是一種嚴重浪費測試資源的行為。在新功能開發完後,運作老功能單測,如果發現單測報錯,則說明這部分老功能的邏輯可能發生了變化,單測需要進行相應的調整,且相關功能應該屬于回歸的範圍

單元測試規範

一. 可衡量:單測的編寫應該是可以用具體的名額衡量的

單測通過率要求100%,行覆寫率要求50%。

解釋:通過率100%沒啥好多說的,如果單測跑不通過,那不是單測有問題就是代碼邏輯有問題。覆寫率的話可以根據具體的工程進行微調,建議不應小于40%,越底層的代碼覆寫率應該越高,越新的代碼覆寫率也應該越高。

老代碼有邏輯變更時,單測也應該做相應的變更。

解釋:這點的目的也是為了保證單測通過率100%。同時,這部分功能應該也屬于改次功能的測試回歸範圍内。

新業務提測前,必須保證老單測的通過率也保持100%。

解釋:這點的目的是為了防止回溯問題的出現。

二. 獨立性:單測應該是獨立且互相隔離的

一個單測隻測試一個方法。

解釋:保證了單測的獨立性。當單測出錯的時候也能夠明确知道是哪個方法出了問題。但這并不是說一個方法隻對應一個單測,因為為了覆寫方法内的不同分支,我們可以為一個方法建立多個單測。

單測不應該依賴于别的單測。

解釋:保證了單測的獨立性。每個單測應該都能獨立運作。不應該有A單測跑完才能跑B單測的情況。

單測如果涉及到資料變更,必須進行復原。

解釋:保證了單測的隔離性。如果單測運作後在資料庫中産生了資料,那這些髒資料可能幹擾測試同學的測試工作,且也可能影響别的單測的運作結果。

單測應該測試目标方法本身的邏輯,對于被測試的方法内部調用的非私有方法應進行mock,推薦使用Mockito進行mock。

解釋:目标方法存在内部調用情況,進行mock可以屏蔽其他方法對目标方法的影響。這樣保證了單測的獨立性,一個單測隻保證它測試的目标方法的邏輯正确性,而不應該受其内部調用方法的邏輯的影響,這部分應該是這些内部調用的方法對應的單測的責任。但是真實情況中,這一點是最難被嚴格執行,因為這樣做就意味着需要對所有的方法都設計單測,比如a調用b調用c的情況,需要至少設計三個單測,而不能隻對a設計單測來覆寫整個調用鍊。不過,這不正是單測的含義嗎?對最小的邏輯單元——方法進行測試,如果對于一個調用鍊進行測試,更像是內建測試的範疇了。而且如果不這麼做,我們就會違反上面的第4條“一個單測隻測試一個方法”。隻有一種情況例外,方法内部調用的是私有方法,這樣的話是可以通過調用方的單測一并測試的,見下面的第13條“私有方法通過調用類的單測進行測試”。我們可以試想一種情況,當一個項目由很多人協同開發時,我怎麼才能放心使用另一個人開發的方法?至少得提供單測吧,如果這個方法的測試是在其調用方的單測中的,那就沒有直接對應的單測了,這樣也就無法保證該方法是否被妥當測試過了。

三. 規範性:單測的編寫需要符合一定規範

對實作類進行測試而非接口。

解釋:面向接口程式設計,面向實作測試。

單測應該是無狀态的。

解釋:即單測應該可以重複執行,且無論跑幾次都應該保證通過率。比如有些方法會對目前時間進行判斷,對于這類方法的單測也需根據目前時間的不同而進行不同的測試。

覆寫範圍應包括所有提供了邏輯的類:service層、manager層、自定義mapper等,甚至還有部分提供業務邏輯的controller層代碼。

解釋:隻要是提供了邏輯的就應該測試,不過個人并不建議在controller層提供業務邏輯,具體原因參考《設計之道-controller層的設計》。

覆寫範圍不應包括自動生成的類:如MyBatis Generator生成的Mapper類、Example類,不應包括各種POJO(DO,BO,DTO,VO...),也不應包括無業務邏輯的controller類。

解釋:自動生成的類有啥好測的?POJO的getter/setter有啥好測的?沒有提供業務邏輯的controller類有啥好測的?這些被排除的類應該在覆寫率統計中被剔除。

私有方法通過調用類的單測進行測試。

解釋:因為私有方法在測試類内沒法直接調用,除非使用反射或其他Mock架構(PowerMock, TestableMock等)。

單測要覆寫到正常分支和異常分支,使用專門的異常測試屬性junit(expected)/testng(expectedExceptions)。禁止使用try-catch。

解釋:很多同學的單測覆寫率不達标,就是因為隻覆寫了正常的分支而遺漏的異常的分支。異常的測試和正常的一樣重要,也就是該報錯的時候就應該報錯。有些同學為了達到單測的覆寫率和通過率的名額,在單測中使用try-catch,這也是不允許的,應該使用專門的異常測試注解。

如果被測試的方法的邏輯展現在方法傳回或成員變量中,則使用Assert斷言驗證該傳回或成員變量。

解釋:如果一個方法的内部組裝了一個傳回值,或變更了一個成員變量,那麼應該使用Assert來驗證該傳回值或成員變量是否符合預期。

比如下面的三個方法,前兩個的邏輯都是展現在傳回值上,後一個的邏輯展現在成員變量中。

/**
     * 邏輯展現在傳回值
     *
     * @return
     */
    public String displayName() {
        String name = "HangzhouZoo";
        return "Zhejiang " + name;
    }

    /**
     * 邏輯展現在傳回值
     *
     * @return
     */
    public String luxuryShow() {
        String show = dog.run();
        return "luxury!! " + show;
    }

    /**
     * 邏輯展現在成員變量
     */
    public void close() {
        this.open = false;
    }           

那麼我們就可以使用Assert斷言來測試這些邏輯:

//邏輯在方法傳回展現
    @Test
    public void displayName() {
        Assert.assertEquals("Zhejiang HangzhouZoo", hangzhouZoo.displayName());
    }

    //邏輯在方法傳回展現
    @Test
    public void luxuryShow() {
        when(dog.run()).thenReturn("dog show");
        Assert.assertEquals("luxury!! dog show", hangzhouZoo.luxuryShow());
    }

    //邏輯在成員變量中展現
    @Test
    public void close() {
        Assert.assertTrue(hangzhouZoo.isOpen());
        hangzhouZoo.close();
        Assert.assertFalse(hangzhouZoo.isOpen());
    }           
如果被測試的方法的邏輯展現在内部的方法調用行為本身,則使用Mockito的verify驗證内部方法調用的情況。

解釋:有些方法的内部根據不同的條件會調用不同的方法,則應該驗證該方法的調用是否符合預期。Mockito的verify可以驗證被mock的方法是否調用了,甚至可以驗證方法調用的次數。

比如下面這個方法有三分條件分支,分支一抛出異常,分支二調用内部方法,分支三組裝傳回值。

/**
     * 邏輯展現在異常、方法調用行為和傳回值
     *
     */
    @Override
    public String show(Animal animal) throws ZooException {
        if (animal instanceof Tiger) {
            throw new ZooException("tiger is not allowed");
        } else if (animal instanceof Dog) {
            return animal.run();
        } else {
            return "only dogs here";
        }
    }           

其中分支二的邏輯就展現在方法調用的行為上,我們可以通過verify來驗證方法是否如預期一樣調用,也可使用times驗證方法調用的次數。

//被測試的方法的邏輯展現在内部方法的調用行為本身
    @Test
    public void show() throws Exception {
        when(dog.run()).thenReturn("dog run");
        hangzhouZoo.show(dog);
        //驗證方法被調用過了
        verify(dog).run();
        //也可以通過times參數來驗證方法具體被調用的次數
        verify(dog, times(1)).run();
        //驗證另一個分支,邏輯展現在傳回值
        Assert.assertEquals("only dogs here", hangzhouZoo.show(new Cat()));
    }           

當然,還記得第13條“異常分支也需要測試麼”,我們還需要寫一個單測來覆寫異常分支:

//測試異常分支
    @Test(expected = ZooException.class)
    public void showForEx() throws Exception {
        hangzhouZoo.show(new Tiger());
    }           
如果被測試的方法的邏輯展現在内部方法調用的參數中,即方法的邏輯用于建構内部調用方法的參數,則使用Mockito的verify驗證内部方法調用的參數。

解釋:有些方法的内部會組裝一個對象,然後将這個對象作為參數傳入另一個内部方法。使用Mockito的verify可以驗證被mock的方法被調用的參數。如果是簡單類型,可以直接驗證,如果是複雜類,則需要擴充

ArgumentMatcher

類來做驗證。

下面這個方法的邏輯展現在内部調用方法的參數構造上:

/**
     * 邏輯展現在參數構造-基本類
     *
     * @param times
     */
    public void bark(int times) {
        int actualTimes = times * 10;
        dog.bark(actualTimes);
    }           

由于參數類型是基本類,是以我們可以直接用verify來驗證:

//邏輯在參數展現-簡單類型
    @Test
    public void bark() {
        doNothing().when(dog).bark(anyInt());
        hangzhouZoo.bark(3);
        verify(dog).bark(30);
        //與上面等價
        verify(dog).bark(eq(30));
    }           

不過如果像下面這樣的參數是複雜類的,就需要擴充一下:

/**
     * 邏輯展現在參數構造-複雜類
     *
     * @param
     * @return
     */
    public String feedVegetable() {
        Food tomato = Food.builder().name("tomato").build();
        return dog.eat(tomato);
    }           

自定義參數比對器:

/**
 * 自定義參數比對規則
 */
public class ObjectMatcher<T> extends ArgumentMatcher<T> {

    private Object expected;
    private Function<T, Object> getProperty;

    public ObjectMatcher(Object expected, Function<T, Object> getProperty) {
        this.expected = expected;
        this.getProperty = getProperty;
    }

    @SuppressWarnings("unchecked")
    @Override
    public boolean matches(Object actual) {
        return getProperty.apply((T) actual).equals(expected);
    }
}           

測試的時候使用argThat校驗方法參數:

//邏輯在參數展現-複雜類
    @Test
    public void feedVegetable() {
        when(dog.eat(any())).thenReturn("dog eat");
        hangzhouZoo.feedVegetable();
        //驗證參數
        verify(dog).eat(argThat(new ObjectMatcher<>("tomato", Food::getName)));
    }           
單測應在相應的目标方法開發完後立即編寫,如能在開發前就開始編寫則更好(TDD)。

解釋:這點可能會違背很多開發同學的認知,怎麼可能先寫單測再寫代碼呢?實際上,如果稍微了解下測試驅動開發(Test-Driven Development),就會發現這并非異想天開,反倒是順理成章的事。我認為有兩種場景下單測的習慣是很容易能夠推動的,第一種是團隊裡沒有測試人員,代碼品質完全由開發人員把控;而第二種就是軟體開發流程使用的是TDD的方式,這樣天然的就保證了單測必須存在。

原文: 設計之道-單元測試規範 作者:SawyerZhou