原文: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è)試基于 Robolectric 和 Truth (參考我之前文章):
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ù)庫:
- 遍歷每一個(gè)表
- 每一個(gè)表都用一個(gè)字符串代表
- 遍歷表中的每一列
- 每一列都用一個(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)證有很大的幫助。