[譯]Android 開發(fā)中避免糟糕問題的3類單元測(cè)試

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

在持續(xù)分發(fā)的過程中,單元測(cè)試十分必要。它們應(yīng)該簡(jiǎn)短,快速和可靠。有時(shí)它們是查找錯(cuò)誤和避免將 bug 帶到產(chǎn)品中的唯一方法。本文將會(huì)介紹3類單元測(cè)試,通過專注 Android 應(yīng)用的關(guān)鍵方面:權(quán)限,SharedPreferences 和 SQLite 數(shù)據(jù)庫來避免開發(fā)中的糟糕問題。在發(fā)布之前找到它們,避免糟糕問題!

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

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

控制你的權(quán)限

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

有時(shí),如果不注意,你新添加的庫可能會(huì)申請(qǐng)你不需要/想要的權(quán)限(比如 Play Service),而且你只有在向 Play Store 提交應(yīng)用時(shí)才會(huì)發(fā)現(xiàn)此問題。如下這個(gè)單元測(cè)試可以避免此類不快的事情發(fā)生:

@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);
    }
}

該測(cè)試基于 Robolectric 來解析 Android 配置清單文件實(shí)現(xiàn)。當(dāng) Gradle 構(gòu)建 APK 時(shí),其中的一個(gè)步驟是組合所有你使用的庫的清單文件,并將他們合并到一起。然后將合并后的清單文件打包到二進(jìn)制文件中。該測(cè)試將會(huì)檢索合并后的清單文件,提取權(quán)限并驗(yàn)證它們是否匹配期望的權(quán)限。使用構(gòu)建的中間狀態(tài)并不是理想,但這是我目前發(fā)現(xiàn)的唯一解決方案。

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

驗(yàn)證你的 SharedPreferences

許多應(yīng)用都使用 SharedPreferences 存儲(chǔ)數(shù)據(jù)。它們是應(yīng)用的核心部分,必須被重度測(cè)試。為了闡述此例子,我設(shè)計(jì)了一個(gè)簡(jiǎn)單的 SharedPreferences 包裝類,我認(rèn)為你們?cè)谧约旱膽?yīng)用中也會(huì)有類似的操作。

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,測(cè)試它們將變得十分簡(jiǎn)單:

@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();
    }
}

這顯然只是一個(gè)簡(jiǎn)單的例子。有時(shí)你會(huì)有更復(fù)雜的需求,比如將一個(gè)對(duì)象序列化為 JSON 格式,并存儲(chǔ)到 SharedPreferences 中,或你的包裝類中會(huì)封裝更多的邏輯特性(每個(gè)用戶對(duì)應(yīng)一個(gè) SharedPreferences,存儲(chǔ)多個(gè)對(duì)象,等)。無論如何,測(cè)試你的 SharedPreferences 都不應(yīng)該被低估或忽視。

征服數(shù)據(jù)庫升級(jí)

維護(hù) SQLite 數(shù)據(jù)庫十分困難。然而,數(shù)據(jù)庫會(huì)隨著應(yīng)用更新而變化,保證數(shù)據(jù)庫正常遷移是強(qiáng)制性任務(wù)。如果你不能做到,將會(huì)導(dǎo)致應(yīng)用崩潰和用戶流失...這是不可接受的!

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

上代碼。首先我們需要添加一個(gè) SQLite JDBC 驅(qū)動(dòng)的依賴:

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

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

@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);
        }
    }
}

使用的方法簡(jiǎn)潔明了。對(duì)于每個(gè)數(shù)據(jù)庫:

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

這些字符串代表了數(shù)據(jù)庫的架構(gòu)。最后,我們比較兩個(gè)架構(gòu)是否相同。

這只是一個(gè)例子,但該架構(gòu)可以被擴(kuò)展因?yàn)?API 中提供了更多可用的條目。你可以在 Metadata 文檔中查看那些是可用的。舉個(gè)栗子,你還可以比較引用和索引。再次強(qiáng)調(diào),適合你應(yīng)用的才是最好的。

數(shù)據(jù)庫遷移非常重要,并且經(jīng)常是出現(xiàn) bug 的地方。此單元測(cè)試可以幫你的遷移腳本正常工作,然后你就可以安全升級(jí)啦。

結(jié)論

這些單元測(cè)試只是示例,我希望你能通過本文得到更多東西。對(duì)持續(xù)分發(fā),數(shù)據(jù)庫安全遷移,權(quán)限控制和 SharedPreferences 有效驗(yàn)證有很大的幫助。

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

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

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