当用户设置新设备或重新安装您的应用时,Android 可以从云端备份或设备间传输中恢复应用的数据。如果您的备份规则遗漏了重要数据,或者您的应用无法使用其恢复的数据,用户会丢失其设置和进度,而不会看到错误。
自动备份和恢复测试可帮助您在发布应用之前发现这些问题。这些测试是 JUnit 5 测试,在开发机器上运行,并通过 Android 调试桥 (adb) 控制已连接的设备。每项测试都会在设备上植入应用数据,通过 Android 备份框架备份和恢复应用,然后检查数据是否完好无损。您可以在模块的 build 文件中将测试配置为 Android Gradle 插件 (AGP) 测试套件。
运作方式
备份和恢复测试套件包含两个协同工作的部分:
- 主机测试是在开发机器上运行的 JUnit 5 测试。它们会为每台设备使用一个
BackupRestoreController来播种数据、备份和恢复应用,以及检查结果。 - 设备操作是实现
BackupDeviceAction的类。它们在设备上您应用的进程中运行,因此可以通过您应用自己的代码读取和写入数据。主机测试按类名称调用设备操作。
当您运行该套件时,AGP 会构建您的应用和包含设备操作的测试 APK。测试框架会在测试使用的每台设备上安装这两个 APK,并在每次测试之前清除应用的数据。
内置存储类型
下表列出了 StorageDomain 子类,这些子类可让您在不编写设备操作的情况下播种和验证数据:
| 存储网域 | 数据 |
|---|---|
StorageDomain.Preference |
SharedPreferences 文件中的密钥。 |
StorageDomain.Database |
SQLite 数据库表中的一行。 |
StorageDomain.TextFile |
应用内部存储空间中的文本文件。 |
StorageDomain.BinaryFile |
应用内部存储空间中的二进制文件。 |
如需测试其他数据(例如 DataStore 文件),请编写设备操作。
交通方式
每个备份都使用 BackupTransportMode 值。下表列出了支持的传输模式:
| 模式 | 场景 |
|---|---|
LOCAL |
备份到设备的本地备份传输。备份数据会保留在设备上。 |
CLOUD_ENCRYPTED |
云端备份,例如从启用了客户端加密功能的设备保存到用户 Google 账号中的备份。 |
CLOUD_UNENCRYPTED |
来自未启用客户端加密功能的设备的云端备份。 |
DEVICE_TO_DEVICE |
设备到设备的数据转移,例如通过数据线或 Wi-Fi。 |
LOCAL 适用于任何受支持的设备。云端模式和设备到设备模式创建的备份与 Android Studio 中的备份应用数据操作相同:备份会保存到开发机器,而不是上传或发送到其他设备。云端和设备到设备模式要求设备上安装有 Google Play 服务,并且您的应用是可调试 build。如果您的备份规则依赖于客户端加密,请测试这两种云端模式。
要求
如需使用自动备份和恢复测试,请确保您的环境满足以下要求:
- Gradle 9.6.0 版或更高版本。
- JDK 17 或更高版本。
- 通过 adb 连接的搭载 Android 12(API 级别 31)或更高版本的设备或模拟器:
- 搭载 API 级别 36.1 或更高级别的设备或模拟器需要 Google Play 服务 26.33.30 或更高版本。
- 如需测试两台设备之间的传输,请连接两台设备。
兼容性
下表列出了自动备份和恢复测试的最低版本和推荐版本:
| 组件 | 最低版本 | 推荐的版本 |
|---|---|---|
| Android Studio | Rabbit 2 Canary 1 | Rabbit 2 Canary 4 或更高版本 |
| Android Gradle 插件 (AGP) | 9.5.0-alpha06 | 9.5.0-alpha09 或更高版本 |
androidx.test.backup 个库 |
1.0.0-alpha01 | 1.0.0-alpha03 或更高版本 |
每个预览版 AGP 都需要匹配的预览版 Android Studio。如需了解详情,请参阅 AGP 预览版。
设置项目
如需向应用模块添加备份和恢复测试套件,请完成以下步骤:
启用实验性标志
在项目的 gradle.properties 文件中,启用测试套件和备份测试:
android.experimental.testSuiteSupport=true
android.experimental.enableBackupTest=true
声明测试套件
在应用模块的 build.gradle.kts 文件中,在 testOptions 代码块中声明备份测试套件:
android {
testOptions {
backupTests {
create("backupTest") {
backupTestLibraryVersion = "1.0.0-alpha03"
}
}
}
}
套件名称(在本例中为 backupTest)决定了源代码集目录和 Gradle 任务名称。您可以使用以下可选属性自定义套件:
- 使用
backupTestLibraryVersion设置 AGP 添加到套件中的androidx.test.backup库(例如"1.0.0-alpha03")的版本。此为可选属性。如果您省略此参数,AGP 会根据 AGP 版本回退到默认库版本。本页中的示例需要使用 1.0.0-alpha03 或更高版本。 - 使用
targetVariants指定套件测试的 build 变体,例如targetVariants.add("demoDebug")。此为可选属性。如果您省略此参数,该套件会测试模块的testBuildType的变体,默认情况下为debug。我们建议测试可调试变体,因为云端和设备到设备传输模式需要可调试应用。
AGP 会将主机库 androidx.test.backup:backup-host 和 JUnit 5 添加到主机测试中。它会将设备库 androidx.test.backup:backup 添加到测试 APK,并设置测试 APK 的插桩运行程序。
如需添加其他依赖项,请使用套件的 hostJar 和 testApk 代码块。
例如,如需在主机测试中使用 Truth,请将其添加到 hostJar 代码块中:
create("backupTest") {
hostJar {
dependencies {
implementation("com.google.truth:truth:1.4.2")
}
}
}
创建源代码集
创建与套件名称匹配的源代码集目录:
- 将主机测试放在
src/{suiteName}/test/kotlin/中。 - 将设备操作放在
src/{suiteName}/androidTest/kotlin/中。
例如,名为 backupTest 的套件使用以下目录结构:
app/src/backupTest/
├── androidTest/
│ └── kotlin/com/example/myapp/ThemeActions.kt
└── test/
└── kotlin/com/example/myapp/SettingsBackupTest.kt
检查备份配置
测试会备份和恢复应用的备份配置中包含的数据。确保已为您的应用启用备份,并且您的备份规则包含您要测试的数据。
编写备份和恢复测试
在套件的 test 源代码集中编写主机测试。使用 @ExtendWith(BackupRestoreExtension::class) 为每个测试类添加注解,以便为测试提供已连接的设备。测试会针对套件所针对的应用变体运行,因此您无需设置其应用 ID。
每个测试方法都会为其使用的每台设备声明一个 BackupRestoreController 参数。
测试内置存储类型
如需测试内置存储类型,请调用 runBackupRestoreFlow,并传入要植入的数据、备份文件的目录和传输模式。该方法会在设备上运行以下步骤:
- 为每个存储网域提供种子。
- 停止应用。
- 备份应用。
- 清除应用的数据。
- 恢复备份。
- 验证每个存储网域是否都具有种子值。
如果某个步骤失败,该方法会抛出异常,导致测试失败。以下示例使用本地备份传输测试 SharedPreferences 键、SQLite 行和内部文件:
package com.example.myapp
import androidx.test.backup.host.BackupRestoreController
import androidx.test.backup.host.BackupRestoreExtension
import androidx.test.backup.host.BackupTransportMode
import androidx.test.backup.host.StorageDomain
import java.nio.file.Path
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.io.TempDir
@ExtendWith(BackupRestoreExtension::class)
class SettingsBackupTest {
@Test
fun settingsSurviveBackupAndRestore(
device: BackupRestoreController,
@TempDir outputDir: Path,
): Unit = runBlocking {
device.runBackupRestoreFlow(
storages = listOf(
StorageDomain.Preference(
prefName = "settings",
key = "theme",
value = "dark",
),
StorageDomain.Database(
dbName = "notes.db",
table = "notes",
primaryKeyCol = "id",
primaryKeyVal = 1,
columnValues = mapOf("title" to "Groceries"),
),
StorageDomain.TextFile(
path = "drafts/note.txt",
content = "Buy milk",
),
),
outputDir = outputDir,
mode = BackupTransportMode.LOCAL,
)
}
}
定义存储网域时,请注意以下几点:
Preference采用不带.xml扩展名的SharedPreferences文件的名称。TextFile和BinaryFile会根据应用的filesDir解析相对路径。Database使用 SQL 写入行,如果表不存在,则创建包含TEXT列的表。如果您的应用使用 Room,我们建议您使用采用 DAO 的设备操作来测试数据库内容,以便数据库具有 Room 预期的架构。
写入设备操作
如需植入或验证内置存储类型未涵盖的数据,请在套件的 androidTest 源代码集中编写设备操作。设备操作会实现 BackupDeviceAction 并替换两个成员:
- 对于播种
数据的操作,将
phase设置为BackupDeviceAction.PHASE_POPULATE;对于检查数据的操作,将 设置为BackupDeviceAction.PHASE_VERIFY。 - 实现
execute,以使用应用的Context和主机测试中的参数运行操作。返回使用BackupDeviceActionResult.success或BackupDeviceActionResult.failure工厂方法创建的结果,并将错误消息传递给failure。
execute 方法在应用的进程中在后台线程上运行,因此它可以阻塞,直到读写操作完成。
以下示例会预填充并验证应用使用 Preferences DataStore 存储的主题设置。该应用在其主源代码集中定义了 DataStore:
// In app/src/main/kotlin/com/example/myapp/Settings.kt
package com.example.myapp
import android.content.Context
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
val Context.settingsDataStore by preferencesDataStore(name = "settings")
val THEME_KEY = stringPreferencesKey("theme")
设备操作使用相同的定义:
// In app/src/backupTest/androidTest/kotlin/com/example/myapp/ThemeActions.kt
package com.example.myapp
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.test.backup.BackupDeviceAction
import androidx.test.backup.BackupDeviceActionArgs
import androidx.test.backup.BackupDeviceActionResult
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class SeedThemeAction : BackupDeviceAction {
override val phase = BackupDeviceAction.PHASE_POPULATE
override fun execute(
context: Context,
args: BackupDeviceActionArgs,
): BackupDeviceActionResult {
val theme = args["theme"]
?: return BackupDeviceActionResult.failure("Missing theme argument")
runBlocking {
context.settingsDataStore.edit { it[THEME_KEY] = theme }
}
return BackupDeviceActionResult.success()
}
}
class VerifyThemeAction : BackupDeviceAction {
override val phase = BackupDeviceAction.PHASE_VERIFY
override fun execute(
context: Context,
args: BackupDeviceActionArgs,
): BackupDeviceActionResult {
val expected = args["theme"]
val actual = runBlocking {
context.settingsDataStore.data.first()[THEME_KEY]
}
return if (actual == expected) {
BackupDeviceActionResult.success()
} else {
BackupDeviceActionResult.failure("Expected $expected, found $actual")
}
}
}
如需将数据返回到主机测试,请将映射传递给 success 方法。主机测试会从 BackupActionResult.Success.data 中读取映射。
运行流程的每个步骤
如需使用设备操作或自定义流程,请自行调用每个步骤。以下主机测试会在云备份和恢复前后运行上一部分中的设备操作:
package com.example.myapp
import androidx.test.backup.host.BackupActionResult
import androidx.test.backup.host.BackupRestoreController
import androidx.test.backup.host.BackupRestoreExtension
import androidx.test.backup.host.BackupTransportMode
import java.nio.file.Path
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.fail
import org.junit.jupiter.api.io.TempDir
const val SEED_THEME = "com.example.myapp.SeedThemeAction"
const val VERIFY_THEME = "com.example.myapp.VerifyThemeAction"
@ExtendWith(BackupRestoreExtension::class)
class ThemeBackupTest {
@Test
fun themeSurvivesCloudBackup(
device: BackupRestoreController,
@TempDir outputDir: Path,
): Unit = runBlocking {
val args = mapOf("theme" to "dark")
device.runOnDevice(SEED_THEME, args).assertSuccess()
device.stopApp()
val backupFile = device.performBackup(
BackupTransportMode.CLOUD_ENCRYPTED,
outputDir,
)
device.clearAppData()
device.performRestore(backupFile)
device.runOnDevice(VERIFY_THEME, args).assertSuccess()
}
}
fun BackupActionResult.assertSuccess() {
if (this is BackupActionResult.Failure) fail(errorMessage)
}
当设备操作失败时,runOnDevice 会返回 BackupActionResult.Failure,而不是抛出异常,因此请检查结果。其他步骤如果失败,则会抛出异常。
测试设备之间的转移
如需测试两台设备之间的传输,请为每台设备声明一个 BackupRestoreController 参数。在源设备上备份应用,然后在目标设备上恢复备份文件。以下示例重复使用了上一个示例中的常量和 assertSuccess 辅助函数:
package com.example.myapp
import androidx.test.backup.host.BackupRestoreController
import androidx.test.backup.host.BackupRestoreExtension
import androidx.test.backup.host.BackupTransportMode
import androidx.test.backup.host.Device
import java.nio.file.Path
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.io.TempDir
@ExtendWith(BackupRestoreExtension::class)
class ThemeTransferTest {
@Test
fun themeTransfersToNewDevice(
@Device(role = "source") source: BackupRestoreController,
@Device(role = "target") target: BackupRestoreController,
@TempDir outputDir: Path,
): Unit = runBlocking {
val args = mapOf("theme" to "dark")
source.runOnDevice(SEED_THEME, args).assertSuccess()
source.stopApp()
val backupFile = source.performBackup(
BackupTransportMode.DEVICE_TO_DEVICE,
outputDir,
)
target.clearAppData()
target.performRestore(backupFile)
target.runOnDevice(VERIFY_THEME, args).assertSuccess()
}
}
借助 @Device 的 role 值,您可以在运行测试时为每个参数选择设备。如果仅连接了一台设备,则两个参数都会使用该设备,并且测试会在单台设备上模拟传输。清除目标数据可确保恢复的数据来自备份。
选择设备
默认情况下,测试框架会按序列号对在线设备进行排序,并按顺序将它们分配给每个测试的 BackupRestoreController 参数。如果测试的设备参数多于设备数量,则多出的参数会共用第一个设备。ANDROID_SERIAL 环境变量不会影响设备分配。
如需在不将序列号放入测试中的情况下选择设备,请使用 @Device(role = "...") 为每个设备参数指定一个角色。然后,在运行测试时,使用 androidx.test.backup.device.serial.{role} 系统属性将每个角色绑定到设备。例如,以下命令会运行该套件,并将 emulator-5554 作为源设备,将 emulator-5556 作为目标设备:
./gradlew :app:testDebugBackupTestDefaultTestSuite -Dandroidx.test.backup.device.serial.source=emulator-5554 -Dandroidx.test.backup.device.serial.target=emulator-5556绑定仅适用于具有该角色的形参。其他参数则用于获取其余设备。
您还可以选择 serial 和 api 值为 @Device 的设备,或具有以下系统属性(这些属性优先于 @Device 值)的设备:
| 系统属性 | 说明 |
|---|---|
androidx.test.backup.device.serial.{role} |
使用 @Device(role = "{role}") 注释的参数的设备序列号。其优先级高于 serials 和 serial。 |
androidx.test.backup.device.serials |
以逗号分隔列表形式的序列号,按顺序分配给设备参数。 |
androidx.test.backup.device.serial |
serials 未涵盖的设备参数对应的设备序列号。 |
androidx.test.backup.device.apis |
API 级别的逗号分隔列表,按顺序分配给设备参数。 |
androidx.test.backup.device.api |
对于 apis 未涵盖的设备参数,则为设备的 API 级别。 |
以相同的方式将这些属性与 -D 一起传递。serial、serials、api 和 apis 属性适用于运行的每项测试,因此请使用 --tests 选项仅运行需要这些属性的测试。
测试凭证恢复
如果您的应用使用恢复凭证,请测试其恢复密钥在备份和恢复后是否仍然有效。云端和设备到设备模式会备份密钥以及应用的数据,但 LOCAL 不会。
编写设备操作,以便创建密钥并使用 Credential Manager 获取密钥。创建操作会返回密钥的凭据 ID,以便验证操作可以检查恢复操作是否恢复了相同的密钥。在此示例中,createRequestJson 和 getRequestJson 是应用传递给 Credential Manager 的请求 JSON 字符串:
// In app/src/backupTest/androidTest/kotlin/com/example/myapp/RestoreKeyActions.kt
package com.example.myapp
import android.content.Context
import androidx.credentials.CreateRestoreCredentialRequest
import androidx.credentials.CreateRestoreCredentialResponse
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetRestoreCredentialOption
import androidx.credentials.RestoreCredential
import androidx.test.backup.BackupDeviceAction
import androidx.test.backup.BackupDeviceActionArgs
import androidx.test.backup.BackupDeviceActionResult
import kotlinx.coroutines.runBlocking
import org.json.JSONObject
class CreateRestoreKeyAction : BackupDeviceAction {
override val phase = BackupDeviceAction.PHASE_POPULATE
override fun execute(
context: Context,
args: BackupDeviceActionArgs,
): BackupDeviceActionResult {
val request = CreateRestoreCredentialRequest(createRequestJson)
val response = runBlocking {
CredentialManager.create(context).createCredential(context, request)
} as CreateRestoreCredentialResponse
val credentialId = JSONObject(response.responseJson).getString("id")
return BackupDeviceActionResult.success(mapOf("credential_id" to credentialId))
}
}
class VerifyRestoreKeyAction : BackupDeviceAction {
override val phase = BackupDeviceAction.PHASE_VERIFY
override fun execute(
context: Context,
args: BackupDeviceActionArgs,
): BackupDeviceActionResult {
val option = GetRestoreCredentialOption(getRequestJson)
val response = runBlocking {
CredentialManager.create(context)
.getCredential(context, GetCredentialRequest(listOf(option)))
}
val credential = response.credential as RestoreCredential
val credentialId = JSONObject(credential.authenticationResponseJson).getString("id")
val expected = args["credential_id"]
return if (credentialId == expected) {
BackupDeviceActionResult.success()
} else {
BackupDeviceActionResult.failure("Expected key $expected, found $credentialId")
}
}
}
以下主机测试会在源设备上创建密钥,在目标设备上恢复密钥,并在目标设备上检查密钥。它会使用运行流程的每个步骤中的 assertSuccess 帮助程序:
package com.example.myapp
import androidx.test.backup.host.BackupActionResult
import androidx.test.backup.host.BackupRestoreController
import androidx.test.backup.host.BackupRestoreExtension
import androidx.test.backup.host.BackupTransportMode
import androidx.test.backup.host.Device
import java.nio.file.Path
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.io.TempDir
const val CREATE_RESTORE_KEY = "com.example.myapp.CreateRestoreKeyAction"
const val VERIFY_RESTORE_KEY = "com.example.myapp.VerifyRestoreKeyAction"
@ExtendWith(BackupRestoreExtension::class)
class RestoreKeyTest {
@Test
fun restoreKeyTransfersToNewDevice(
@Device(role = "source") source: BackupRestoreController,
@Device(role = "target") target: BackupRestoreController,
@TempDir outputDir: Path,
): Unit = runBlocking {
val created = source.runOnDevice(CREATE_RESTORE_KEY)
created.assertSuccess()
val credentialId =
(created as BackupActionResult.Success).data.getValue("credential_id")
source.stopApp()
val backupFile = source.performBackup(
BackupTransportMode.DEVICE_TO_DEVICE,
outputDir,
)
// Also deletes any restore key on the target.
target.clearAppData()
target.performRestore(backupFile)
target.runOnDevice(VERIFY_RESTORE_KEY, mapOf("credential_id" to credentialId))
.assertSuccess()
}
}
如需创建将 isCloudBackupEnabled 设置为 true(默认值)的密钥,源设备需要有一个已开启备份功能和屏幕锁定的 Google 账号。否则,Credential Manager 会抛出 E2eeUnavailableException。
与在新设备上进行云端恢复不同,云端模式不需要使用同一 Google 账号或来源设备的屏幕锁定,并且会恢复在 isCloudBackupEnabled 设置为 false 的情况下创建的密钥。
在测试之间保留应用数据
在每次测试之前,测试框架都会在测试使用的每台设备上清除应用的数据。如需为测试类或方法关闭此功能,请使用 @Isolation(IsolationPolicy.MANUAL) 为其添加注解。
使用 Java 编写测试
BackupRestoreController 方法是 Kotlin 挂起函数。如果您使用 Java 编写主机测试,请使用每种方法的 Async 变体(例如 runBackupRestoreFlowAsync),该变体会返回 ListenableFuture。
运行备份和恢复测试
AGP 会为套件测试的每个变体创建一个名为 test{Variant}{SuiteName}DefaultTestSuite 的 Gradle 任务。如需针对 debug 变体运行 backupTest 套件,请连接您的设备并运行以下任务:
./gradlew :app:testDebugBackupTestDefaultTestSuitecheck、test 和 connectedCheck 任务不会运行备份和恢复测试套件,因此请明确运行套件的任务。
如需运行单个测试类或方法,请使用 --tests 选项:
./gradlew :app:testDebugBackupTestDefaultTestSuite --tests "com.example.myapp.ThemeBackupTest"检查测试报告
测试运行完成后,AGP 会在 {module}/build/reports/tests/{taskName}/index.html(例如 app/build/reports/tests/testDebugBackupTestDefaultTestSuite/index.html)中生成 HTML 测试报告。
排查恢复失败问题
在以下情况下,云端恢复或设备间恢复会失败,并显示出错提示:
- Google Play 服务跳过您的应用,例如,因为其应用 ID 以
com.google.或com.android.开头、其备份为空,或者它设置了android:allowBackup="false"。如需测试具有此类应用 ID 的应用,请使用LOCAL模式。 - 在搭载 API 级别 36.1 或更高级别的设备或模拟器上,Google Play 服务版本低于 26.33.30。通过设备上的 Play 商店更新 Google Play 服务,或使用附带 26.33.30 或更高版本的系统映像。
已知问题和限制
自动备份和恢复测试具有以下已知问题和限制:
- Android Studio 尚不支持测试套件。即使项目配置正确,代码编辑器也可能会在测试套件源文件中显示未解析的引用错误。请改用 Gradle 从命令行构建和运行备份与恢复测试。
- 测试仅在通过 adb 连接到开发机器的设备和模拟器上运行。不支持 Firebase Test Lab。
- 测试会清除应用的数据,并更改所用设备上的备份设置。我们建议使用专用测试设备。
如需了解每个版本中的变更,请参阅备份和恢复测试版本说明。