概述
我是卻把清梅嗅,一個普通的Android開發者,除了日常工作之外,我還喜歡在我的Github上開源分享自己寫的一些小工具。其中我個人比較滿意的是RxImagePicker,它是我花費業餘時間實現的一個Android的響應式圖片選擇器,它的專案主頁:
隨著一些小夥伴的支援,這個圖片選擇器慢慢被嘗試應用在了一些專案中,隨著用的人越來越多,我感到壓力越來越大,至少我需要保證,每次釋出的新版本要避免低階的失誤,至少不能發生一使用就崩潰的情況吧。
我很快意識到我遇到了困境——即使是庫的一個小版本的更新,我都需要保證庫中每個介面基本功能的可用,版本釋出後,我都需要自己去依賴Jcenter上最新的版本,然後執行並手動測試它的各個介面。
就這樣,我堅持了幾個版本的迭代,迭著迭著,我就迭不動了。
我不知道還能堅持手動劃來劃去測試多久,這意味著,UI的自動化測試勢在必行,於是我藉著這個機會去做了。結果是:UI的自動化測試被應用到了我的這個專案中。
現在,每次釋出版本,我只需要一鍵執行,避免了不會產生低階bug同時,免去了每個介面手動測試的繁瑣:
我需要做的就是一遍愜意喝茶,一遍等待自動化測試的結果,很快,我得到了下面的結果:
測試程式碼其實並不多,但是也的確花費了我不少的閒暇時間去學習Espresso的UI自動化測試,但我認為這是值得的。
當然,因為Android的UI自動化測試在國內並未廣泛應用開來(實際上不只是UI自動化測試,單元測試也是如此,我個人猜想,和國內普遍性的焦躁心態不無關係),這方便的學習資料很少,我也多多少少踩了一些坑,我決定將我的實踐經歷分享出來,希望對一些想要學習Espresso的朋友有一定的幫助。
準備
本文預設讀者對AndroidUI自動化測試的基本概念有一定的瞭解,並且初步掌握了Espresso工具庫的使用。
如果您還不是很熟悉這些基礎的API,請參考筆者的這篇文章:
《解放雙手,Android開發應該嘗試的UI自動化測試》
此外,如果讀者有一定的測試相關的基礎,包括JUnit4,Rule的概念,以及Kotlin的基本語法就更好了。
本文示例的所有測試程式碼都源自此專案:
如果覺得這個庫還不錯,也歡迎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本身就已經存在了。
我最終的解決方案是藉助於ActivityTestRule
和JUnit4
。
ActivityTestRule
本身就可以提供正在測試中的Activity:
fun ActivityTestRule<out Activity>.isFinished(): Boolean = activity.isFinishing
複製程式碼
這之後,配合JUnit4
本身的斷言完全可以實現Activity是否已經關閉的校驗,我只需要這樣呼叫:
Assert.assertTrue(activityTestRule.isFinished())
複製程式碼
這麼簡單的實現方式讓我感到哭笑不得,所幸雖然耽誤了一些時間,但我得到了我想要的結果。
小結
雖然經歷了各種各樣奇怪的問題(踩坑),好在有驚無險,成功上岸,關於Android的測試相關文獻(程式碼demo)一向甚少,希望本文能夠為正在學習UI自動化測試的同行們提供一些可行性的建議和指導。
本文專案地址:
也希望本文能夠讓一些朋友體會到UI自動化測試的好處,即使覆蓋實現的過程非常曲折,但是當真正實現之後,才會真正愛上它,正所謂:
金風玉露一相逢,便勝卻人間無數。