# 單元測試
###### 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/)