使用UI Automator 为 Android 应用程序编写自动化测试脚本

本文将介绍如何基于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子节点,
instrumentationname属性必须定义为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 ClassCode 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代表了应用列表界面中的设置图标,然后分别调用了scrollclick方法。

@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,此方法的参数为SearchConditiontimeout。下面调用这个方法打开该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.apkCode 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)

结论

希望本文可以帮助你创建自己的测试脚本,提高应用程序的测试覆盖率并减少手动工作。

发表评论