[譯]Android 開發中避免糟糕問題的3類單元測試

原文:3 unit tests to avoid bad surprises on Android
作者:Jérémie Martinez
譯者:lovexiaov

在持續分發的過程中,單元測試十分必要。它們應該簡短,快速和可靠。有時它們是查找錯誤和避免將 bug 帶到產品中的唯一方法。本文將會介紹3類單元測試,通過專注 Android 應用的關鍵方面:權限,SharedPreferences 和 SQLite 數據庫來避免開發中的糟糕問題。在發布之前找到它們,避免糟糕問題!

首先,你需要知道這些單元測試基于 RobolectricTruth (參考我之前文章):

testCompile "org.robolectric:robolectric:3.0"
testCompile "com.google.truth:truth:0.27"

控制你的權限

管理好權限往往是一個應用成功的關鍵。我們聽說過很多由于濫用權限導致應用罵聲一片的例子。在 Android 設備上,用戶十分在意新應用安裝時申請的權限。實際上,如果他們認為你申請了不必要的權限,你的評分(在 PlayStore/應用商店上可以查看)將極速降低。

有時,如果不注意,你新添加的庫可能會申請你不需要/想要的權限(比如 Play Service),而且你只有在向 Play Store 提交應用時才會發現此問題。如下這個單元測試可以避免此類不快的事情發生:

@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public final class PermissionsTest {

    private static final String[] EXPECTED_PERMISSIONS = {
            […]
    };

    private static final String MERGED_MANIFEST =
        "build/intermediates/manifests/full/debug/AndroidManifest.xml"

    @Test
    public void shouldMatchPermissions() {
        AndroidManifest manifest = new AndroidManifest(
                Fs.fileFromPath(MERGED_MANIFEST),
                null,
                null
        );

        assertThat(new HashSet<>(manifest.getUsedPermissions())).
                containsOnly(EXPECTED_PERMISSIONS);
    }
}

該測試基于 Robolectric 來解析 Android 配置清單文件實現。當 Gradle 構建 APK 時,其中的一個步驟是組合所有你使用的庫的清單文件,并將他們合并到一起。然后將合并后的清單文件打包到二進制文件中。該測試將會檢索合并后的清單文件,提取權限并驗證它們是否匹配期望的權限。使用構建的中間狀態并不是理想,但這是我目前發現的唯一解決方案。

另一個缺陷是當你確實想要添加一個新權限時,你需要同時更新該單元測試。我承認這不是理想的解決方案,但有時你必須為了安全作出權衡。當你想做持續分發(參考我此前的文章)并且要保證權限未被變更時更要這樣做。

驗證你的 SharedPreferences

許多應用都使用 SharedPreferences 存儲數據。它們是應用的核心部分,必須被重度測試。為了闡述此例子,我設計了一個簡單的 SharedPreferences 包裝類,我認為你們在自己的應用中也會有類似的操作。

public class Preferences {

    private static final String NOTIFICATION = "NOTIFICATION";
    private static final String USERNAME = "USERNAME";

    private final Context context;

    public Preferences(Context context) {
        this.context = context;
    }

    public String getUsername() {
        return getPreferences().getString(USERNAME, null);
    }

    public void setUsername(String username) {
        getPreferences().edit().
                       putString(USERNAME, username).
                       apply();
    }

    public boolean hasNotificationEnabled() {
        return getPreferences().getBoolean(NOTIFICATION, false);
    }

    public void setNotificationEnabled(boolean enable) {
        getPreferences().edit().
                        putBoolean(NOTIFICATION, enable).
                        apply();
    }

    private SharedPreferences getPreferences() {
        return context.getSharedPreferences("user_prefs", MODE_PRIVATE);
    }
}

幸好有 Robolectric,測試它們將變得十分簡單:

@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public final class PreferencesTest {

    private Preferences preferences;

    @Before
    public void setUp() {
        preferences = new Preferences(RuntimeEnvironment.application);
    }

    @Test
    public void should_set_username() {
        preferences.setUsername("jmartinez");
        assertThat(preferences.getUsername()).isEqualTo("jmartinez");
    }

    @Test
    public void should_set_notification() {
        preferences.setNotificationEnabled(true);
        assertThat(preferences.hasNotificationEnabled()).isTrue();
    }

    @Test
    public void should_match_defaults() {
        assertThat(preferences.getUsername()).isNull();
        assertThat(preferences.hasNotificationEnabled()).isFalse();
    }
}

這顯然只是一個簡單的例子。有時你會有更復雜的需求,比如將一個對象序列化為 JSON 格式,并存儲到 SharedPreferences 中,或你的包裝類中會封裝更多的邏輯特性(每個用戶對應一個 SharedPreferences,存儲多個對象,等)。無論如何,測試你的 SharedPreferences 都不應該被低估或忽視。

征服數據庫升級

維護 SQLite 數據庫十分困難。然而,數據庫會隨著應用更新而變化,保證數據庫正常遷移是強制性任務。如果你不能做到,將會導致應用崩潰和用戶流失...這是不可接受的!

如下單元測試基于之前同事 Thibaut 的工作成果。思路是比較新創建的數據庫和更新后數據庫架構。如果是創建新數據庫,只會調用 SQLiteOpenHelper 中的 onCreate 方法;如果是更新數據庫,則會先得到數據庫的首個版本(假設顯示版本號是1)并調用 onUpgrade 方法。通過比較,我們可以確認升級腳本正常工作并給出一個相同的全新數據庫。

上代碼。首先我們需要添加一個 SQLite JDBC 驅動的依賴:

testCompile 'org.xerial:sqlite-jdbc:3.8.10.1'
testCompile 'commons-io:commons-io:1.3.2'

如你所見,我還添加了 commons-io 來簡化文件操作。接著,是單元測試:

@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public final class MigrationTest {

    private File newFile;
    private File upgradedFile;

    @Before
    public void setup() throws IOException {
        File baseDir = new File("build/tmp/migration");
        newFile = new File(baseDir, "new.db");
        upgradedFile = new File(baseDir, "upgraded.db");
        File firstDbFile = new File("src/test/resources/origin.db");
        FileUtils.copyFile(firstDbFile, upgradedFile);
    }

    @Test
    public void upgrade_should_be_the_same_as_create() throws Exception {
        Context context = RuntimeEnvironment.application;
        DatabaseOpenHelper helper = new DatabaseOpenHelper(context);

        SQLiteDatabase newDb = SQLiteDatabase.openOrCreateDatabase(newFile, null);
        SQLiteDatabase upgradedDb = SQLiteDatabase.openDatabase(
            upgradedFile.getAbsolutePath(),
            null,
            SQLiteDatabase.OPEN_READWRITE
        );

        helper.onCreate(newDb);
        helper.onUpgrade(upgradedDb, 1, DatabaseOpenHelper.DATABASE_VERSION);

        Set<String> newSchema = extractSchema(newDbFile.getAbsolutePath());
        Set<String> upgradedSchema = extractSchema(upgradedDbFile.getAbsolutePath());

        assertThat(upgradedSchema).isEqualTo(newSchema);
    }

    private Set<String> extractSchema(String url) throws Exception {
        Connection conn = null;

        final Set<String> schema = new TreeSet<>();
        ResultSet tables = null;
        ResultSet columns = null

        try {
            conn = DriverManager.getConnection("jdbc:sqlite:" + url);

            tables = conn.getMetaData().getTables(null, null, null, null);
            while (tables.next()) {

            String tableName = tables.getString("TABLE_NAME");
            String tableType = tables.getString("TABLE_TYPE");
            schema.add(tableType + " " + tableName);

            columns = conn.getMetaData().getColumns(null, null, tableName, null);
                while (columns.next()) {

                  String columnName = columns.getString("COLUMN_NAME");
                  String columnType = columns.getString("TYPE_NAME");
                  String columnNullable = columns.getString("IS_NULLABLE");
                  String columnDefault = columns.getString("COLUMN_DEF");
                  schema.add("TABLE " + tableName +
                        " COLUMN " + columnName + " " + columnType +
                        " NULLABLE=" + columnNullable +
                        " DEFAULT=" + columnDefault);
                }
            }

            return schema;
        } finally {
            closeQuietly(tables);
            closeQuietly(columns);
            closeQuietly(conn);
        }
    }
}

使用的方法簡潔明了。對于每個數據庫:

  1. 遍歷每一個表
  2. 每一個表都用一個字符串代表
  3. 遍歷表中的每一列
  4. 每一列都用一個字符串代表

這些字符串代表了數據庫的架構。最后,我們比較兩個架構是否相同。

這只是一個例子,但該架構可以被擴展因為 API 中提供了更多可用的條目。你可以在 Metadata 文檔中查看那些是可用的。舉個栗子,你還可以比較引用和索引。再次強調,適合你應用的才是最好的。

數據庫遷移非常重要,并且經常是出現 bug 的地方。此單元測試可以幫你的遷移腳本正常工作,然后你就可以安全升級啦。

結論

這些單元測試只是示例,我希望你能通過本文得到更多東西。對持續分發,數據庫安全遷移,權限控制和 SharedPreferences 有效驗證有很大的幫助。

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

推薦閱讀更多精彩內容