# Unit Test - 測試Room database --- * 延續整合測試 - [Unit Test - 測試Android Components](https://hackmd.io/pKa8Zt-7TdunWGdsn9UK8A?view) ## 這篇使用Room Database,並用整合測試來驗證Dao是否有正確執行 ## 開始吧 ### 情境 * 我們有一個資料庫,裡面包含一個資料表 * 資料表有幾個欄位,儲存相關的資料 * 有一個Dao定義一些SQL語法-在這例子使用 **新增、刪除** ### Gralde依賴 * 加入Room * 一些常用的測試 ```kotlin= apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' dependencies { // Room implementation "androidx.room:room-runtime:2.3.0" kapt "androidx.room:room-compiler:2.3.0" // Kotlin Extensions and Coroutines support for Room implementation "androidx.room:room-ktx:2.3.0" // Local Unit Tests implementation "androidx.test:core:1.3.0" testImplementation "junit:junit:4.13.2" testImplementation "org.hamcrest:hamcrest-all:1.3" testImplementation "androidx.arch.core:core-testing:2.1.0" testImplementation "org.robolectric:robolectric:4.3.1" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2" testImplementation "com.google.truth:truth:1.0.1" testImplementation "org.mockito:mockito-core:2.23.0" // Instrumented Unit Tests androidTestImplementation "junit:junit:4.13.2" androidTestImplementation "com.linkedin.dexmaker:dexmaker-mockito:2.12.1" androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2" androidTestImplementation "androidx.arch.core:core-testing:2.1.0" androidTestImplementation "com.google.truth:truth:1.0.1" androidTestImplementation 'androidx.test.ext:junit:1.1.2' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' androidTestImplementation "org.mockito:mockito-core:2.23.0" } ``` ### 建立資料庫需要的類別與介面 * data class ->定義我們需要的欄位名稱、類型 * Dao -> 操作資料庫的方法 * Database ->本體 #### data class * 這邊例子使用購物清單 ```kotlin= @Entity(tableName = "shopping_items") data class ShoppingItem( var name: String, var amount: Int, var price: Float, var imageUrl: String, @PrimaryKey(autoGenerate = true) val id: Int? = null ) ``` #### Dao * 使用新增、刪除 * 兩個函數返回LiveData,不需要使用suspend fun ```kotlin= @Dao interface ShoppingDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertShoppingItem(shoppingItem: ShoppingItem) @Delete suspend fun deleteShoppingItem(shoppingItem: ShoppingItem) @Query("SELECT * FROM shopping_items") fun observeAllShoppingItems(): LiveData<List<ShoppingItem>> @Query("SELECT SUM(price * amount) FROM shopping_items") fun observeTotalPrice(): LiveData<Float> } ``` #### Database ```kotlin= @Database( entities = [ShoppingItem::class], version = 1 ) abstract class ShoppingItemDatabase : RoomDatabase() { abstract fun shoppingDao(): ShoppingDao } ``` ![](https://i.imgur.com/M6mO63B.png) ### 開始寫測試程式 * 因為是整合測試,所以要在androidTest這個路徑底下新增 * package盡量一致,也放在專案名稱/data/local * 命名方式,依照要測試的檔案名稱加上Test,ShoppingDaoTest ### 程式碼簡單說明 #### 先完成初始化 ```kotlin= @RunWith(AndroidJUnit4::class) @SmallTest class ShoppingDaoTest { //定義資料庫與Dao private lateinit var database: ShoppingItemDatabase private lateinit var dao: ShoppingDao //初始化資料庫的instance & Dao @Before fun setUp(){ database = Room.inMemoryDatabaseBuilder( ApplicationProvider.getApplicationContext(), ShoppingItemDatabase::class.java ).allowMainThreadQueries() .build() dao = database.shoppingDao() } //當資料庫操作完畢,要close @After fun tearDown(){ database.close() } } ``` 1. @RunWith(AndroidJUnit4::class)是宣告這個測試程式要運行在這類別,不是JVM 2. @SmallTest是聲明這個測試它的範圍?耗時?,Google有一張表可以參考 [Test Sizes](https://testing.googleblog.com/2010/12/test-sizes.html) ![](https://i.imgur.com/lrvt2U6.png) #### 測試函數 ```kotlin= @Test fun insertShoppingItem() = runBlockingTest { val shoppIngItem = ShoppingItem("name", 1,1f,"url",1) dao.insertShoppingItem(shoppIngItem) val allShoppingItems = dao.observeAllShoppingItems().getOrAwaitValue() // return liveData assertThat(allShoppingItems).contains(shoppIngItem) } ``` 1. 資料庫的操作,通常寫在IO執行緒,所以用runBlockingTest替代,它跟用runBlocking差別在於會略過像delay(1000)這種suspend fun,直接執行coroutine block本體程式碼 2. ![](https://i.imgur.com/8rpmwSk.png) 3. runBlockingTest會警告說要加入聲明,在測試類別加入@ExperimentalCoroutinesApi 4. ![](https://i.imgur.com/joURCib.png) 5. 建立一筆資料並執行Datebase的寫入功能 6. 再透過Dao的方法讀取剛剛寫入的資料,因為這個方法返回LiveData,google有提供方法可以直接獲取到資料,在test的跟目錄創建一個類別,將下面的code寫進去就可以用了 7. 透過getOrAwaitValue()就可以拿到資料 8. 再比對查詢的資料是否有包含寫入的資料,來完成驗證 ```kotlin= package com.androiddevs.shoppinglisttestingyt /* * Copyright (C) 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import androidx.annotation.VisibleForTesting import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException /** * Gets the value of a [LiveData] or waits for it to have one, with a timeout. * * Use this extension from host-side (JVM) tests. It's recommended to use it alongside * `InstantTaskExecutorRule` or a similar mechanism to execute tasks synchronously. */ @VisibleForTesting(otherwise = VisibleForTesting.NONE) fun <T> LiveData<T>.getOrAwaitValue( time: Long = 2, timeUnit: TimeUnit = TimeUnit.SECONDS, afterObserve: () -> Unit = {} ): T { var data: T? = null val latch = CountDownLatch(1) val observer = object : Observer<T> { override fun onChanged(o: T?) { data = o latch.countDown() this@getOrAwaitValue.removeObserver(this) } } this.observeForever(observer) try { afterObserve.invoke() // Don't wait indefinitely if the LiveData is not set. if (!latch.await(time, timeUnit)) { throw TimeoutException("LiveData value was never set.") } } finally { this.removeObserver(observer) } @Suppress("UNCHECKED_CAST") return data as T } ``` #### 你還缺少規則 * 如果對剛剛的測試程式碼執行,它會跳出錯誤,說你的job尚未完成 ![](https://i.imgur.com/UhiMgmB.png) * 必須新增規則 * 在執行一次就綠燈了~~ ```kitlin= @ExperimentalCoroutinesApi @RunWith(AndroidJUnit4::class) @SmallTest class ShoppingDaoTest { //這邊概念有點像是讓livedata立即notify change給他的observer @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() private lateinit var database: ShoppingItemDatabase private lateinit var dao: ShoppingDao } ``` ![](https://i.imgur.com/Q08lGZV.png) #### 完成剩下的測試函數 * 刪除 * 計算購買項目總價錢 ```kotlin= @Test fun deleteShoppingItem() = runBlockingTest { val shoppIngItem = ShoppingItem("name", 1, 1f, "url", 1) dao.insertShoppingItem(shoppIngItem) //寫入 dao.deleteShoppingItem(shoppIngItem) //刪除 val allShoppingItems = dao.observeAllShoppingItems().getOrAwaitValue() // return liveData //比對寫入的資料是否不存在查詢的資料 assertThat(allShoppingItems).doesNotContain(shoppIngItem) } @Test fun calculateTotalPrice() = runBlockingTest { val shoppIngItem1 = ShoppingItem("name", 2, 1f, "url", 1) val shoppIngItem2 = ShoppingItem("name", 3, 6.5f, "url", 2) val shoppIngItem3 = ShoppingItem("name", 0, 100f, "url", 3) dao.insertShoppingItem(shoppIngItem1) dao.insertShoppingItem(shoppIngItem2) dao.insertShoppingItem(shoppIngItem3) //寫入三筆資料,比對價格是否一樣 val allShoppingPrice = dao.observeTotalPrice().getOrAwaitValue() // return liveData assertThat(allShoppingPrice).isEqualTo(2 * 1f + 3 * 6.5f + 0 * 100f) } ``` ### Room的測試方法大概就如上述..再嘗試修改的方法 ```kotlin= @Test fun updateShoppingItem() = runBlockingTest { val shoppIngItem1 = ShoppingItem("name", 2, 1f, "url", 1) val updateItem1 = ShoppingItem("banana", 2, 1f, "url", 1) dao.insertShoppingItem(shoppIngItem1) dao.updateShoppingItem(updateItem1) val afterUpdate = dao.observeAllShoppingItems().getOrAwaitValue() assertThat(afterUpdate).isEqualTo(listOf(ShoppingItem("banana", 2, 1f, "url", 1))) } ``` 參考資料 [Philipp Lackner channel](https://www.youtube.com/watch?v=xGbr9LOSbC0) ###### tags: `test` `Unit Test` `kotlin` `Android`