單元測試框架:JUnit

簡介

測試 在軟件開發中是一個很重要的方面,良好的測試可以在很大程度決定一個應用的命運。
軟件測試中,主要有3大種類:

  • 單元測試
    單元測試主要是用于測試程序模塊,確保代碼運行正確。單元測試是由開發者編寫并進行運行測試。一般使用的測試框架是 JUnit 或者 TestNG。測試用例一般是針對方法 級別的測試。
  • 集成測試
    集成測試用于檢測系統是否能正常工作。集成測試也是由開發者共同進行測試,與單元測試專注測試個人代碼組件不同的是,集成測試是系統進行跨組件測試。
  • 功能性測試
    功能性測試是一種質量保證過程以及基于測試軟件組件的規范下的由輸入得到輸出的一種黑盒測試。功能性測試通常由不同的測試團隊進行測試,測試用例的編寫要遵循組件規范,然后根據測試輸入得到的實際輸出與期望值進行對比,判斷功能是否正確運行。

概述

本文只對 單元測試 進行介紹,主要介紹如何在 Android Studio 下進行單元測試,單元測試使用的測試框架為 JUnit

好處

可能目前仍有很大一部分開發者未使用 單元測試 對他們的代碼進行測試,一方面可能是覺得沒有必要,因為即使沒有進行單元測試,程序照樣運行得很好;另一方面,也許有些人也認同單元測試的好處,但是由于需要額外的學習成本,所以很多人也是沒有時間或者說是沒有耐心進行學習······
這里我想說的是,如果大家去看下 github 上目前主流的開源框架,star 數比較多的項目,一般都有很詳盡的測試用例。所以說,單元測試對于我們的項目開發,還是挺有好處的。
至于單元測試的好處,我這里提及幾點:

  • 保證代碼運行與我們預想的一樣,代碼正確性可以得到保證
  • 程序運行出錯時,有利于我們對錯誤進行查找(因為我們忽略我們測試通過的代碼)
  • 有利于提升代碼架構設計(用于測試的用例應力求簡單低耦合,因此編寫代碼的時候,開發者往往會為了對代碼進行測試,將其他耦合的部分進行解耦處理)
    ······

JUnit 簡介

JUnit is a simple framework to write repeatable tests. It is an instance of the xUnit architecture for unit testing frameworks.

JUnit 是一個支持可編寫重復測試用例的簡單框架。它是 xUnit 單元測試框架架構的一個子集。

名稱 解釋
Assertions 單元測試實用方法
Test Runners 測試實例應當怎樣被執行(測試運行器)
Aggregating tests in Suites 合并多個相關測試用例到一個測試套件中(當運行測試套件時,相關用例就會一起被執行)
Test Execution Order 指定測試用例運行順序
Exception Testing 如何指定測試用例期望的異常
Matchers and assertThat 如何使用 Hamcrest 的匹配器 (matchers) 和更加具備描述性的斷言 (assertions)
Ignoring Tests 失能類或方法的測試用例
Timeout for Tests 指定測試用例的最大運行時間(超過這個時間,自動結束測試用例)
Parameterized Tests 測試用例運行多次,每次都使用不同的參數值
Assumptions with Assume 類似斷言,但不會使測試用例失敗
Rules 為測試用例增加Rules(相當于添加功能)
Theories 使用隨機生成的數據使測試用例更加科學嚴謹
Test Fixtures 為測試方法或者類指定預備的set upclean up方法
Categories 將測試用例組織起來,方便過濾
··· ···

Assertions - 斷言
JUnit 為所有的原始類型和對象,數組(原始類型數組或者對象數組)提供了多個重載的斷言方法(assertion method)。斷言方法的參數第一個為預期值,第二個為實際運行的值。另一個可選方法的第一個參數是作為失敗輸出的字符串信息。還有一個稍微有些區別的斷言方法:assertThatassertThat的參數有一個可選的失敗信息輸出,實際運行的值和一個 Matcher 對象。請知悉assertThat的預期值和實際運行值與其他的斷言方法位置是相反的。
ps:實際開發中,建議采用 Hamcrest 提供的斷言方法:assertThat,因為這個方法一方面寫出的代碼更具可讀性,一方面當斷言失敗時,這個方法會給出具體的錯誤提示信息。

更多的 Assertions 信息,請查看文檔:Assert

Test Runners - 測試運行器
當一個類被注解@RunWith或者集成一個被@RunWith注解的類時,JUnit 會把測試用例運行在該類上,而不是內置的運行器上。

ps: JUnit 的默認運行器是 BlockJUnit4ClassRunner
如果類注解為@RunWith(JUnit4.class),則使用的是默認的測試運行器 BlockJUnit4ClassRunner

更多詳細信息,請查看文檔:@RunWith

Aggregating tests in Suites - 測試套件
使用套件(Suite)作為運行器使得你可以手動建造一個可以容納許多類的測試用例。使用測試套件時,你需要創建一個類,然后為其注解上@RunWith(Suite.class)@SuiteClasses(TestClass1.class, ...),這樣,當你運行這個類時,測試套件各個類的測試用例就會全部被執行。

import org.junit.runner.RunWith;
import org.junit.runners.Suite;

@RunWith(Suite.class)
@Suite.SuiteClasses({
  TestFeatureLogin.class,
  TestFeatureLogout.class,
  TestFeatureNavigate.class,
  TestFeatureUpdate.class
})

public class FeatureTestSuite {
  // the class remains empty,
  // used only as a holder for the above annotations
}

Test Execution Order
JUnit 4.11版本開始,JUnit 默認使用確定的,不可預見性的測試用例執行順序(MethodSorters.DEFAULT)。要改變測試用例執行順序,只需簡單為測試類添加@FixMethodOrder注解,并指定一個方法排序規則:
@FixMethodOrder(MethodSorters.JVM):由JVM決定方法執行順序,在不同的JVM上,執行順序可能不同。
@FixMethodOrder(MethodSorters.NAME_ASCENDING):按方法名進行排序(字典序)進行執行。

import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class TestMethodOrder {

    @Test
    public void testA() {
        System.out.println("first");
    }
    @Test
    public void testB() {
        System.out.println("second");
    }
    @Test
    public void testC() {
        System.out.println("third");
    }
}

Exception Testing
你如何驗證代碼拋出的異常是你所期望的?驗證代碼正常走完是很重要,但是確保代碼在異常情況下表現也與預期一樣也是很重要的,比如:

new ArrayList<Object>().get(0);

這句代碼應該拋出一個 IndexOutOfBoundsException異常。@Test注解有一個可選的參數 expected,它可以攜帶一個Throwable的子類。如果我們希望驗證ArrayList能正確拋出一個異常,我們應該這樣寫:

@Test(expected = IndexOutOfBoundsException.class) 
public void empty() { 
     new ArrayList<Object>().get(0); 
}

參數expected的使用應該慎重。只要測試代碼中的任何一句拋出一個IndexOutOfBoundsException異常,那么上面的測試用例就會通過。對于代碼比較長的測試用例,推薦使用 ExpectedException 規則。

更多詳情,請查看:Exception testing

Matchers and assertThat

  • assertThat的一個通用格式為:
assertThat([value], [matcher statement])

示例:

assertThat(x, is(3));
assertThat(x, is(not(4)));
assertThat(responseString, either(containsString("color")).or(containsString("colour")));
assertThat(myList, hasItem("3"));

assertThat的第二個參數是一個Matcher.
詳細的Matcher介紹,可以查看以下兩個文檔:

Ignoring Tests
由于某些原因,你不希望測試用例運行失敗,你只想忽略它,那你只需暫時失能這個測試用例即可。
JUnit 中,你可以通過注釋方法或者刪除@Test注解來忽略測試用例;但是這樣的話測試運行器就不會對該測試用例進行相關報告。另一個方案是為測試用例在@Test注解前面或后面添加上@Ignore注解;那么測試運行器運行后,就會輸出相關測試用例忽略數目,運行所有測試用例的數目和測試用例失敗的數目顯示。
注意下@Ignore注解可以攜帶一個可選參數(String類型),如果你想記錄測試用例忽略的原因,可以使用這個參數:

@Ignore("Test is ignored as a demonstration")
@Test
public void testSame() {
    assertThat(1, is(1));
}

Timeout for Tests
對于失控或者運行時間太長的測試用例,則自動被認為失敗,有兩種方法可以實現這個動作。

  • @Test增加timeout參數
    你可以為一個測試用例指定一個超時時間(毫秒),在規定時間內,如果測試用例沒有運行結束,那么測試用例運行所在線程就會拋出一個異常,從而引起測試失敗。
@Test(timeout=1000)
public void testWithTimeout() {
  ...
}

這種實現方式是通過將測試用例方法運行在另一個單獨的線程中。如果測試用例運行時間超過規定的時間,那么測試用例就會失敗,JUnit 就會打斷執行測試用例的線程。如果測試用例內部執行有可以中斷的操作,那么運行測試用例的線程就會退出(如果測試用例內部是一個無限循環,那么運行測試用例的線程將會永遠運行,而其他測試用例仍在其他的線程上執行)。

  • Timeout Rule (應用到測試類的所有測試用例)
    Timeout Rule會將同一個超時時間應用到測試類的所有測試方法中,并且如果測試用例@Test帶有timeout參數,則會疊加到一起(實際測試中,并沒有疊加的效果,甚至tiemout參數并不生效,依舊還是以Timeout Rule為準)
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.Timeout;

public class HasGlobalTimeout {
    public static String log;
    private final CountDownLatch latch = new CountDownLatch(1);

    @Rule
    public Timeout globalTimeout = Timeout.seconds(10); // 10 seconds max per method tested

    @Test
    public void testSleepForTooLong() throws Exception {
        log += "ran1";
        TimeUnit.SECONDS.sleep(100); // sleep for 100 seconds
    }

    @Test
    public void testBlockForever() throws Exception {
        log += "ran2";
        latch.await(); // will block 
    }
}

Timeout rule指定的超時時間timeout會應用到所有的測試用例中,包括任何的@Before@After方法。如果測試方法是一個無限循環(或者是無法響應中斷操作),那么@Afte注解的方法永遠不會被執行。

Parameterized Tests - 參數化測試
對于單元測試來說,如果想要同一個測試用例中測試多組不同的數據,那么只能手動執行一次后,更改數據,再進行執行,而使用參數化測試的話,則可以將上述的行為進行自動化,我們所需要做的就是提供一個數據集合,然后創建相應的成員變量用來接收數據集合傳遞過來的數據(在測試類構造器中接收),最后運行測試用例時,參數化測試運行器就會依次從數據集合中取出一個數據,并傳給測試用例運行:

//功能類

public class Math {
    public static int add(int a, int b) {
        return a + b;
    }
}
//單元測試類
@RunWith(Parameterized.class) //指定參數化測試運行器
public class MathTest {
    private int a;  //聲明成員變量用于接收數據
    private int b;

    public MathTest(int a, int b) { //接受集合數據
        this.a = a;
        this.b = b;
    }

    @Parameterized.Parameters //創建參數集合
    public static Collection<Object[]> data() {
        Collection<Object[]> collection = new ArrayList<>();
        collection.add(new Object[]{1, 2});
        collection.add(new Object[]{10, 20});
        collection.add(new Object[]{30, 40});
        return collection;
    }

    @Test
    public void add() throws Exception {
        assertThat(Math.add(a, b), is(equalTo(30)));
    }

}

Assumptions with Assume - 前置條件
前置條件與斷言類似,只是斷言在不匹配時,測試用例就會失敗,而前置條件在不匹配時只會使測試用例退出。
前置條件的使用場景是:當你的代碼在不同的環境下,可能有不同的結果時,如果你明確后續的測試代碼是基于某一特定的環境下,才進行測試,那么,借助前置條件,就可以實現所需功能。
比如,假設 Windows 平臺的文件路徑分隔符為"\",而 Linux 平臺的為"/”,假設我們的測試用例只想在 Linux 平臺上進行測試,那么:

@Test
public void filenameIncludesUsername() {
        assumeThat(File.separatorChar, is('/'));
        assertThat(new User("optimus").configFileName(), is("configfiles/optimus.cfg"));
    }

如果在 Windows 平臺運行測試用例時,assumeThat(File.separatorChar, is('/'))就會不匹配,那么測試用例就直接退出(類似異常機制)。

Rules - 規則
Rules允許為測試用例增加靈活的條件或者是重新定義每個類的測試用例行為。測試類可以重新或者繼承一下任一提供的Rules,或者自己自定義一個。

Rule Description
TemporaryFolder 創建臨時文件夾/文件(測試方法完成后文件被自動刪除)
ExternalResource 外部資源Rules的一個基類
ErrorCollector 收集錯誤信息
Verifier 具備校驗功能的一個基類
TestWatcher 具備測試結果記錄的一個基類
TestName Rules對象可在測試用例內部獲取測試用例方法名
Timeout 為測試類所有測試用例約束最長運行時間
ExpectedException 該類使得測試用例能在方法內判別測試代碼是否拋出預期異常
ClassRule 類級別Rule,用于靜態變量的注解,在測試類運行時只執行一次
Rule 方法級別的Rule,用于成員變量的注解,在類的每個測試用例執行時都會被執行
RuleChain 為多個Rules指定順序
TestRule 自定義Rules基類

這里簡單介紹下自定義Rules,假設我們要為所有的測試用例輸出前后添加"------------",那么,我們需要先創建一個Rule


public class CustomerRule implements TestRule {
    @Override
    public Statement apply(final Statement base, Description description) {
        return new Statement(){
            @Override
            public void evaluate() throws Throwable {
                System.out.println("--------------------------");
                base.evaluate();
                System.out.println();
                System.out.println("--------------------------");
            }
        };
    }
}

然后把自定義的TestRule運用到測試類里面即可:

    @Rule
    public CustomerRule customerRule = new CustomerRule();

    @Test
    public void testCustom() {
        assertThat(1, is(1));
    }

更多Rules詳細信息,請查看:Rules

Theories - 測試理論
JUnit 中的 Theories 可以理解成一個測試理論,該理論把測試分為兩部分:一個是提供測試數據(單個數據用@DataPoint注解,集合數據使用@DataPoints注解),數據提供者必須為靜態成員/方法;另一個是理論本身,也即測試用例方法。
Theories 的測試用例允許參數傳遞(普通測試用例測試方法不能攜帶參數),參數傳遞規則是首先從數據集合中取出一個作為第一個參數,然后依次取出集合的元素(包含已作為參數1的那個數據)作為第二個參數····
看下下面的測試用例就會比較清楚 Theories 的運作流程:

@RunWith(Theories.class)
public class MathTest {
//    @DataPoint
//    public static int arg0 = 1;
//    @DataPoint
//    public static int arg1 = 10;
//    @DataPoint
//    public static int arg2 = 0;
    @DataPoints
    public static int[] args = new int[]{1, 10, 0};
    
    @Theory
    public void divied(int a, int b) throws Exception {
        Assume.assumeTrue(b != 0);
        System.out.println(String.format("a=%d,b=%d", a, b));
        assertThat(Math.divied(a, b), not(equalTo(2)));
    }
}

運行結果如下:

result

從上面的測試用例可以看出,MathTest提供的數據集合為{1,10,0},所以:
第一次 運行測試用例divied(int a, int b)時,從集合中取出一個參數,即1會傳遞給參數a,然后又從集合中取出一個參數,也是1,傳遞給b,然后執行測試用例;
第二次 運行時,參數a保持不變,然后從新從集合中取出下一個元素給到b,所以b=10,然后執行測試用例;
第三次 運行時,參數a保持不變,然后從新從集合中取出下一個元素給到b,所以b=0,然后執行測試用例時,由于不滿足Assume前置條件,故測試用例不再往下運行,直接退出,所以看到當b=0時,沒有打印結果;
第四次 運行時,由于b在前面第一輪運行時已完整取出了整個集合數據,所以此時就輪到參數a取出集合的下一個數據,即a=10,然后就按照前一輪的執行邏輯繼續執行下去。

從上面的分析中可以看出,TheoriesParameterized Tests 很類似,兩者都實現了多組數據共同作用于同一個測試用例的功能,不過兩者的參數傳遞機制還是有很大的不同的, Parameterized Tests 可以提供多維數組的形式符合參數個數順序,而 Theories 的參數集合中的每個元素都會同時作用于各個參數;個人感覺還是 Parameterized Tests 更符合通常的測試邏輯。

Test Fixtures - 測試設備
Test Fixtures 是被用作測試用例運行的基準的一系列對象的混合狀態,Test Fixtures 為我們提供了4個注解(均用于方法上):

Annotation Description
@BeforeClass 測試類運行時執行
@AfterClass 測試類結束時執行
@Before 每個測試用例執行前先執行
@After 每個測試用例執行后再執行

Categories - 分類
Categories 見名知意,就是將一系列測試類/測試方法進行分類,每個類或者接口都可以作為一個Category,且支持類別繼承。
比如,你指定一個測試用例屬于SuperClass.class的類別(使用@Category(SuperClass.class)注解在測試類用例上),然后@IncludeCategory(SuperClass.class),那么任何測試用例上注解了@Category(SuperClass.class)或者@Category({SubClass.class})的方法都會被執行。
舉個例子:

  1. 首先我們需要定義一個或多個測試類別(即Category)
public class Category {
    public static interface Category01 {}

    public static interface Category02 {}

    public static interface Category01Impl extends Category01{}
}

這里有3種測試Category,其中,類別Category01Impl繼承了類別Category01,所以任何@IncludeCategory(Category01.class)的測試類,測試時也會執行類別為Category01Impl的測試用例。

  1. 定義好了測試類別后,我們就需要將這些類別運用到測試類或者測試用例上
public class Tests {
    public static class Test01 {
        @Test
        @Category(Category01.class) //運用到測試用例上
        public void test01() {
            System.out.println("This testCase belongs to Category01");
        }
        @Test
        @Category(Category01Impl.class)//運用到測試用例上
        public void test01Impl() {
            System.out.println("This testCase belongs to Category01Impl");
        }
    }

    @Category(Category02.class)//運用到測試類上,類中所有測試方法都屬于`Category02.class`這個類別
    public static class Test02 {
        @Test
        public void test02() {
            System.out.println("This testCase belongs to Category02");
        }
    }
}
  1. 最后,再Categories類別測試運行器上運行需要的測試用例即可
@RunWith(Categories.class)
@IncludeCategory(Category01.class)
@SuiteClasses({Tests.Test01.class, Tests.Test02.class}) // Note that Category is a kind of Suite
public class CategoryTest {
}

更多詳細信息,請查看:Categories

Android Studio 進行單元測試

假設我們需要對一個 Java Module 進行單元測試,采用 JUnit 框架,則部署步驟如下:

  • build.gralde 中依賴 JUnit:
dependencies {
     testImplementation 'junit:junit:4.12' //or testCompile
}
  • 創建一個類
public class Math {
    public static int add(int a, int b) {
        return a + b;
    }
}
  • 對上面的類Mathadd方法進行測試
    我們可以手動創建一個Math的測試類,但是借助于 Android Studio,我們可以很方面的使用快捷操作自動生成測試類和測試用例,具體做法為:打開要進行測試的類文件,雙擊類名/方法名進行選中,然后按快捷鍵:<Ctrl-Shift-T>
創建測試用例
  • 最后,寫上測試代碼,進行測試就可以了。

更多詳細信息,請查看官網:Building Local Unit Tests

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

推薦閱讀更多精彩內容