原文:3 unit tests to avoid bad surprises on Android
作者:Jérémie Martinez
譯者:lovexiaov
在持續分發的過程中,單元測試十分必要。它們應該簡短,快速和可靠。有時它們是查找錯誤和避免將 bug 帶到產品中的唯一方法。本文將會介紹3類單元測試,通過專注 Android 應用的關鍵方面:權限,SharedPreferences 和 SQLite 數據庫來避免開發中的糟糕問題。在發布之前找到它們,避免糟糕問題!
首先,你需要知道這些單元測試基于 Robolectric 和 Truth (參考我之前文章):
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);
}
}
}
使用的方法簡潔明了。對于每個數據庫:
- 遍歷每一個表
- 每一個表都用一個字符串代表
- 遍歷表中的每一列
- 每一列都用一個字符串代表
這些字符串代表了數據庫的架構。最后,我們比較兩個架構是否相同。
這只是一個例子,但該架構可以被擴展因為 API 中提供了更多可用的條目。你可以在 Metadata 文檔中查看那些是可用的。舉個栗子,你還可以比較引用和索引。再次強調,適合你應用的才是最好的。
數據庫遷移非常重要,并且經常是出現 bug 的地方。此單元測試可以幫你的遷移腳本正常工作,然后你就可以安全升級啦。
結論
這些單元測試只是示例,我希望你能通過本文得到更多東西。對持續分發,數據庫安全遷移,權限控制和 SharedPreferences 有效驗證有很大的幫助。