工作多年后我更明白了UT的重要性

對于有經(jīng)驗的開發(fā)寫單元測試是非常有必要的,并且對自己的代碼質(zhì)量以及編碼能力也是有提高的。單元測試可以幫助減少bug泄露,通過運行單元測試可以直接測試各個功能的正確性,bug可以提前發(fā)現(xiàn)并解決,由于可以跟斷點,所以能夠比較快的定位問題,比泄露到生產(chǎn)環(huán)境再定位要代價小很多。同時充足的UT是保證重構(gòu)正確性的有效手段,有了足夠的UT防護,才能放開手腳大膽重構(gòu)已有代碼,工 作多年后更了解了UT,了解了UT的重要性。

單元測試

在敏捷的開發(fā)理念中,覆蓋全面的自動化測試是添加新特性和重構(gòu)的必要前提。單元測試在軟件開發(fā)過程中的重要性不言而喻,特別是在測試驅(qū)動開發(fā)的開發(fā)模式越來越流行的前提下,單元測試更成為了軟件開發(fā)過程中不可或缺的部分。同時單元測試也是提高軟件質(zhì)量,花費成本比較低的重要方法。

1.單元測試的時機和測試點

1.1單元測試的時機

  1. 在業(yè)務(wù)代碼前編寫單元測試采用測試驅(qū)動開發(fā),這是我們經(jīng)常使用和推薦的。
  2. 在業(yè)務(wù)代碼過程中進行單元測試,對重要的業(yè)務(wù)邏輯和復(fù)雜的業(yè)務(wù)邏輯進行添加測試。
  3. 在業(yè)務(wù)邏輯之后再編寫測試是我們不建議的,除非對遺留代碼的修改,需要先進行測試用例的添加,保證我們修改和重構(gòu)后的代碼不會破壞之前的業(yè)務(wù)邏輯。

1.2單元測試的測試點

  1. 在邏輯復(fù)雜的代碼中添加測試。
  2. 在容易出錯的地方添加測試。
  3. 不易理解的代碼中添加測試,在以后看到測試就可以非常清楚代碼要實現(xiàn)的邏輯。
  4. 在考慮后期需求變更相對較大的代碼中添加測試,這樣后期需求更變修改代碼之后就不用太擔(dān)心寫的代碼對不對以及是否破壞了已有代碼邏輯。
  5. 外部接口處添加解耦代碼、同時增加單元測試。

2.代碼不可測試性的根源

  1. 代碼中調(diào)用到了底層平臺的接口或只有系統(tǒng)運行后才能獲得的資源(數(shù)據(jù)庫連接、發(fā)送郵件,網(wǎng)絡(luò)通訊,遠程服務(wù), 文件系統(tǒng)等)但業(yè)務(wù)代碼與這些資源未解耦。這樣在測試代碼需要創(chuàng)建這個類的時候會去初始化這些資源時導(dǎo)致無法測試。
  2. 在方法內(nèi)部new一個與本次測試無關(guān)的對象。
  3. 代碼依賴層次很深,邏輯復(fù)雜,一次方法的往往要調(diào)用N次底層的接口,或者類的方法非常多。這樣的代碼我們需要對類進行重構(gòu),盡量保證類的單一職責(zé):這個類在系統(tǒng)中的意圖應(yīng)當(dāng)是單一的,且修改它的原因應(yīng)該只有一個。
  4. 使用單例類和靜態(tài)方法,并且單例類和靜態(tài)方法使用到了我們底層的接口或者其他接口。

3.測試工具使用和測試方法介紹

在做單元測試的時候,我們會發(fā)現(xiàn)我們要測試的方法會引用很多外部依賴的對象,如調(diào)用平臺接口、連接數(shù)據(jù)庫、網(wǎng)絡(luò)通訊、遠程服務(wù)、FTP、文件系統(tǒng)等等。 而我們沒法控制這些外部依賴的對象,為了解決這個問題,我們就需要用到Mock工具來模擬這些外部依賴的對象,來完成單元測試。
現(xiàn)在比較流行的Mock工具有JMock、EasyMock、Mockito、PowerMock。我們使用的是Mockito和PowerMock。PowerMock彌補了其他3個Mock工具不能mock靜態(tài)、final 、私有方法的缺點。
在下面的情況下我們可以使用Mock對象來完成單元測試。

  1. 實對象具有不可確定的行為,會產(chǎn)生不可預(yù)測的結(jié)果。 如:數(shù)據(jù)庫查詢可以查出一條記錄、多條記錄、或者返回數(shù)據(jù)庫異常等結(jié)果。
  2. 真實對象很難被創(chuàng)建。如:平臺代碼,或者Web、JBoss容器等。
  3. 真實對象的某些行為很難觸發(fā)。 如:代碼中需要處理的網(wǎng)絡(luò)異常、數(shù)據(jù)庫異常、消息發(fā)送異常等。
  4. 真實情況令程序運行很慢。 在敏捷的實踐中我們完成了CI,在開發(fā)提交代碼前需要執(zhí)行整個項目的單元測試用例,只有測試通過才可以提交代碼。這就要求我們每個單元測試用例需要盡可能的短,整個項目的測試時間才會短。當(dāng)有的測試用例需要測試大數(shù)據(jù)量情況下系統(tǒng)的預(yù)期時,就需要使用Mock對象。
    如我們代碼中需要判斷只有當(dāng)系統(tǒng)的緩存隊列大于40000時,我們開始考慮丟棄非關(guān)鍵的消息,當(dāng)超過48000時,需要只處理最重要的消息,當(dāng)超過50000時需要丟棄全部消息。此時就需要對此緩存隊列進行Mock,根據(jù)調(diào)用返回不同的數(shù)據(jù)量給測試。
  5. 測試需要知道真實對象是如何被調(diào)用的。如:測試用例需要驗證是否發(fā)送了JMS,此時就可以通過Mock對象是否被調(diào)用來測試。
  6. 真實對象實際不存在時。 如:當(dāng)我們與其他模塊交互時,或者與新的接口打交道時,更有就是對方的代碼還沒有開發(fā)完畢時,我們可以通過Mock來模擬接口的行為,實現(xiàn)代碼邏輯的驗證和測試。

3.1 Mocktio簡單使用說明

mock可以模擬各種各樣的對象,從而代替真正的對象做出希望的響應(yīng)。

1、模擬對象的創(chuàng)建

List cache = mock(ArrayList.class);
System.out.println(cache.get(0));
//-> null 由于沒有對mock對象給預(yù)期,所以返回都是null

2、模擬對象方法調(diào)用的返回值

List cache = mock(ArrayList.class);
when(cache.get(0)).thenReturn("hello");
System.out.println(cache.get(0));
//-> hello

3、模擬對象方法多次調(diào)用和多次返回值

List cache = mock(ArrayList.class);
when(cache.get(0)).thenReturn("0").thenReturn("1").thenReturn("2");
System.out.println(cache.get(0));
System.out.println(cache.get(0));
System.out.println(cache.get(0));
System.out.println(cache.get(0));
//-> 0,1,2,2 如果實際調(diào)用的次數(shù)超過了預(yù)期的次數(shù),則會一直返回最后一次的預(yù)期值。

4、模擬對象方法調(diào)用拋出異常

List cache = mock(ArrayList.class);
when(cache.get(0)).thenReturn(new Exception("Exception"));
System.out.println(cache.get(0));

5、模擬對象方法在沒有返回值時也可以拋異常

List cache = mock(ArrayList.class);
doThrow(new Exception("Exception")).when(cache).clear();

6、模擬方法調(diào)用時的參數(shù)匹配

AnyInt的使用,匹配任何int參數(shù)
List cache = mock(ArrayList.class);
when(cache.get(anyInt())).thenReturn("0");
System.out.println(cache.get(0));
System.out.println(cache.get(2));
//-> 0,0

7、模擬方法是否被調(diào)用和調(diào)用的次數(shù),預(yù)期調(diào)用了一次

List cache = mock(ArrayList.class);
cache.add("steven");
verify(cache).add("steven");

預(yù)期調(diào)用了兩次入緩存,沒有調(diào)用清除緩存的方法

List cache = mock(ArrayList.class);
cache.add("steven");
cache.add("steven");
verify(cache,times(2)).add("steven");
verify(cache,never()).clear();

還可以通過atLeast(int i)和atMost(int i)來替代times(int i)來驗證被調(diào)用的次數(shù)最小值和最大值。
【注意】
Mock對象默認(rèn)情況下,對于所有有返回值且沒有預(yù)期過的方法,Mocktio會返回相應(yīng)的默認(rèn)值。對于內(nèi)置類型會返回默認(rèn)值,如int會返回0,布爾值返回false。對于其他type會返回null。mock對象會覆蓋整個被mock的對象,因此沒有預(yù)期的方法只能返回默認(rèn)值。這個在初次使用Mock時需要注意,經(jīng)常會發(fā)現(xiàn)測試結(jié)果不對,最后才發(fā)現(xiàn)自己未給相應(yīng)的預(yù)期。

3.2 PowerMock簡單使用說明

PowerMock使用一個自定義類加載器和字節(jié)碼操作來模擬靜態(tài)方法,構(gòu)造函數(shù),final類和方法,私有方法,去除靜態(tài)初始化器等等。
PowerMock使用簡單,在類名前添加注解,在預(yù)期前調(diào)用PowerMock的mock靜態(tài)類方法,其他的預(yù)期方法和Mockito類似。

@PrepareForTest(System.class)
@RunWith(PowerMockRunner.class)
public class Test {
@org.junit.Test
public void should_get_filed() {
    System.out.println(System.getProperty("myName"));
    PowerMockito.mockStatic(System.class);
    PowerMockito.when(System.getProperty("myName")).thenReturn("steven");
    System.out.println(System.getProperty("myName"));
    //->null steven
    }
}

3.3 Fake對象的使用

測試中需要模擬對象,除了常用的mock對象外,我們還會經(jīng)常用到Fake對象。Mock對象是預(yù)先計劃好的對象,帶有各種期待,他們組成了一個關(guān)于他們期待接受的調(diào)用的詳細說明。而Fake對象是有實際可工作的實現(xiàn),但是通常有一些缺點導(dǎo)致不適合用于產(chǎn)品,我們通常使用Fake對象在測試中來模擬真實的對象。
在測試中經(jīng)常會發(fā)現(xiàn)我們需要使用系統(tǒng)或者平臺給我們提供的接口,在測試中我們可以新創(chuàng)建一個類去實現(xiàn)此接口,然后在根據(jù)具體情況去實習(xí)此模擬類的相應(yīng)方法。

如我們創(chuàng)建了自己的FakeLog對象來模擬真實的日志打印,這樣我們可以在測試類中使用FakeLog來代替代碼中真實使用的Log類,可以通過FakeLog的方法和預(yù)期的結(jié)果比較來進行測試正確性的判斷。

Fake對象和mock對象還有一個實際中使用的區(qū)別,F(xiàn)ake對象我們構(gòu)造好后,以后所有的代碼都去調(diào)用此Fake對象就可以了,不用每個類每次都要給預(yù)期。從這個角度可以看到當(dāng)一個類的方法或者預(yù)期相對不變時,可以采用Fake對象,當(dāng)這個類的返回信息預(yù)期變化非常不可預(yù)期時,可以采用MOCK對象。

3.4Mock服務(wù)的兩種方式

(1)直接注入:用于類之間的依賴層次較多的情況,測試整個業(yè)務(wù)流程,粒度大。

Service service = mock(Service.class);
new Processor().process(service );

(2)重寫protected方法返回mock對象:用于類直接依賴于該服務(wù)的情況,測試行為的細節(jié),粒度小。

Service service = mock(Service .class);
generator = new Generator() {
    @Override
    protected Service getService() {
        return service;
    }
}

3.5測試異常

Throwable有兩個直接子類:Exception和Error

1、expcetd=SomeExecption.class

@Test(expected = AssertionError.class)
public void should_occur_assertion_error_when_xx() throws Exception {
    new Processor().process();
}

@Test(expected = NumberFormatException.class)
public void should_throw_number_format_exception_when_xx() {
    Convert.convert2Long();
}

2、try-catch-fail只能用于Exception,Error不能用此種方式

try {
    method.invoke();
    fail();
} catch (Exception e) {
    assertTrue(e.getCause() instanceof RuntimeException);
}

3.6私有方法—采用反射來調(diào)用

@Test
public void should_throw_runtime_exception_when_check_data_fail() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
   
    Method method = Generator.class.getDeclaredMethod("check", Item.class);
    method.setAccessible(true);
    try {
        method.invoke(Generator, mock(Item.class));
    } catch (Exception e) {
        assertTrue(e.getCause() instanceof RuntimeException);
    }
}

4.單元測試的格式

4.1測試類結(jié)構(gòu)

public class ExampleTest {
    @BeforeClass
    public static void setUp() throws Exception {
        initGlobalParameter();
        registerServices();
    }

    @Before
    public void setUp() throws Exception {
        initGlobalParameter();
        registerServices();
    }

    @After
    public void tearDown() throws Exception {
        ServiceAccess.serviceMap.clear();
        clearCache();     }

    @AfterClass
    public static void tearDown() throws Exception {
        ServiceAccess.serviceMap.clear();
        clearCache();
    }

    @Test
    public void should_get_some_result1_when_give_some_condition1{
    }

    @Test
    public void should_get_some_result2_when_give_some_condition2{
    }
}

JUnit4是JUnit框架有史以來的最大改進,其主要目標(biāo)便是利用Java5的Annotation特性簡化測試用例的編寫。先簡單解釋一下什么是Annotation,這個單詞一般是翻譯成元數(shù)據(jù)。元數(shù)據(jù)是什么?元數(shù)據(jù)就是描述數(shù)據(jù)的數(shù)據(jù)。也就是說,這個東西在Java里面可以用來和public、static等關(guān)鍵字一樣來修飾類名、方法名、變量名。修飾的作用描述這個數(shù)據(jù)是做什么用的,差不多和public描述這個數(shù)據(jù)是公有的一樣。

  • @Before:每個測試方法執(zhí)行之前都要執(zhí)行一次。
  • @After:before對應(yīng),每個測試方法執(zhí)行之后要執(zhí)行一次。
  • @BeforeClass:在所有測試方法之前運行,只運行一次。一般在此類中申請昂貴的外部資源。父類中有@BeforeClass方法,在其子類運行之前也會運行。
  • @AfterClass:與BeforeClass對應(yīng),在所有測試結(jié)束后,釋放BeforeClass中申請的資源。 注意:@Before,@After,@BeforeClass,@AfterClass 標(biāo)示的方法一個類中只能各有一個
  • @Test: 告訴JUnit,該方法要作為一個測試用例來運行。

4.2測試代碼的位置

在Java中一個包可以橫跨兩個不同的目錄,所以我們的測試代碼和產(chǎn)品代碼放在同一目錄中,這樣維護起來更方便,測試代碼和產(chǎn)品代碼在同一個包中,這樣也減少了不必要的包引起,同時在測試類中使用繼承更加的方便。

4.3測試用例格式3段式

一個測試用例主體內(nèi)容一般采用三段式:given-when-then

  • Given:構(gòu)造測試條件;

  • When:執(zhí)行待測試的方法;

  • Then:判斷測試結(jié)果是否符合期望。
    例如:

@Test
public void should_get_correct_result_when_add_two_numbers() {
    int a = 1;
    int b = 2;

    int c = MyMath.add(a, b);

    assertEquals(3, c);
}

4.4類名的命名方式

測試類的名稱以Test結(jié)尾。從目標(biāo)類的類名衍生出其單元測試類的類名。類名前加上Test后綴。
Fake(偽類)放在測試包中,使用前綴Fake。

4.5方法名的定義方式

should …do something…when…under some conditions…

例如:

should_NOT_delete_A_when_exists_B_related_with_A
should_throw_exception_when_the_parameter_is_illegal

4.6業(yè)務(wù)代碼中為測試提供的方法的注解

在業(yè)務(wù)代碼中為了測試而單獨提供的保護方法或者其他方法,我們通過@ForTest來標(biāo)注。FofTest類如下:

@Target({ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.TYPE})
public @interface ForTest {
    String description() default "";
}

5.代碼中涉及外部接口時,如何來編寫單元測試

我們的代碼涉及的模塊非常眾多,經(jīng)常需要相互協(xié)作來完成一個功能,在此過程中經(jīng)常需要使用到外部的接口、同時也為別的模塊提供服務(wù)。

5.1數(shù)據(jù)庫

數(shù)據(jù)庫的單元測試,由于測試無法進行數(shù)據(jù)庫的連接,故我們通過提取通用接口(DBManagerInterface)和FakeDBManager來實現(xiàn)數(shù)據(jù)庫解耦。FakeDBManager可以對真實的數(shù)據(jù)庫進行模擬,也就是我們通過Fake一個簡單的內(nèi)存數(shù)據(jù)庫來模擬實際真實的數(shù)據(jù)庫。
DBManager是我們的真實連接數(shù)據(jù)庫的業(yè)務(wù)類。我們在測試時,是可以通過注入的方式用FakeDBManager來替換DBManager。

5.2平臺接口

5.2.1 平臺接口的Mock

平臺中的服務(wù)接口,都可以通過mock來進行測試。需要注意的是在業(yè)務(wù)代碼中需要進行相應(yīng)的解耦,可以通過SET方法或者構(gòu)造器來注入平臺的服務(wù)類。

public class ListenerTest {
    private ServerService  service = mock(ServerService.class);

@Before
public void setUp() throws Exception {
    when(service.getIp()).thenReturn("127.0.0.1");
    when(service.getPort()).thenReturn("80");
    when(service.getTcpPort()).thenReturn("8080");
}

此處需要注意如果用到靜態(tài)變量全局唯一的,需要在使用后在 tearDown中進行清除。

5.3 文件接口的測試

我們的業(yè)務(wù)中也會出現(xiàn)與外部文件進行讀寫的代碼。按照單元測試書寫的原則,單元測試應(yīng)該是獨立的,不依賴于外部任何文件或者資源的。好的單元測試是運行速度快,能夠幫助我們定位問題。所以我們普通涉及到外部文件的代碼,都需要通過mock來預(yù)期其中的信息,如MOCK(I18n)文件或者properties、xml文件中的數(shù)據(jù)。
對于一些重要的文件,考慮到資源消耗不大的情況下,我們也會去為這些文件添加單元測試。需要訪問真實的文件,我們第一步就需要去獲取資源文件的具體位置。通過下面的FileService的getFileWorkDirectory我們可以獲取單元測試運行時的根目錄。

public class FileService {
public static String getFileWorkDirectory() {
    return new StringBuilder(getFileCodeRootDirectory()).append("test").toString();
}

public static String getFileCodeRootDirectory() {
    String userDir = System.getProperty("user.dir");
    userDir = userDir.substring(0, userDir.indexOf(File.separator + "CODE" + File.separator));
    StringBuilder workFilePath = new StringBuilder(userDir);
    workFilePath.append(File.separator).append("CODE").append(File.separator);
    return workFilePath.toString();
}
}

我們在單元測試中可以通過傳入具體的文件名稱,可以在測試代碼中訪問真實的文件。
這種方法可以適用I18n文件,xml文件, properties文件。
我們在對I18n文件進行測試時,也可以通過Fake對象根據(jù)具體的語言來進行國際化信息的測試。具體FakeI18nWrapper的代碼在第7章中給出可以參考。

@Before
public void setUp() throws Exception {
    String i18nFilePath = FileService.getFileWorkDirectory() + "\\conf\\i18n.xml";
    I18N i18N = new FakeI18nWrapper(new File(i18nFilePath), I18nLanguageType.en_US);
    I18nOsf.setTestingI18NInstance(i18N);
}

6.單元測試中涉及多線程、單例類、靜態(tài)類的處理

6.1多線程測試

通過單元測試,能較早地發(fā)現(xiàn) bug 并且能比不進行單元測試更容易地修復(fù)bug。但是普通的單元測試方法(即使當(dāng)徹底地進行了測試時)在查找并行 bug 方面不是很有效。這就是為什么在實驗室測試沒有問題,但在外場經(jīng)常出現(xiàn)各種莫名其妙的問題。
為什么單元測試經(jīng)常遺漏并行 bug?通常的說法是并行程序和Bug的問題在于它們的不確定性。但是對于單元測試目的而言,在于并行程序是非常 確定的。所以我們單元測試需要對關(guān)鍵的邏輯、涉及到并發(fā)的場景進行多線程測試。
多線程的不確定性和單元測試的確定的預(yù)期確實是有點矛盾,這就需要精心的設(shè)計單元測試中的多線程用例。
Junit本身是不支持普通的多線程測試的,這是因為Junit的底層實現(xiàn)上是用System.exit退出用例執(zhí)行的。JVM都終止了,在測試線程啟動的其他線程自然也無法執(zhí)行。所以要想編寫多線程Junit測試用例,就必須讓主線程等待所有子線程執(zhí)行完成后再退出。我們一般的方法是在主測試線程中增加sleep方法,這種方法優(yōu)點是簡單,但缺點是不同機器的配置不一樣,導(dǎo)致等待時間無法確定。更為高效的多線程單元測試可以使用JAVA的CountDownLatch和第三方組件GroboUtils來實現(xiàn)。
下面通過一個簡單的例子來說明下多線程的單元測試。
測試的業(yè)務(wù)代碼如下,功能是唯一事務(wù)號的生成器。

class UniqueNoGenerator {
    private static int generateCount = 0;

    public static synchronized int getUniqueSerialNo() {
        return generateCount++;
    }
}

6.1.1 Sleep

private static Set<Integer> results = new HashSet<>();

@Test
public void should_get_unique_no() throws InterruptedException {
    Thread[] threads = new Thread[100];
    for (int i = 0; i < threads.length; i++) {
    threads[i] = generateThread();
    }
    //啟動線程
    Arrays.stream(threads).forEach(Thread::start);
    Thread.sleep(100L);
    
    assertEquals(results.size(), 100);
 }

private Thread generateThread() {
    return new Thread(() -> {
        int uniqueSerialNo = UniqueNoGenerator.getUniqueSerialNo();
        results.add(uniqueSerialNo);
    });
}

通過Sleep來等待測試線程中的所有線程執(zhí)行完畢后,再進行條件的預(yù)期。問題就是用戶無法準(zhǔn)確的預(yù)期業(yè)務(wù)代碼線程執(zhí)行的時間,不同的環(huán)境等待的時間也是不等的。由于需要添加延時,同時也違背了我們單元測試執(zhí)行時間需要盡量短的原則。

6.1.2 ThreadGroup

private static Set<Integer> results = new HashSet<>();
private ThreadGroup threadGroup = new ThreadGroup("test");

@Test
public void should_get_unique_no() throws InterruptedException {
    Thread[] threads = new Thread[100];
    for (int i = 0; i < threads.length; i++) {
    threads[i] = generateThread();
 }
    //啟動線程
    Arrays.stream(threads).forEach(Thread::start);
    while (threadGroup.activeCount() != 0) {
    Thread.sleep(1);
    }
    assertEquals(results.size(), 100);
    }
    
    private Thread generateThread() {
    return new Thread(threadGroup, () -> {
    int uniqueSerialNo = UniqueNoGenerator.getUniqueSerialNo();
    results.add(uniqueSerialNo);
    });
}

這個是通過ThreadGroup來實現(xiàn)多線程測試的,可以把需要測試的類放入一個線程組,同時去判斷線程組中是否還有未結(jié)束的線程。測試中需要注意把新建的線程加入到線程組中。

6.1.3 CountDownLatch

private static Set<Integer> results = new HashSet<>();
private CountDownLatch countDownLatch = new CountDownLatch(100);

@Test
public void should_get_unique_no() throws InterruptedException {
    Thread[] threads = new Thread[100];
    for (int i = 0; i < threads.length; i++) {
        threads[i] = generateThread();
    }
    //啟動線程
    Arrays.stream(threads).forEach(Thread::start);
    countDownLatch.await();

    assertEquals(results.size(), 100);
}

private Thread generateThread() {
    return new Thread(() -> {
        int uniqueSerialNo = UniqueNoGenerator.getUniqueSerialNo();
        results.add(uniqueSerialNo);
        countDownLatch.countDown();
    });
}

通過JAVA的CountDownLatch可以很方便的來判斷,測試中的線程是否已經(jīng)執(zhí)行完畢。CountDownLatch是一個同步輔助類,在完成一組正在其他線程中執(zhí)行的操作之前,它允許一個或多個線程一直等待,我們這里是讓測試主線程等待。countDown方法是當(dāng)前線程調(diào)用此方法,則計數(shù)減一。awaint方法,調(diào)用此方法會一直阻塞當(dāng)前線程,直到計時器的值為0。

6.2單例類測試

單例模式要點:

  1. 單例類在一個容器中只有一個實例。
  2. 單例類使用靜態(tài)方法自己提供向客戶端提供實例,自己擁有自己的引用。
  3. 必須向整個容器提供自己的實例。
    單例類的實現(xiàn)方式有多種方式,如懶漢式單例、餓漢式單例、登記式單例等。我們這里采用內(nèi)部類的形式來構(gòu)造單例類,實現(xiàn)的優(yōu)點是此種方式不需要給類或者方法添加鎖,唯一實例的生成是由JAVA的內(nèi)部類生成機制保證。
    下面的例子構(gòu)造了一個單例類,同時這個單例類我們提供了一個獲取遠程Cpu信息的方法。再構(gòu)造一個使用類ResourceManager.java來模擬調(diào)用此單例類,同時看下我們測試ResourceManager.java過程中遇到的問題。
    單例類DBManagerTools.java:
public class DbManager {
        private DbManager() {
        }
        
        public static DbManager getInstance() {
        return DbManagerHolder.instance;
        }
        
        private static class DbManagerHolder {
        private static DbManager instance = new DbManager();
        }
        
        public String getRemoteCpuInfo(){
        FtpClient ftpClient = new FtpClient("127.0.0.1","22");
        return ftpClient.getCpuInfo();
        }
    }

調(diào)用類 ResourceManager.java:

public class ResourceManager {
    public String getBaseInfo() {
        StringBuilder buffer = new StringBuilder();
        buffer.append("IP=").append("127.0.0.1").append(";CPU=").append(DbManager.getInstance().getRemoteCpuInfo());
        return buffer.toString();
    }
}

測試類 
@Test
public void should_get_cpu_info() {
    String expected = "IP=127.0.0.1;CPU=Intel";
    ResourceManager resourceManager = new ResourceManager();

    String baseInfo = resourceManager.getBaseInfo();

    assertThat(baseInfo, is(expected));
}

從上面的描述可以看到,由于業(yè)務(wù)代碼強關(guān)聯(lián)了一個單例類,同時這個單例類會去通過網(wǎng)絡(luò)獲取遠程機器的信息。這樣我們的單元測試在運行中就會去連接網(wǎng)絡(luò)中的服務(wù)器導(dǎo)致測試失敗。在業(yè)務(wù)類中類似這種涉及到單例類的調(diào)用經(jīng)常用到。
這種情況下我們需要修改下業(yè)務(wù)代碼使代碼可測。
第一種方法:提取方法并在測試類中復(fù)寫。

public class ResourceManager {
    public String getBaseInfo() {
        StringBuilder buffer = new StringBuilder();
        buffer.append("IP=").append("127.0.0.1").append("CPU=").append(getRemoteCpuInfo());
        return buffer.toString();
    }

    @ForTest
    protected String getRemoteCpuInfo() {
        return DbManager.getInstance().getRemoteCpuInfo();
    }
}

@Test
public void should_get_cpu_info() {
    String expected = "IP=127.0.0.1;CPU=Intel";
    ResourceManager resourceManager = new ResourceManager(){
        @Override
        protected String getRemoteCpuInfo() {
            return "Intel";
        }
    };

    String baseInfo = resourceManager.getBaseInfo();

    assertThat(baseInfo, is(expected));
}

第二種方法:提取單例類中的方法為接口,然后在業(yè)務(wù)代碼中通過set方法或者構(gòu)造器注入到業(yè)務(wù)代碼中。

public class DbManager implements ResourceService{
    private DbManager() {
    }

    public static DbManager getInstance() {
        return DbManagerHolder.instance;
    }

    private static class DbManagerHolder {
        private static DbManager instance = new DbManager();
    }

    @Override
    public String getRemoteCpuInfo(){
        FtpClient ftpClient = new FtpClient("127.0.0.1","22");
        return ftpClient.getCpuInfo();
    }

public interface ResourceService {
    String getRemoteCpuInfo();
}

public class ResourceManager {
    private ResourceService resourceService = DbManager.getInstance();

    public String getBaseInfo() {
        StringBuilder buffer = new StringBuilder();
        buffer.append("IP=").append("127.0.0.1").append("CPU=").append(resourceService.getRemoteCpuInfo());
        return buffer.toString();
    }

    public void setResourceService(ResourceService resourceService) {
        this.resourceService = resourceService;
    }
}

@Test
public void should_get_cpu_info() {
    String expected = "IP=127.0.0.1;CPU=Intel";
    ResourceManager resourceManager = new ResourceManager();
    DbManager mockDbManager = mock(DbManager.class);
    resourceManager.setResourceService(mockDbManager);
    when(mockDbManager.getRemoteCpuInfo()).thenReturn("Intel");
    
    String baseInfo = resourceManager.getBaseInfo();

    assertThat(baseInfo, is(expected));
}

通過上面的方法可以方便的解開業(yè)務(wù)代碼對單例的強依賴,有時候我們發(fā)現(xiàn)我們的業(yè)務(wù)代碼是靜態(tài)類,這個時候你會發(fā)下第一種方法是解決不了問題的,只能通過第2中方法來實現(xiàn)。
通過上面的代碼可以看到我們應(yīng)該盡量的少用單例,在必須使用單例時可以設(shè)計接口來進行業(yè)務(wù)與單例類的解耦。

6.3靜態(tài)類測試

靜態(tài)類與單例類類似,也可以通過提取方法后通過復(fù)現(xiàn)方法來解耦,同樣也可以通過服務(wù)注入的方式來實現(xiàn)。也可以使用PowerMock來預(yù)期方法的返回。
實際應(yīng)用中如果單例類不需要維護任何狀態(tài),僅僅提供全局訪問的方法,這種情況考慮可以使用靜態(tài)類,靜態(tài)方法比單例更快,因為靜態(tài)的綁定是在編譯期就進行的。
同時需要注意的是不建議在靜態(tài)類中維護狀態(tài)信息,特別是在并發(fā)環(huán)境中,若無適當(dāng)?shù)耐酱胧┒薷亩嗑€程并發(fā)時,會導(dǎo)致壞的競態(tài)條件。
單例與靜態(tài)主要的優(yōu)點是單例類比靜態(tài)類更具有面向?qū)ο蟮哪芰?,使用單例,可以通過繼承和多態(tài)擴展基類,實現(xiàn)接口和更有能力提供不同的實現(xiàn)。
在我們開發(fā)過程中考慮到單元測試,還是需要謹(jǐn)慎的使用靜態(tài)類和單例類。

7.代碼可測性的解耦方法

在使用一些解依賴技術(shù)時,我們常常會感覺到許多解依賴技術(shù)都破壞了原有的封裝性。但考慮到代碼的可測性和質(zhì)量,犧牲一些封裝性也是可以的,封裝本身也并不是最終目的,而是幫助理解代碼的。下面在介紹下常用的解依賴方法。這些解依賴方法的思想都是通用的,采用控制反轉(zhuǎn)和依賴注入的方式來進行。

7.1盡量減少業(yè)務(wù)代碼與平臺代碼之間的耦合

軟件開發(fā)中調(diào)用平臺服務(wù)查詢資源屬性的典型代碼:

public class DataProceeor{
    private static final SomePlatFormService service = ServerService.lookup(SomePlatFormService.ID);
    public static CompensateData getAttributes(String name){
        service.queryCompensate(name);
    }
}

這種代碼在實現(xiàn)上沒有問題,但是無法進行單元測試(不啟動軟件)。因為此類加載時需要獲取平臺查詢資源相關(guān)的服務(wù),業(yè)務(wù)代碼與平臺代碼存在強耦合性。
在不破壞原有功能的基礎(chǔ)上對這段代碼做如下改造:

1、引入實例變量和構(gòu)造器

public class DataProceeor{
    private static final SomePlatformService service = ServerService.lookup(SomePlatformService.ID);
    private SomePlatformService _service;

    public DataProceeor(SomePlatformService service) {
        _service = service;
    }

    public DataProceeor() {
        _service = ServerService.lookup(SomePlatformService.ID);;
    }

    public CompensateData getAttributes(String name){
        service.queryCompensate(name);
    }
}

2、增加新方法

public CompensateData getSomeAttributes(String name){
    _service.queryCompensate(name);
}

3、查找代碼中所有用到方法getAttributes的地方,全部替換成getSomeAttributes。

4、完成第3步后,刪除已經(jīng)無用的變量和方法。

5、重命名引入的變量和方法,使其符合命名規(guī)范。

public class DataProceeor{
    private SomePlatformService service;
    public DataProceeor(SomePlatformService service){
        this.service = service;
    }

    public DataProceeor() {
        service = ServerService.lookup(SomePlatformService.ID);;
    }

    public static CompensateData getAttributes(String name){
        service.queryCompensate(name);
    }
}

6、增加對新方法的測試用例

public class DataProcessorTest {
    private DataProceeor dataProceeor;
    private SomePlateService somePlateService;
    private Map<String, String> attributes;

    @Before
    public void setUp() throws Exception {
        attributes.put("value", "1");
    }

    @Test
    public void should_get_attributes() {
        somePlateService = mock(SomePlateService.class);
        when(somePlateService.queryAttribue()).thenReturn(attributes);

        dataProceeor = new DataProceeor();

        Data data = dataProceeor.getAttributes("value");
        assertThat(data.value(), is("1"));
        assertThat(data.value(), is("2"));
    }
}

運行該測試用例,發(fā)現(xiàn)最后一句斷言沒有通過:
修改最后一句斷言為:assertThat(data.value(), not("2"));
再次運行測試,測試用例通過。

7.2 擴展平臺的部分類,實現(xiàn)測試的目的

模式1中的例子查詢資源屬性時沒有設(shè)置過濾條件,事實上大多數(shù)處理都是依賴其他處理類:

public class ClassA {

    public void processA () {
            ClassBProcessor processor new ClassBProcessor();
            processor.processB();
        } catch (Exception e) {
            logger.warn(e); 
        }
    }
}

在本例中,processB方法的Filter是在processA方法內(nèi)部構(gòu)造出來的,我們可以嘗試給processA方法編寫測試用例:
測試用例沒有通過,問題出在哪里呢?
Debug代碼發(fā)現(xiàn),在processA方法內(nèi)部構(gòu)造出來的Filter和我們在測試代碼中構(gòu)造的Filter并不是同一個對象。很自然地想到為Filter類編寫子類,并覆蓋其equals方法。
用自定義的ClassBProcessor代替平臺的ClassBProcessor:

public String getClassBProcessor(){
    ClassBProcessor filter = new SelfClassBProcessor();
    return filter);
}

修改后測試用例運行通過。

7.3 巧用protedted方法實現(xiàn)測試的注入

在模式2中,由于ClassBProcessor是在processA內(nèi)部構(gòu)造的,并且沒有euqals方法,導(dǎo)致無法測試。還可以用別的方法對其進行改造。代碼示例如下:
1.提取protected方法buildProcessor()

public class ClassA {

    public void processA () {
            ClassBProcessor processor new ClassBProcessor();
            processor.processB();
        } catch (Exception e) {
            logger.warn(e); 
        }
    }
}

@ForTest
protected ClassBProcessor buildProcessor() {
     return new ClassBProcessor();
}

2.在測試代碼中重寫buildProcessor方法

@Before
public void setUp() throws Exception {

    private ClassBProcessor classBProcessor;
    ClassA classA = new ClassA(){
        @Override
       protected ClassBProcessor buildProcessor() {
        return classBProcessor;
        }
    };
}

運行測試,可以通過。

8、總結(jié)

UT是開發(fā)人員的利器,是開發(fā)的前置保護傘,也是寫出健壯代碼的有力保證,總之一句話不會寫UT的開發(fā)不是好廚子。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容