全副武裝!Android UI 自動化測試在 RxImagePicker 中的實踐歷程

概述

我是卻把清梅嗅,一個普通的Android開發者,除了平常工做以外,我還喜歡在個人Github上開源分享本身寫的一些小工具。其中我我的比較滿意的是RxImagePicker,它是我花費業餘時間實現的一個Android的響應式圖片選擇器,它的項目主頁:java

github.com/qingmei2/Rx…android

隨着一些小夥伴的支持,這個圖片選擇器慢慢被嘗試應用在了一些項目中,隨着用的人愈來愈多,我感到壓力愈來愈大,至少我須要保證,每次發佈的新版本要避免低級的失誤,至少不能發生一使用就崩潰的狀況吧。git

我很快意識到我遇到了困境——即便是庫的一個小版本的更新,我都須要保證庫中每一個界面基本功能的可用,版本發佈後,我都須要本身去依賴Jcenter上最新的版本,而後運行並手動測試它的各個界面。github

就這樣,我堅持了幾個版本的迭代,迭着迭着,我就迭不動了。微信

我不知道還能堅持手動劃來劃去測試多久,這意味着,UI的自動化測試勢在必行,因而我藉着這個機會去作了。結果是:UI的自動化測試被應用到了個人這個項目中架構

如今,每次發佈版本,我只須要一鍵運行,避免了不會產生低級bug同時,免去了每一個界面手動測試的繁瑣app

UI自動化測試效果

我須要作的就是一遍愜意喝茶,一遍等待自動化測試的結果,很快,我獲得了下面的結果:ide

測試代碼其實並很少,可是也的確花費了我很多的閒暇時間去學習Espresso的UI自動化測試,但我認爲這是值得的。函數

固然,由於Android的UI自動化測試在國內並未普遍應用開來(實際上不僅是UI自動化測試,單元測試也是如此,我我的猜測,和國內廣泛性的焦躁心態不無關係),這方便的學習資料不多,我也多多少少踩了一些坑,我決定將個人實踐經歷分享出來,但願對一些想要學習Espresso的朋友有必定的幫助。工具

準備

本文默認讀者對AndroidUI自動化測試的基本概念有必定的瞭解,而且初步掌握了Espresso工具庫的使用。

若是您還不是很熟悉這些基礎的API,請參考筆者的這篇文章:

《解放雙手,Android開發應該嘗試的UI自動化測試》

此外,若是讀者有必定的測試相關的基礎,包括JUnit4,Rule的概念,以及Kotlin的基本語法就更好了。

本文示例的全部測試代碼都源自此項目:

github.com/qingmei2/Rx…

若是以爲這個庫還不錯,也歡迎star或者fork它,這也算是筆者寫本文時的一個私心吧(抱拳)...

步步爲營,實踐中遇到的問題

1.依賴和配置

首先是在本身項目的build.gradle文件中添加Espresso的相關依賴以及配置AndroidJUnitRunner

android {
    defaultConfig {
        // ...
        testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner'
    }
}
dependencies {

  //...

  androidTestImplementation "com.android.support.test.espresso:espresso-core:3.0.2"
  androidTestImplementation "com.android.support.test.espresso:espresso-contrib:3.0.2"
  androidTestImplementation "com.android.support.test.espresso:espresso-idling-resource:3.0.2"
  androidTestImplementation "com.android.support.test.espresso:espresso-intents:3.0.2"
  androidTestImplementation 'com.android.support.test:runner:1.0.2'
  androidTestImplementation 'com.android.support.test:rules:1.0.2'
}
複製代碼

另:Groovy是一個很是好用的語言,在個人我的項目中,每次我發佈新的版本,我須要讓sample去依賴Jcenter遠端的版本;而開發時會去依賴project中的Module,這樣經過添加配置一個變量,做爲開關進行版本控制便可:

寫好後,就能夠在Module下的androidTest包下開始本身的UI自動化測試了:

首先咱們從簡單的開始,sample的主頁面:

2.測試界面跳轉(Intent)

主頁面很是簡單,3個按鈕,跳轉3個不一樣的圖片選擇界面,對於單一的按鈕測試代碼以下:

@RunWith(AndroidJUnit4::class)
@LargeTest
class MainActivityTest {

    val TEST_PACKAGE_NAME = "com.qingmei2.sample"

    @Rule
    @JvmField
    var tasksActivityTestRule = IntentsTestRule<MainActivity>(MainActivity::class.java)

    @Test
    fun testJump2SystemActivity() {
        // 檢查點擊事件——點擊SystemTheme按鈕,跳轉系統選擇器界面
        checkScreenJumpEvent({ R.id.btn_system_picker },
                { ".system.SystemActivity" })
    }

    private fun checkScreenJumpEvent(buttonId: () -> Int,
                             shortName: () -> String,
                             packageName: () -> String = { TEST_PACKAGE_NAME }) {

        // 點擊對應按鈕
        onView(withId(buttonId())).perform(click()).check(doesNotExist())
        // 是否有對應的intent產生
        intending(allOf(
                toPackage(packageName()),    // 包路徑
                hasComponent(hasShortClassName(shortName()))  //類的shortClassName
        ))

        // 點擊返回鍵,檢查是否回到當前界面
        pressBack()
        onView(withId(buttonId())).check(matches(isDisplayed()))
    }
}
複製代碼

對於頁面的跳轉測試,Espresso提供了IntentsTestRule以代替ActivityTestRule,同時它提供了對界面元素髮生的Intent跳轉行爲的檢查機制

所以,咱們對於界面跳轉的檢測,只須要將ActivityTestRule替換爲IntentsTestRule便可,不須要多餘的配置。

固然,致使這種簡便性的真正緣由是,IntentsTestRule自己就是繼承了ActivityTestRule

public class IntentsTestRule<T extends Activity> extends ActivityTestRule<T> {}
複製代碼

3.測試權限請求

接下來的圖片選擇界面的測試,以微信主題爲例,界面以下:

正如我本身操做的,我分別須要添加模擬用戶打開相機和用戶打開相冊的兩種行爲的測試代碼。

其實這個測試並不難寫,以打開微信主題的相冊界面爲例,測試代碼以下:

@RunWith(AndroidJUnit4::class)
@LargeTest
class WechatActivityTest {
    // 打開相冊固然是須要調用startActivityForResult獲取結果
    // 所以這裏Mock成功後的activityResult(根據實際項目中的參數來)
    private val successActivityResult: Instrumentation.ActivityResult =
            with(Intent()) {
                putExtra(BasePreviewActivity.EXTRA_RESULT_BUNDLE, EXTRA_BUNDLE)
                putExtra(BasePreviewActivity.EXTRA_RESULT_APPLY, EXTRA_RESULT_APPLY)

                Instrumentation.ActivityResult(Activity.RESULT_OK, this)
            }

    @Rule
    @JvmField
    var systemActivityTestRule = IntentsTestRule<WechatActivity>(WechatActivity::class.java)

    @Test
    fun testPickGallery() {
        intending(allOf(
                toPackage("com.qingmei2.rximagepicker_extension_wechat"),
                hasComponent(".ui.WechatImagePickerActivity")
        )).respondWith(successActivityResult)

        onView(withId(R.id.imageView)).check(matches(isDisplayed()))

        onView(withId(R.id.fabGallery)).perform(click())

        onView(withId(R.id.imageView)).check(doesNotExist())
    }

    companion object Mock {
        private const val EXTRA_BUNDLE = "123"
        private const val EXTRA_RESULT_APPLY = "456"
    }
}
複製代碼

這裏看起來沒有什麼問題,咱們直接運行,Espresso的代碼模擬按鈕的點擊事件,結果並無出現想象中的界面跳轉,緣由就是:

在進入相冊界面以前,系統彈出了一個權限請求的彈窗。

個人UI界面邏輯是,若是用戶沒有賦予權限,那麼不會跳轉接下來的界面,而權限彈窗是系統級的,咱們沒法經過Espresso找到對應的Button進行權限的確認。

依賴於求助Google,我發現所幸在比較新的版本中,Espresso提供了GrantPermissionRule,它能夠自動賦予界面所彈出權限彈窗對應的權限:

@Rule
@JvmField
var grantPermissionRule = GrantPermissionRule.grant(
      android.Manifest.permission.WRITE_EXTERNAL_STORAGE
)
複製代碼

我在測試類中添加了WRITE_EXTERNAL_STORAGE的權限Rule,果真在接下來的測試中,沒有再出現由於權限彈窗致使出現的測試失敗狀況。

4.Application & Library ?

接下來我要講述的這個問題困擾了我好久,在講述它以前,我先放張圖:

正如您所見的,這是項目的結構,sample做爲工具庫的上層調用者,底層不一樣主題的圖片選擇UI界面被放在了library module中(好比上文所展現的微信主題界面WechatImagePickerActivity)。

我認爲WechatImagePickerActivity的UI測試也應該放在library下——這彷佛理所固然,但當我寫好相關的測試代碼後,在運行時,我發現一個問題,相冊界面沒有顯示任何圖片,就好像手機裏沒有任何照片同樣。

我苦思冥想好久,最終找到了問題的關鍵,我發現,當我把該Activity的UI測試代碼放在library包下,那麼個人自定義相冊界面沒法找到任何圖片資源;而若是我把該Activity的UI測試代碼放在application包下,那麼個人自定義相冊界面就可以正常顯示了

我並不知道發生這種狀況的真正緣由,可是找到了這個緣由已經足夠,我把全部的UI測試代碼都暫時放在了sample的androidTest目錄下了。

5.測試UI前使用依賴注入

並不是全部界面均可以直接測試,一些Activity在啓動的同時是須要一些額外依賴的,舉例來講,個人項目中,微信主題界面的WechatImagePickerActivity的onCreate()中,須要一個這樣的對象:

class SelectionSpec private constructor() : ICustomPickerConfiguration {
    //....各類各樣的配置,好比最大可選數量,themeId等
    var themeId: Int = 0
    var orientation: Int = 0
    var countable: Boolean = false
    var maxSelectable: Int = 0
    var maxImageSelectable: Int = 0
    var maxVideoSelectable: Int = 0
    var filters: ArrayList<Filter>? = null
    var capture: Boolean = false
    var captureStrategy: CaptureStrategy? = null
    var spanCount: Int = 0
    var gridExpectedSize: Int = 0
    var thumbnailScale: Float = 0.toFloat()

    // Builder 再也不顯示
}

class WechatImagePickerActivity : AppCompatActivity() {
    // 在Activity onCreate()中,須要SelectionSpec的對象,若爲空,則會crash
    override fun onCreate(savedInstanceState: Bundle?) {
        setTheme(SelectionSpec.instance!!.themeId)
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_picker_wechat)
    }
    // ...
}
複製代碼

因而可知,有些狀況下,若是不提早爲界面添加必須的依賴,UI測試是根本沒法進行的。

這些對象可能會在API的調用過程當中,庫的內部進行了解析以及初始化,可是對於單個UI界面的測試來說,這些對象卻並不必定會被初始化。正如你所見的,本文中的Activity就在這種狀況下拋出NullPointException。

依賴的實例化取決於項目或者庫的架構設計,完成以後,只須要經過ActivityTestRule提供的beforeActivityLaunched()進行依賴的注入便可,這個方法會在Activity啓動以前執行:

@Rule
    @JvmField
    val tasksActivityTestRule =
            object : IntentsTestRule<WechatImagePickerActivity>(WechatImagePickerActivity::class.java) {

                override fun beforeActivityLaunched() {
                    super.beforeActivityLaunched()

                    // Inject the ICustomPickerConfiguration
                    SelectionSpec.instance = WechatConfigrationBuilder(MimeType.ofImage(), false)
                            .maxSelectable(9)
                            .countable(true)
                            .spanCount(3)
                            .build()
                }
            }
複製代碼

如今咱們在Activity啓動前配置好了所須要的依賴,運行測試代碼,NullPointException也再也不發生了。

6.對RecyclerView的操做進行測試

Espresso在新的版本(好像是2.2+)中添加了RecyclerViewAction,以對應咱們想要對RecyclerView的操做,這極大方便了開發者進行使用(要知道早期版本中是沒有對RecyclerView的支持,這樣咱們想對item進行操做,就必須依賴onData())。

RecyclerViewAction很強大,但我不會針對它的如何使用進行過多的講解,以一個簡單的小例子進行闡述,當咱們想操做一個item中的某個指定的View進行操做,咱們能夠經過自定義ViewAction來實現:

fun clickRecyclerChildWithId(id: Int): ViewAction =
        object : ViewAction {
            override fun getDescription(): String =
                    "Click on a child view with specified id."

            override fun getConstraints(): Matcher<View>? =
                    null

            override fun perform(uiController: UiController, view: View) {
                view.findViewById<View>(id).apply {
                    performClick()
                }
            }
        }

fun ViewInteraction.clickRecyclerChildWithId(itemPosition: Int, viewId: Int) =
        perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
                itemPosition, clickRecyclerChildWithId(viewId)
        ))
複製代碼

以我的項目爲例,微信相冊界面,我想點擊指定Position Item的CheckView以選中某張圖片,測試代碼就能夠這樣:

// select image
onView(withId(R.id.recyclerview))
    .perform(actionOnItemAtPosition<RecyclerView.ViewHolder>(
                1, clickRecyclerChildWithId(R.id.check_view)  //頂層函數
    ))
複製代碼

Kotlin頂層函數很是實用,加上拓展函數,會使得上述的測試代碼變得更加簡潔:

// 點擊poisition = 1 的item的CheckView
onView(withId(R.id.recyclerview))
        .clickRecyclerChildWithId(1, R.id.check_view)   //拓展函數
複製代碼

7.測試Activity是否已經Finish

除了系統的Back鍵,不少界面都有返回功能的按鈕設計,甚至其餘界面元素,它們會致使當前Activity的關閉,這個該如何測試呢?

我翻遍了Espresso的官方文檔,都沒有找到對Activity是否關閉的CheckAPI,而且,我詫異的發現,不管是百度仍是google,我都沒找到關於這個狀況的討論。

我一時間一籌莫展,我不認爲這種測試Case沒有工程師想到過,他們是如何處理的呢?

我換了一個思考的角度,爲何Espresso沒有提供這樣的API給開發者——除非是,API自己就已經存在了

我最終的解決方案是藉助於ActivityTestRuleJUnit4

ActivityTestRule自己就能夠提供正在測試中的Activity:

fun ActivityTestRule<out Activity>.isFinished(): Boolean = activity.isFinishing
複製代碼

這以後,配合JUnit4自己的斷言徹底能夠實現Activity是否已經關閉的校驗,我只須要這樣調用:

Assert.assertTrue(activityTestRule.isFinished())
複製代碼

這麼簡單的實現方式讓我感到啼笑皆非,所幸雖然耽誤了一些時間,但我獲得了我想要的結果。

小結

雖然經歷了各類各樣奇怪的問題(踩坑),好在有驚無險,成功上岸,關於Android的測試相關文獻(代碼demo)一貫甚少,但願本文可以爲正在學習UI自動化測試的同行們提供一些可行性的建議和指導。

本文項目地址:

github.com/qingmei2/Rx…

也但願本文可以讓一些朋友體會到UI自動化測試的好處,即便覆蓋實現的過程很是曲折,可是當真正實現以後,纔會真正愛上它,正所謂:

金風玉露一相逢,便勝卻人間無數。

歡迎關注個人微信公衆號「玉剛說」,接收第一手技術乾貨
相關文章
相關標籤/搜索