本文将介绍如何基于kotlin 编写 UI Automator 脚本来自动测试任何 Android 应用程序。将编写一个在设置界面里添加 Wi-Fi 网络的 测试脚本,并检查设备是否连接成功
UI Automator介绍
UI Automator是一个 Android 测试框架,它允许我们编写可以与设备中安装的任何应用程序交互的脚本。UI Automator 不需要访问应用程序源代码即可工作。因此,脚本可以导航并与应用程序托盘、设置应用程序、第三方应用程序或你想要的任何其他应用程序交互。
创建项目
在 Android Studio 里创建一个新项目。Android Studio 将在项目中创建三个不同的文件夹:main:androidTest和test
。这是默认的项目结构,其中main
包含应用程序代码,test
包含在开发机器上运行的单元测试,即androidTest默认情况下进行的检测测试,如 UI Automator 测试。
如果是给当前的应用程序编写测试脚本,那么可以在androidTest
目录中创建脚本,
但在本文中,我们仅使用 UI Automator 脚本创建项目,主要测试另一个应用程序,所以在开始需要做一些修改:
打开app模块的build.gradle
,在android
节点下添加以下代码:
sourceSets {
androidTest {
java.srcDir 'src/main/java'
}
}
Code language: JavaScript (javascript)
可以同时删除test和androidTest目录。同时删除一些不再使用的资源:
app/src/main/res/values/themes.xml
app/src/main/res/values-night/themes.xml
app/src/main/res/values/colors.xml
配置好目录后,让我们添加所需的依赖项。我们需要使用implementation
而不是添加依赖项androidTestImplementation
。你还可以删除不会使用的依赖项:
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.test.ext:junit:1.1.3'
implementation 'androidx.test:runner:1.4.0'
implementation 'androidx.test.uiautomator:uiautomator:2.2.0'
}
Code language: JavaScript (javascript)
更改后,你build.gradle
将如下所示:
plugins {
id 'com.android.application'
id 'kotlin-android'
}
android {
compileSdkVersion 30
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "com.paceli.wifitest"
minSdkVersion 18
targetSdkVersion 30
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
androidTest {
java.srcDir 'src/main/java'
}
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.test.ext:junit:1.1.3'
implementation 'androidx.test:runner:1.4.0'
implementation 'androidx.test.uiautomator:uiautomator:2.2.0'
}
Code language: JavaScript (javascript)
需要手动将instrumentation
标签添加到AndroidManifest.xml
文件中:
在manifest
节点下添加instrumentation
子节点,instrumentation
的name
属性必须定义为androidx.test.runner.AndroidJUnitRunner
targetPackage
必须和当前应用包名保持一致
修改后的AndroidManifest.xml以下更改:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.paceli.wifitest">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" />
<instrumentation
android:name="androidx.test.runner.AndroidJUnitRunner"
android:targetPackage="com.paceli.wifitest" />
</manifest>
Code language: HTML, XML (xml)
编写脚本
创建并配置项目后,创建一个新类并添加注释@RunWith(AndroidJUnit4::class)
,定义AndroidJUnit4
为测试运行器。
与 JUnit 一样,测试方法必须使用@Test
. 启动(初始化)和停止(反初始化)方法必须分别用注释@Before
和@After
。如下代码所示:
package com.paceli.wifitest
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.*
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class UiAutomatorOrder {
/**
* Run before the method with @Test annotation
*/
@Before
fun before() {
Log.d(TAG, "Before")
}
/**
* Run after the method with @Before annotation
* and before methods with @After annotation
*/
@Test
fun test() {
Log.d(TAG, "Test")
}
/**
* Run after each method with @Test annotation
*/
@After
fun after() {
Log.d(TAG, "After")
}
companion object {
private const val TAG = "UiAutomatorExample"
/**
* Run once before other methods from [UiAutomatorOrder] class
*/
@JvmStatic
@BeforeClass
fun beforeClass() {
Log.d(TAG, "Before Class")
}
/**
* Run once after other methods from [UiAutomatorOrder] class
*/
@JvmStatic
@AfterClass
fun afterClass() {
Log.d(TAG, "After Class")
}
}
}
Code language: PHP (php)
运行此示例将产生以下 logcat 输出:
Before Class
Before
Test
After
After Class
Code language: PHP (php)
现在在测试类中创建一个名为validateWifi
的方法并标注@Test
.
为了点击按钮、从屏幕读取文本、执行滑动手势以及与 UI 的任何其他元素交互,我们需要获取UiDevice类的实例
此操作在init函数中完成
package com.paceli.wifitest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class WifiTest {
private val device: UiDevice
init {
val instrumentation = InstrumentationRegistry.getInstrumentation()
device = UiDevice.getInstance(instrumentation)
}
@Test
fun validateWifi() {
}
}
Code language: JavaScript (javascript)
要与屏幕上的元素进行交互,我们需要使用UiDevice
实例获取对它们的引用。为此,我们将调用方法findObject
传递我们想要与之交互的元素的属性。一些属性,比如文本,对用户是可见的,但还有一些我们在设备屏幕上看不到,所以需要使用 Android SDK 中的UI Automator Viewer工具($ANDROID_HOME/tools/bin/uiautomatorviewer) 获取屏幕信息
通过单击左上角的Device Screenshot按钮,该工具将显示当前ADB连接的设备的屏幕截图和屏幕dump信息。
可以通过在屏幕截图或右侧的层次结构视图中单击元素来选择元素。index, text, content-desc,enabled
等属性显示在Node detail视图中。
UiObject2
类表示来自屏幕的元素,它的一个实例由findObject
方法返回。为了启动设置应用,用户需要在主屏幕上执行滚动手势以启动应用程序列表,然后单击设置图标。于是我们得到了一个UiObject2
的实例,workspace
代表了应用列表界面中的设置图标,然后分别调用了scroll
和click
方法。
@Test
fun validateWifi() {
// Open apps list by scrolling on home screen
val workspace = device.findObject(
By.res("com.google.android.apps.nexuslauncher:id/workspace")
)
workspace.scroll(Direction.DOWN, 1.0f)
// Click on Settings icon to launch the app
val settings = device.findObject(
By.res("com.google.android.apps.nexuslauncher:id/icon").text("Settings")
)
settings.click()
}
Code language: JavaScript (javascript)
如果某些元素需要一些时间才能显示在屏幕上,我们可以使用方法wait
,此方法的参数为SearchCondition
和timeout
。下面调用这个方法打开该Network & internet部分,然后继续到Add network屏幕
// ...
// Wait up to 2 seconds for the element be displayed on screen
val networkAndInternet = device.wait(Until.findObject(By.text("Network & internet")), 2000)
networkAndInternet.click()
// Click on element with text "Wi‑Fi"
val wifi = device.wait(Until.findObject(By.text("Wi‑Fi")), 2000)
wifi.click()
// Click on element with text "Add network"
val addNetwork = device.wait(Until.findObject(By.text("Add network")), 2000)
addNetwork.click()
Code language: JavaScript (javascript)
在Add network屏幕上,有一个文本字段,用户必须在其中输入网络 SSID。要在 UI Automator 脚本中输入文本,我们只需要获取此字段的UiObject2
实例并调用setText
传递我们要输入的字符串
下面的代码展示输入 SSID AndroidWifi
,然后单击保存按钮添加它。
// ...
// Obtain an instance of UiObject2 of the text field
val ssidField = device.wait(Until.findObject(By.res("com.android.settings:id/ssid")), 2000)
// Call the setText method using Kotlin's property access syntax
val ssid = "AndroidWifi"
ssidField.text = ssid
//Click on Save button
device.findObject(By.res("android:id/button1").text("Save")).click()
Code language: JavaScript (javascript)
为了检查Wi-Fi是否正确添加以及Android设备是否连接到它,可以简单地检查一下屏幕上是否显示Connected
,为此,让我们使用hasObject
方法,该方法返回一个布尔值,指示某个元素当前是否正在屏幕上显示。
// ...
// BySelector matching the just added Wi-Fi
val ssidSelector = By.text(ssid).res("android:id/title")
// BySelector matching the connected status
val status = By.text("Connected").res("android:id/summary")
// BySelector matching on entry of Wi-Fi list with the desired SSID and status
val networkEntrySelector = By.clazz(RelativeLayout::class.qualifiedName)
.hasChild(ssidSelector)
.hasChild(status)
// Perform the validation using hasObject
// Wait up to 5 seconds to find the element we're looking for
val isConnected = device.wait(Until.hasObject(networkEntrySelector), 5000)
Assert.assertTrue("Verify if device is connected to added Wi-Fi", isConnected)
Code language: PHP (php)
我们还可以选择使用 Android API 来获取设备连接的当前 Wi-Fi 的 SSID。这是可能的,因为 UI Automator 脚本作为 Android 应用程序运行,因此它可以访问应用程序开发过程中常用的 API,如intent、系统服务、context等。
为了能够获取 Wi-Fi SSID,请将这些权限添加到AndroidManifest.xml:
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" / >
Code language: HTML, XML (xml)
以下方法获取应用程序的Context(UI Automator 脚本)并使用它来获取WifiManager的实例并获取 Wi-Fi SSID。我们将使用返回值与我们之前添加的网络名称进行比较。
private fun getCurrentWifiSsid(): String? {
val context = InstrumentationRegistry.getInstrumentation().context
val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as WifiManager
val wifiInfo = wifiManager.connectionInfo
// The SSID is quoted, then we need to remove quotes
return wifiInfo.ssid?.removeSurrounding("\"")
}
Code language: PHP (php)
现在测试方法已准备就绪:
@Test
fun validateWifi() {
// Open apps list by scrolling on home screen
val workspace = device.findObject(
By.res("com.google.android.apps.nexuslauncher:id/workspace")
)
workspace.scroll(Direction.DOWN, 1.0f)
// Click on Settings icon to launch the app
val settings = device.findObject(
By.res("com.google.android.apps.nexuslauncher:id/icon").text("Settings")
)
settings.click()
// Wait up to 2 seconds for the element be displayed on screen
val networkAndInternet = device.wait(Until.findObject(By.text("Network & internet")), 2000)
networkAndInternet.click()
// Click on element with text "Wi‑Fi"
val wifi = device.wait(Until.findObject(By.text("Wi‑Fi")), 2000)
wifi.click()
// Click on element with text "Add network"
val addNetwork = device.wait(Until.findObject(By.text("Add network")), 2000)
addNetwork.click()
// Obtain an instance of UiObject2 of the text field
val ssidField = device.wait(Until.findObject(By.res("com.android.settings:id/ssid")), 2000)
// Call the setText method using Kotlin's property access syntax
val ssid = "AndroidWifi"
ssidField.text = ssid
//Click on Save button
device.findObject(By.res("android:id/button1").text("Save")).click()
// BySelector matching the just added Wi-Fi
val ssidSelector = By.text(ssid).res("android:id/title")
// BySelector matching the connected status
val status = By.text("Connected").res("android:id/summary")
// BySelector matching on entry of Wi-Fi list with the desired SSID and status
val networkEntrySelector = By.clazz(RelativeLayout::class.qualifiedName)
.hasChild(ssidSelector)
.hasChild(status)
// Perform the validation using hasObject
// Wait up to 5 seconds to find the element we're looking for
val isConnected = device.wait(Until.hasObject(networkEntrySelector), 5000)
Assert.assertTrue("Verify if device is connected to added Wi-Fi", isConnected)
// Perform the validation using Android APIs
val connectedWifi = getCurrentWifiSsid()
Assert.assertEquals("Verify if is connected to the Wifi", ssid, connectedWifi)
}
Code language: PHP (php)
要拥有完整的测试脚本,我们需要在执行实际测试之前进行一些设置。如果你不是在主屏幕时运行脚本,你会注意到抛出了NullPointerException
发生这种情况是因为我们假设测试开始时设备位于主屏幕中。为了保证这一点,请在@Before注释中添加以下设置方法。
@Before
fun setUp() {
// Press Home key before running the test
device.pressHome()
}
Code language: JavaScript (javascript)
现在,Home在运行测试之前将始终按下该键。你可能还想在测试执行后返回主屏幕,为此只需添加另一个带有@After注释的方法,类似于我们对设置方法所做的。
@After
fun tearDown() {
// Press Home key after running the test
device.pressHome()
}
Code language: JavaScript (javascript)
总之,setUp
方法用于确保设备处于所需的初始状态。tearDown
方法负责在运行测试后进行清理,以免影响后续的方法。假设你正在编写一个向设备添加密码的脚本。必须在拆卸方法中删除此密码,否则下一个脚本可能会卡在密码屏幕中。
使用 ADB 运行脚本
运行前需要现在Android studio中构建APK,点击Build -> Make Project
即可
构建完成后通过ADB安装:
adb install -r -g app-debug.apk
Code language: CSS (css)
执行:
adb shell am instrument -w -e class 'com.paceli.wifitest.WifiTest' com.paceli.wifitest/androidx.test.runner.AndroidJUnitRunner
com.paceli.wifitest.WifiTest — 是要执行的测试脚本的类名。
com.paceli.wifitest/androidx.test.runner.AndroidJUnitRunner — 是测试包名和运行器类,格式为<test_package_name>/<runner_class>.
Code language: HTML, XML (xml)
结论
希望本文可以帮助你创建自己的测试脚本,提高应用程序的测试覆盖率并减少手动工作。