天天看點

[Android]使用自定義JUnit Rules、annotations和Resources進行單元測試(翻譯)

以下内容為原創,歡迎轉載,轉載請注明

來自天天部落格:http://www.cnblogs.com/tiantianbyconan/p/5795091.html

使用自定義JUnit Rules、annotations和Resources進行單元測試

原文:http://www.thedroidsonroids.com/blog/android/unit-tests-rules-annotations-resources

簡介

Unit Test并不隻有斷言和測試方法組成。它有一些可以用來提高品質和測試代碼可讀性的技術。在本文中我們将探索:

  • annotations
  • JUnit rules
  • java resources

背景

很多或者大多數Android apps作為一個API Client,是以需要資料格式之間的轉換(通常是JSON)和POJO(資料模型類)。我們不需要在自己的代碼中實作一個轉換引擎而是可以使用如 GSON 或 moshi 等三方庫來完成。

衆所周知的庫通常都是有很高的單元測試的覆寫率的,是以如下測試它們是沒有意義的:

@Test
public void testGson() {
 //given
 Gson gson = new Gson();
 //when
 String result = gson.fromJson("\"test\"", String.class);
 //then
 assertThat(result).isEqualTo("test");
}
           

Listing 1. 無用的GSON單元測試.

另一方面測試解析(JSON到POJO)和生成(POJO到JSON)邏輯相關的模型類可能是有用的。如下的POJO:

public class Contributor {
 public String login;
 public boolean siteAdmin;
 public long id;
}
           

Listing 2. 簡單POJO.

和相應的JSON:

{
    "login": "koral--",
    "id": 3340954,
    "site_admin": true
}
           

Listing 3. 簡單JSON.

如果屬性映射都正确的話,我們希望去測試它。注意屬性

siteAdmin

使用了不同的命名風格 - Java中的駝峰命名和JSON中的蛇底命名。

簡單方案

最簡單的一種unit test看起來如下:

@Test
public void testParseHardcodedContributors() throws Exception {
 //given
 String json = "[\n" +
 "  {\n" +
 "    \"login\": \"koral--\",\n" +
 "    \"id\": 3340954,\n" +
 "    \"site_admin\": true\n" +
 "  },\n" +
 "  {\n" +
 "    \"login\": \"Wavesonics\",\n" +
 "    \"id\": 406473,\n" +
 "    \"site_admin\": false\n" +
 "  }\n" +
 "]\n";
 
 GsonBuilder gsonBuilder = new GsonBuilder();
 gsonBuilder.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES);
 Gson gson = gsonBuilder.create();
 
 //when
 Contributor[] contributors;
 try (Reader reader = new BufferedReader(new StringReader(json))) {
 contributors = gson.fromJson(reader, Contributor[].class);
 }
           

Listing 4. 使用寫死JSON的單元測試.

這種方法有幾個弊端。最值得注意的就是比較差的JSON可讀性,有大量的轉義字元和沒有文法高亮。此外有一點模版代碼,如果有更多的JSON需要測試的話将會産生重複代碼。讓我們思考怎樣可以用更加簡便的方法來編寫,提高可讀性和消除代碼重複率。

改進

首先

Gson

對象可以在測試方法外部執行個體化,比如使用一些像 [Dagger] (http://google.github.io/dagger) 的DI(依賴注入)機制或者使用一個簡單的常量。DI已經超出了本文的範圍是以我們在例子代碼中使用後者。在代碼提取後看起來如下:

public final class Constants {
 public static final Gson GSON;
 
 static {
 final GsonBuilder gsonBuilder = new GsonBuilder();
 gsonBuilder.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES);
 GSON = gsonBuilder.create();
 }
}
           

Listing 5. 把GSON執行個體作為一個全局變量.

接着,文本形式的JSON可以被置于一個resource file中。這會給我們帶來文法的高亮和縮進(漂亮的列印),預設情況下Android Studio和Intellij IDEA内置了這些功能特性。不需要引号的轉義,是以可讀性也不再是問題。再者,檔案中的行數和列數和GSON中的一緻,是以将會更加容易地像這樣debug異常:

MalformedJsonException: Unterminated array at line 4 column 5 path $[2]

。如果JSON被放置在一個單獨的檔案,行數是會被确切地比對到的,跟上述寫死JSON的例子沖突的地方是,需要通過java源檔案中的偏移進行調整。下面是這個示例中被使用的檔案:

[
  {
    "login": "koral--",
    "id": 3340954,
    "site_admin": true
  },
  {
    "login": "Wavesonics",
    "id": 406473,
    "site_admin": false
  }
]
           

Listing 6. 包含JSON的Java resource檔案.

最後,代碼執行轉換可以從測試方法中提取,是以廣義說它會更容易在不同的測試用例上使用。它可以使用下章将會讨論的Java和JUnit特性來實作。

Goodies

Java resources

Java resources是程式需要的資料檔案,它被放置在源代碼外面。注意我們讨論的是 Java resources,預設被放置在

src/<source set>/resources

,并不是 Android App Resources(drawables, layouts等)。這本例子中并沒有Android特别的特性。是以一切都是可以像 Robolectric 那樣脫離frameworks可單元測試的。

如果listing 6的JSON檔案被儲存于

src/test/resources/pl/droidsonroids/modeltesting/api/contributors.json

,它可以通過調用

TestClass.getResourceAsStream("contributors.json")

來被單元測試代碼通路。相關的類需要被放置在對應的package中,在這個例子中是

pl.droidsonroids.modeltesting.api

。詳情見

#getResourceAsStream()

javadoc

注解Annotation

Annotation是關聯到源代碼元素的中繼資料(eg. 方法或者類)。有衆所周知的一些如@Override或@Deprecated的内置注解。也可以自定義并使用它們把特定的resources綁定到測試方法中。

注解來起來與interface很類似:

Java

@Retention(RUNTIME)
@Target(METHOD)
public @interface JsonFileResource {
	String fileName();
	Class<?> clazz();
}
           

Listing 7. 簡單注解.

注意

interface

關鍵字前面的

@

符号。我們自定義的注解被2個元注解來注解。我們設定Retention為RUNTIME,因為注解需要在單元測試執行時(運作時)為可讀,是以預設的retention(CLASS)的并不滿足。我們也需要設定Target為METHOD因為我們隻需要為方法進行注解(綁定特定的resource)。錯位的注解會引發編譯錯誤。沒有指定一個target,注解會可以被用于任何地方。

JUnit Rules

簡單來說,rule是在測試(方法)運作時觸發的一個hook。我們将使用rule在測試方法執行之前增加一些額外的行為。即我們将從resources中解析JSON并提供給測試方法内部相應的POJO。我們的目标時像下面這樣支援單元測試:

@Rule public JsonParsingRule jsonParsingRule = new JsonParsingRule(Constants.GSON);
 
@Test
@JsonFileResource(fileName = "contributors.json", clazz = Contributor[].class)
public void testGetContributors() throws Exception {
 Contributor[] contributors = jsonParsingRule.getValue();
 assertThat(contributors).hasSize(2);
 assertThat(contributors[0].login).isEqualTo("koral--");
}
           

Listing 8. 使用自定義rule的簡單測試方法.

如你所見,模版代碼與listing 4相比明顯地減少。隻有必要的部分是類型明确的:

  • GSON執行個體用來解析JSONs -

    jsonParsingRule = new JsonParsingRule(Constants.GSON)

  • 被放置JSON字元串的resource -

    @JsonFileResource(fileName = "contributors.json"

  • POJO類 -

    , clazz = Contributor[].class

  • POJO執行個體的接收 -

    contributors = jsonParsingRule.getValue()

注意對于測試類隻需要一個

JsonParsingRule

執行個體。對于每個測試方法Rule會被獨立計算并且在特定方法中

jsonParsingRule.getValue()

的結果不會影響到上一次測試。clazz并不是一個錯字而是故意的,因為

class

是Java語言關鍵字并不能用做一個辨別符。還有一個重要的是被@Rule注解的屬性必須是public和非static的。

Rule實作

看下rule實作的草案:

public class JsonParsingRule implements TestRule {
 private final Gson mGson;
 private Object mValue;
 
 public JsonParsingRule(Gson gson) {
 mGson = gson;
 }
 
 @SuppressWarnings("unchecked")
 public  T getValue() {
 return (T) mValue;
 }
 
 @Override
 public Statement apply(final Statement base, final Description description) {
 return new Statement() {
 @Override
 public void evaluate() throws Throwable {
 //TODO set mValue according to annotation
 base.evaluate();
 }
 };
 }
}
           

Listing 9. Rule骨架.

我們的rule實作了TestRule,是以可以使用被使用

@Rule

注解。我們使用了一個範型的getter,是以它的傳回值可以被直接配置設定給特定類型的變量而不需要在測試方法中轉型。在

apply()

方法中我們可以建立一個原始Statement(測試方法)的包裝。調用

base.evaluate()

被放置在最後(在注解處理之後),是以在測試方法執行過程中rule的效果是可見的。

現在更接近地觀看statement包裝的關鍵部分(listing 9中

TODO

的實作):

JsonFileResource jsonFileResource = description.getAnnotation(JsonFileResource.class);
if (jsonFileResource != null) {
 Class<?> clazz = jsonFileResource.clazz();
 String resourceName = jsonFileResource.fileName();
 Class<?> testClass = description.getTestClass();
 InputStream in = testClass.getResourceAsStream(resourceName);
 
 assert in != null : "Failed to load resource: " + resourceName + " from " + testClass;
 try (Reader reader = new BufferedReader(new InputStreamReader(in))) {
 mValue = mGson.fromJson(reader, clazz);
 }
}
           

Listing 10. Statement的實作.

description

參數在這裡是必不可少的,它可以讓我們通路測試方法包括注解在内的中繼資料。Rule适用于所有測試方法,包括沒有注解的,這種情況下getAnnotation()會傳回

null

,并且我們可以有條件地跳過定制的其餘部分。是以測試方法沒有

@JsonFileResource

注解的測試方法(比如,一些不涉及JSON的測試)可以放在使用了

JsonParsingRule

的測試類中。第8行是下面代碼的一個簡寫等效:

if (in != null) {
 throw new AssertionError("Failed to load resource: " + resourceName + " from " + testClass);
}
           

Listing 11. 斷言語句判定.

最後我們傳入使用被Reader包裝的resource到GSON引擎。Try-with-resources語句在這裡被使用,是以Reader将會在讀取甚至發生異常之後自動關閉。這裡需要在

finally

塊中明确類型。

注意try-with-resources從Android API 19(Kitkat)才可用。如果測試代碼位于Android gradle module中,并且你的

minSdkVersion

低于19,那麼你可能需要在

evaluate()

方法上增加

@TargetApi(Build.VERSION_CODES.KITKAT)

注解來避免lint錯誤。單元測試會在開發機器(Mac,PC等)上被執行而不是Android裝置或者模拟器,是以這裡隻有

compileSdkVersion

才是關鍵。

這樣的單元測試(不需要使用Android特定的API)也可以被放在java module中(

build.gradle

apply plugin: 'java'

)。理論上這事最好的idea,但是在Android Studio/Intellij IDEA中有一個問題需要預防,那就是從IDE開箱即用地執行單元測試的配置工作。