# 單元測試 ###### tags: `code` 單元測試是所有測試中最基底的存在, 既不需要耗費資源跑緩慢的 Android 模擬器又能快速地得到驗證。 使用的目標對象是一個有明確答案的功能上。 以下示例為: 此驗證工具會回傳其驗證結果,在測試環境中以結果和預期的答案, 在 `assertEquals` 方法內去對照兩個值是否相等,則完成單元測試。 ```java= //真實環境 object VerifyUtils{ fun verifyPassword(password: String) : Boolean { // 實作邏輯 } } ``` ```java= //測試環境 @Test fun testVerifyPassword(){ val actual = VerifyUtils.verifyPassword("password") // 結果 val expected = true // 預期 assertEquals(expected, actual) // 結果和預期是否一樣 } ``` # UI 單元測試 本章節會介紹 UI 單元測試以環境切換和 koin 依賴注入達成測試, 以及在這些過程中遇到的差異點,來做出適用性比較。 章節介紹 [ToC] ## 配置套件 Android 框架並未搭載在JVM環境內,如果測試案例依賴 Android 框架, 則需要配置 androidTestImplementation。 App在 `/src` 下有三種路徑: - `/main` 正式環境檔案 - `/test` 單元測試路徑 - `/androidTest` Android測試路徑 配置好 Espresso 套件就可開始在`/androidTest` 路徑下對UI進行測試, 以下是在 build.gradle 內的配置。 ```java= android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } ``` ```java= dependencies { // jUnit 單元測試 testImplementation 'junit:junit:4.13' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.2' // Espresso UI單元測試 androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test:rules:1.3.0' } ``` ## 使用 Espresso 錄製添加流程 如果不清楚要如何使用 Espresso 點擊 UI 畫面以及驗證,可以使用錄製方式, 程式會紀錄你的點選步驟和想驗證的資訊,之後生成檔案。 使用錄製添加的好處在於對於一些較複雜或是動態產生的UI圖面, 沒有 resId 可以獲取時,自動產生可以幫助理解此UI圖面的獲取方式。 1. Run > Record Espresso Test ![](https://i.imgur.com/YVRAPim.png =250x) 2. 在模擬機或手機上操作流程 ![](https://i.imgur.com/Ch1ZnJs.png =500x) ## UI 單元測試 - 標註@ANNOTATION 單元測試在 JUnit4 時引入了標註的使用方式, 將 **初始化、測試、結束** 都以標註添加在函式上方,以下範例。 @Before 及 @After 必定在測試的開始和結束去執行, 如果有多個 @Test 函式,則隨機執行。 ```java= class LoginActivityTest { @Before fun start(){ //初始化 } @Test fun testPayFlow(){ //要執行的測試步驟 } @After fun finish(){ // 清理及關閉物件 } } ``` ## UI 單元測試 - 宣告測試規則 在 `/androidTest` 路徑下新增測試檔案後,使用測試規則 @Rule 開啟指定的測試頁面環境,建議使用 activityScenarioRule,其優點是在使用完畢後會自行關閉測試環境。 :bulb: 拿到 activityScenarioRule 環境之後, 可以使用 `onActivity` 獲取 Activity的參數和方法。 如果此案例不需要拿 Activtiy 參數做使用,則不須開啟。 ```java= /** * 在每次測試前創建 Activity 情境 */ class LoginActivityTest { // 新版測試環境開啟方式 -> 在@After測試時間點會執行 scenario.close 清除測試流程 @get:Rule var activityScenarioRule = activityScenarioRule<LoginActivity>() // 舊版測試環境開啟方式 -> 已棄用,少了清除流程的動作 @get:Rule var mActivityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) @test fun loginTest(){ activityScenarioRule.onActivity{ loginActivity -> //獲取 Activity 的內容參數、公開方法 } } ... } ``` ## UI 單元測試 - 攥寫測試案例 ### Pos登入測試案例 測試案例:輸入帳號密碼,驗證是否有得到成功畫面, 寫完後點擊函示前的綠色按鈕執行。 ```java= class LoginActivityTest { @get:Rule var activityScenarioRule = activityScenarioRule<LoginActivity>() @Test fun loginSuccess() { val loginSuccessId = "loginSuccessId" val loginSuccessPW = "loginSuccessPW" activityScenarioRule.onActivity { loginActivity -> // 打出Login API loginActivity.viewModel.login( loginSuccessId, loginSuccessPW) } // 驗證成功畫面是否有出現 loginSuccessViewIsDisplayed() } private fun loginSuccessViewIsDisplayed(){ // 找到指定畫面 val view = Espresso.onView(withId(R.id.cl_option_scan_collect)) // 驗證 view.check(matches(isDisplayed())) } } ``` 為了讓測試環境不被外部資料影響,必須替換成假 API Response, 先暫時對正式環境的 LoginVM 改寫成以下狀態。 ```java= fun login(userId: String, password: String){ when(userId){ "loginSuccessId" -> _loginSuccess.value = Event(true) "loginFailId" -> _loginSuccess.value = Event(false) } } ``` :::danger 接著跑測試會遭遇驗證失敗: 問題在於 ViewModel 沒有同步執行, 在對 LiveData 設值前先已經先跑完驗證,導致結果與預期不一致。 ::: 添加套件讓測試環境MVVM同步執行 ```java= dependencies{ testImplementation "android.arch.core:core-testing:1.1.1" androidTestImplementation "android.arch.core:core-testing:1.1.1" } ``` ```java= /** * 在 Gradle 加入 Architecture Component 的 testing library 後, * 宣告測試規則,使 LiveData 同步執行,否則會在收到結果前做驗證導致測試失敗。 */ class LoginActivityTest { @get:Rule var activityScenarioRule = activityScenarioRule<LoginActivity>() @get:Rule val rule = InstantTaskExecutorRule() @Test fun loginSuccess() { // 上述案例 } ... } ``` 運行測試環境 -> 測試成功。 ### 除錯分享 在撰寫各種測試案例時,有發現一個Bug,是在測試環境藉由 intent 導轉後, 由於正式環境使用完 intent 對其做了清除的動作,修改掉原本測試環境的導向。 所以需注意==測試環境的流程是否會去對 intent 做清除==。 以下為報錯畫面 ( *無法跑完Activtiy生命週期* ): 測試環境印出 intent.action → android.intent.action.MAIN 正式環境印出 intent.action → null ![](https://i.imgur.com/slnODWp.png) 下一步會新增測試環境,讓測試不牽涉到正式環境的配置。 # Build Variants 切換正式、測試環境 在 Gradle 的 productFlavors 新增一個 Test 環境變數, 並在 sourceSet 設定所有支線的讀取路徑,藉由切換變數設定環境。 ```java= android { productFlavors { _Test { buildConfigField "String", "TEST_VERSION_NAME", "\"v${testVersionName} test\"" } _Dev { buildConfigField "String", "TEST_VERSION_NAME", "\"v${testVersionName} dev\"" } } // 路徑被分為主線及支線: // 主線 - src/main 主線路徑。 // 支線 - src/_testMode、src/_mainMode 支線路徑藉由切換 Build Variant 制定, // 且不可與主線有同名檔案,會造成衝突,所以五種環境皆需定義同一檔案的支線路徑。 sourceSets { _Test { java.srcDirs = ['src/_testMode/java'] } _Dev{ java.srcDirs = ['src/_mainMode/java'] } } } ``` 配置完成後,可以到Build Variants 使用下拉選單切換環境檔案。 ![](https://i.imgur.com/c3XcMnC.png =400x) 切換環境選單後,會顯示出當前所使用的路徑, 藉此在不同環境對「同檔名但不同檔案」的參數做設定。 ![](https://i.imgur.com/aNh5Pn3.png =500x) :::info *由於「同檔名但不同檔案」使用上會有難以辨識的問題, 建議使用場景可以是在同一個檔案定義正式及測試兩種參數, 以環境去判斷該使用何種參數。 ::: 當 ViewModel 寫好在測試環境內的參數,就可以開始寫測試路徑,以及驗證。 # Koin 依賴注入 Koin 是純 Kotlin 編寫的輕量級依賴注入框架,可以讓你簡易的注入資料庫。 [官方文件點我](https://insert-koin.io/) 首先在 Gradle 配置 Koin 的使用套件,可定義參數讓 Koin 版本都讀取同一個值。 ```java= dependencies{ def koin_version = '2.2.2' // Koin for Kotlin implementation "org.koin:koin-core:$koin_version" // Koin AndroidX ViewModel features implementation "org.koin:koin-androidx-viewmodel:$koin_version" // Koin for Unit tests testImplementation "org.koin:koin-test:$koin_version" androidTestImplementation "org.koin:koin-test:$koin_version" } ``` ## 1. 實作 ViewModelModule 在此我們使用MVVM框架,==ViewModel 注入 Repository 來做範例==, 自訂一個 Module 提供給 Application 層讀取。 一開始使用會覺得疑惑,為什麼沒有定義類別而是直接代入 module? 這是因為 Koin 都幫你包好了,當你代入 module 這個方法時, Koin 會幫你生成一個模組類別,讓你在這個 scope 裡面製作要注入的物件。 Koin 所提供的這個 viewModel 方法,==目的在於跟隨使用頁面的生命週期==, 依照使用情境也可以使用 factory、single 方法去注入物件。 ```java= val viewModelModule = module { // ViewModel 在此注入真實 API 物件 viewModel { HomeFragmentVM(RealMCClient()) } factory { // 每次都生成新的實例 } single { // 全域使用單一實例 } } ``` 截圖示例: ![](https://i.imgur.com/F1V3N79.png =400x) ## 2. Application 層啟動 Koin 接著我們在 Application 層啟動 Koin,放入剛剛製作的 Module - androidContext:向Koin注入context - modules:註冊宣告的Module ```java= class App : Application() { override fun onCreate() { super.onCreate() initModule() } private fun initModule() { startKoin { androidContext(this@App) modules(viewModelModule) } } } ``` ## 3. 修改 ViewModel 物件 ```java= class HomeFragmentVM(val mCClient: InjectMCClient) : ViewModel() { // ... } // 創造一個 MCClient 介面 Interface InjectMCClient{ //... } //放在正式環境的 MCClient class RealMCClient : InjectMCClient { //... } //放在測試環境的 MCClient class RealMCClient : InjectMCClient { //... } ``` ## 4. 使用 Koin 取得 Module 提供的 ViewModel ```java= class MainActivity : AppCompatActivity() { val viewModel: HomeFragmentVM by viewModel() } ``` ```java= class HomeFragment : Fragment() { val viewModel: HomeFragmentVM by sharedViewModel() } ``` 如果是以上提到的factory、single 模式,則以 get 方法取得, Koin 會依照你宣告的型態找出對應的類型。 ``` val viewModel:LoginFragmentVM = get() ``` 實作成功! ## Koin 在測試環境的使用方式 在初始化時開啟 Koin 局域,設定測試環境以及宣告的 testModule, 在 viewModel 其中注入,就可以讓正式及測試環境使用不同的 API 物件。 ```java= class PayFlowTest { @get:Rule var activityScenarioRule = activityScenarioRule<MainActivity>() private val testModule = module { // 在此注入假 API 物件 viewModel { HomeFragmentVM(FakeMCClient()) } } @Before fun before() { startKoin { // 取得測試所使用的 Context androidContext(ApplicationProvider.getApplicationContext()) modules(testModule) } @Test fun payFlow() { // 測試案例 } @After fun after() { stopKoin() } ``` ### Pay帳單點擊測試案例 ```java= class PayFlowTest { @get:Rule var activityScenarioRule = activityScenarioRule<MainActivity>() private val testModule = module { viewModel { HomeFragmentVM(FakeMCClient()) } viewModel { BillActivityVM(FakeMCClient()) } viewModel { BillListPaidFragmentVM(FakeMCClient()) } viewModel { BillListUnpaidFragmentVM(FakeMCClient()) } viewModel { BillListExpiryFragmentVM(FakeMCClient()) } } @Before fun before() { startKoin { // 取得測試所使用的 Context androidContext(ApplicationProvider.getApplicationContext()) modules(testModule) } @Test fun payFlow() { // 點擊付款中心 onView(withId(R.id.cl_bill)).waitToPerform(click()) // 點擊待處理帳單 onView(withId(R.id.rv_billList_list).withIndex(0)).waitToPerform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, click())) // 點擊付款 onView(withId(R.id.btn_billDetail_pay)).waitToPerform(click()) } // 等待後驗證畫面的擴充方法 fun ViewInteraction.waitToPerform(vararg viewActions: ViewAction?) { Thread.sleep(3000L) this.check(matches(isDisplayed())) this.perform(*viewActions) } @After fun after() { stopKoin() } ``` # 環境切換分析 - Build Varient 在切換環境的時候,如果是讀取同樣檔名不同位置的檔案,會有辨識性不佳難以維護的問題。 - 在 Koin 則可以在 Application 層注入你想更動的資料庫內容, 不必在 Activity 內去判斷環境為何。 範例: timeout 的參數分為以正式、測試環境的內容。 Build Varient 方法1: 缺點:讀取不同分支檔案的相同方法,辨識不易。 ```java= class MainActivity{ private latinit var viewModel: MainActivityVM @Override fun onCreate(){ viewModel = EspViewModelProvider(this).get(MainActivityVM::class.java) // viewModel 會讀取不同分支檔案的相同 init 方法,辨識不易。 viewModel.initTimeout() } } // 正式環境 - 正式分支檔案 class MainActivityVM { private var timeout? = null fun initTimeout(){ timeout = 30000L } } // 正式環境 - 測試分支檔案 class MainActivityVM { private var timeout? = null fun initTimeout(){ timeout = 1000L } } ``` Build Varient 方法2: 缺點:由環境判斷讀取何種參數,會造成有眾多的switchcase產生 ```java= class MainActivity{ private latinit var viewModel: MainActivityVM @Override fun onCreate(){ viewModel = EspViewModelProvider(this).get(MainActivityVM::class.java) viewModel.initTimeout() } } // 正式環境 class MainActivityVM { companion onject{ const val DEV = "_dev" const val TEST = "_test" const val TIMEOUT_DEV = 30000L const val TIMEOUT_TEST = 1000L } private var timeout? = null fun initTimeout(){ // 藉由環境判斷讀取何種參數 wheh(BuildConfig.FLAVOR){ DEV -> { timeout = DEV } TEST -> { timeout = TEST } else -> {} } } } ``` Koin 方法: 優點:藉由 Koin 依賴注入的方法,不用到 Activity 內宣告要使用哪種環境資料庫, 也同時藉由介面抽換很清楚的知道實作的資料庫為何。 ```java= //此 Modeule 提供給 Application 使用 val viewModelModule = module { // 帶入參數為介面,寫入你需要實作的環境物件 viewModel { MainActivityVM(RealMCClient()) } } class MainActivity{ private val viewModel: MainActivityVM by viewModel() @Override fun onCreate(){ viewModel.init() } } // 正式環境 class MainActivityVM(mMClient: InjectClient) { private var timeout? = null fun initTimout(){ timeout = mMClient.timeout } } // 創造一個 MCClient 介面 abstract class InjectMCClient{ abstract var timeout: Long } // 放在正式環境的 MCClient class RealMCClient : InjectMCClient { override var timeout: Long = 30000L } // 放在測試環境的 MCClient class FakeMCClient : InjectMCClient { override var timeout: Long = 1000L } ``` ### 結論 藉由以上三種實作, Build Varient 的切換方式會比較適合在切換參數稀少的狀況下使用,以免造成維護不易,或者是需添加眾多 switch case 來判斷環境為何,程式碼變得冗長。Koin 依賴注入的方式則解決了以上的問題,測試物件不需放在正式環境做切換,避免誤用以及為了隔離,可以放在 `AndroidTest` 環境來做使用。 # Espresso 進階使用 在使用 Espresso.onView 方法去尋找特定畫面時, 很常遇到一些特殊場景以致於無法用 resId 指定畫面, 以下提供幾個擴充方法讓 Espresso 能夠找到這些特殊畫面。 ## 查找動態生成的UI畫面 測試案例為輸入支付密碼時,下方的九宮格數字鍵為動態產生,無法獲取 resId。 ![](https://i.imgur.com/n99ZLHt.png =150x) 這邊使用了 allOf 方法去查找符合條件的 UI, 帶入三個Matcher參數如果都符合同一UI物件, 則會丟回這個UI Matcher。 在 childAtIndex 的方法裡面藉由比對父層 ViewGroup, 以及你輸入的 index,去抓取父層內的第 index 個子畫面。 已知此動態UI為 LinearLayout 框架, 就可以使用此條件、內容文字、index 找出對應畫面。 ```java= // 九宮格 UI 總共有第 0~3 列,第 0~2 行,第一列的第一行為「5」 val button = onView( allOf(withText("5"), withClassName(Matchers.`is`("android.widget.LinearLayout")).childAtIndex(1).childAtIndex(1), isDisplayed())) button.waitToPerform(click()) // 藉由比對 view.parent 找出特定的 viewGroup 後,找出其子層級的第 index 個 fun Matcher<View>.childAtIndex(index: Int): Matcher<View> { return object : TypeSafeMatcher<View>() { override fun describeTo(description: Description) { description.appendText("Child at position $index in parent ") this@childAtIndex.describeTo(description) } override fun matchesSafely(view: View): Boolean { val parent = view.parent return parent is ViewGroup && this@childAtIndex.matches(parent) && view == parent.getChildAt(index) } } } ``` ## 讓 UI 的頁籤換頁 測試案例為頁籤換頁。 ![](https://i.imgur.com/TTtE6MQ.png =200x) 新增一個類別繼承 ViewAction, 藉由所取得的 resId 得到整個 TabLayout 物件, 再帶入 index 對其做操作,就能切換到此頁籤。 ```java= class PayFlowTest{ @Test fun payFlow(){ onView(withId(R.id.tab_bill)).waitToPerform(TabsMatcher(1)) } } // 藉由取得的 resId,以相對應物件型態,找到其 index。 class TabsMatcher(val position: Int) : ViewAction{ override fun getConstraints(): Matcher<View> { return isDisplayed() } override fun getDescription(): String { return "Click on tab" } override fun perform(uiController: UiController?, view: View?) { if(view is TabLayout){ view.getTabAt(position)?.select() } } } ``` # AndroidTest 安卓踩雷測試坑 ## 模擬推播測試案例 此案例目的在於測試多種推播流程是否都能正常開啟應導向的頁面, 在點擊推播的時候,==由於推播參數 `Intent.FLAG_ACTIVITY_NEW_TASK` 沒有如預期的執行清除之前的 Activity==,所以需要藉由 `activity.finish` 清除。 每個 UI 測試案例都會開啟 Activity 環境,但經過流程後已經不是最原先的環境, 所以使用以下方法獲取當前 Activity,依照情境決定是否要 finish, 再執行模擬點擊推播的路徑。 ```java= object GetActivityUtils { @Throws(Throwable::class) fun getCurrentActivity(): Activity? { var currentActivity: Activity? = null InstrumentationRegistry.getInstrumentation().runOnMainSync { val resumedActivities = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED) for (activity in resumedActivities) { LogUtils.i(" %%% current activity: ${activity.javaClass.name}") currentActivity = activity break } } return currentActivity } } ``` [一些測試的坑](https://wafer.li/Android/android-%E6%B5%8B%E8%AF%95%E5%9D%91%E7%82%B9%E8%AF%A6%E8%A7%A3%EF%BC%88%E4%B8%80%EF%BC%89/)