【Android】 RoomのDaoユニットテストの書き方
こんにちは、ホンビノス五郎です。
ナビタイムジャパンで『ビジネスナビタイム動態管理ソリューション』のAndroidアプリ開発を担当しています。
Room は、 Android アプリにおいて SQLite データベースを楽に利用できるようにする Jetpack ライブラリです。
本記事では、 Room において実際にデータの出し入れを担う Dao のユニットテストについて説明します。
Room 自体の使い方については、既存の SQLite API を置き換える方法として以前に書いた記事や、公式ドキュメントをご覧ください。
前置き
想定読者
KotlinでのAndroid開発、およびJUnitでのユニットテストをしたことがある方を想定しています。
そのため、Kotlinの言語機能や Coroutine, JUnit の使い方については細かく説明しません。
テスト対象
実装例でユニットテストの対象とするコードはこちらです。
@Entity("user_t")
data class UserEntity(
@PrimaryKey
val userId: Long,
val userName: String
)
@Database(entities = [UserEntity::class], version = 1)
abstract class UserDatabase: RoomDatabase() {
abstract fun userDao(): UserDao
}
@Dao
interface UserDao {
// テスト対象
@Query("SELECT * from user_t where userId = :userId")
fun select(userId: Long): Flow<UserEntity?>
@Upsert
suspend fun upsert(entity: UserEntity)
}
この UserDao のユニットテストをします。
参照するテーブルはIDと名前を対応させるだけの簡単な構造ですが、 select() の戻り値をFlowにしています。
このFlowを購読することで、データベースに変更があったときに新しいデータをすぐ画面に反映させることができます。
テスト環境
この記事では、 Robolectric を使ったローカル単体テスト (unitTest) の場合について主に説明します。 (理由については後述します)
ただし、 インストルメンテーションテスト (androidTest) として実施する上で差分が出る箇所についてもなるべく注釈するようにします。
また、テストフレームワークにはJUnit4を使います。
実装
準備
@RunWith(RobolectricTestRunner::class)
class UserDaoTest {
private lateinit var db: UserDatabase
private lateinit var dao: UserDao
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, UserDatabase::class.java))
.build()
dao = db.userDao()
}
@After
fun tearDown() {
db.close()
}
}
Robolectric を使うため、ランナーに RobolectricTestRunner を指定します。
(※ インストルメンテーションテストの場合は AndroidJUnit4)
@Before で実行前に Database を初期化し、 @After で実行後に閉じるようにしています。
テストケース作成
select() で取得したFlowに、適切にUserEntityがemitされるかどうか確認するテストを書いてみます。
@Test
fun select() = runTest {
val resultList = mutableListOf<UserEntity?>()
// selectで取得したFlowに入ってきた値をresultListに追加する
val job = launch {
dao.select(userId = 1).toList(resultList)
}
val insertEntity = UserEntity(userId = 1, userName = "トコブシ次郎")
dao.upsert(insertEntity)
val updateEntity = UserEntity(userId = 1, userName = "シャコ三郎")
dao.upsert(updateEntity)
// 最初は値が入っていないのでnullが入る
MatcherAssert.assertThat(resultList[0], CoreMatchers.nullValue())
// insertEntityを追加したことでFlowに流れてくる
MatcherAssert.assertThat(resultList[1], CoreMatchers.equalTo(insertEntity))
// updateEntitiyで更新したことでFlowに流れてくる
MatcherAssert.assertThat(resultList[2], CoreMatchers.equalTo(updateEntity))
// Flowに入ってきた値をresultListに追加するJobを終了
job.cancel()
}
しかし、このテストは失敗します。
Expected: null
but: was <UserEntity(userId=1, userName=トコブシ次郎)>
Flowの購読を始めた段階ではデータベースに値が入っていないため、 resultList の先頭には null が入ることを期待していますが、実際には insert した値が先頭になっています。
これは、 toList() の内部で resultList に値が追加される処理よりも先に、挿入・更新処理やアサーション処理が実行されてしまうためです。
そのため、 upsert() で挿入・更新をするたびに、リストに値を追加する処理が動くようにする必要があります。
Daoの内部処理をCoroutineで制御できるようにする
Dao を Coroutine で制御できるようにするために、Databaseに設定を入れておく必要があります。
@RunWith(RobolectricTestRunner::class)
class UserDaoTest {
private val testDispatcher = StandardTestDispatcher() // ←追加
private lateinit var db: UserDatabase
private lateinit var dao: UserDao
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, UserDatabase::class.java)
.setQueryExecutor(testDispatcher.asExecutor()) // ←追加
.setTransactionExecutor(testDispatcher.asExecutor()) // ←追加
.build()
dao = db.userDao()
}
// @After, @Test...
}
テスト用のDispatcherを作り、 asExecutor() で Executor に変換して、Database作成時に Builder に渡します。
こうすることで、testDispatcherを通じて Dao の処理タイミングを Coroutine で制御できるようになります。
テスト内の必要なタイミングでDaoの内部処理を動かす
runTest() 内で値がFlowに反映されてほしいタイミングで advanceUntilIdle() を呼び出すことで、待機中の処理を呼び出すことができます。
@Test
fun select() = runTest(testDispatcher.scheduler) { // ← Builderに渡したTestDispatcherのschedulerを指定する
val resultList = mutableListOf<UserEntity?>()
val job = launch {
dao.select(userId = 1).toList(resultList)
}
advanceUntilIdle() // ← 値が入っていない状態で待機中の処理を動かす
val insertEntity = UserEntity(userId = 1, userName = "Ange")
dao.upsert(insertEntity)
advanceUntilIdle() // ← insertされた状態で待機中の処理を動かす
val updateEntity = UserEntity(userId = 1, userName = "Lize")
dao.upsert(updateEntity)
advanceUntilIdle() // ← updateされた状態で待機中の処理を動かす
MatcherAssert.assertThat(resultList[0], CoreMatchers.nullValue())
MatcherAssert.assertThat(resultList[1], CoreMatchers.equalTo(insertEntity))
MatcherAssert.assertThat(resultList[2], CoreMatchers.equalTo(updateEntity))
job.cancel()
}
このとき runTest() の第一引数に、先ほど Database の Builder に Executor として渡した testDispatcher のもつ scheduler を渡します。
こうすることで、 runTest() でテストを実行する CoroutineScope と、 Dao 内部でデータベースへの入出力を行う CoroutineScope を結び付けられます。
メインスレッドでの実行を許可する
ここまでの状態でテストを実行すると、次のようなエラーが出て失敗します。
Cannot access database on the main thread since it may potentially lock the UI for a long period of time.
runTest() でテストを実行する CoroutineScope と、 Dao 内部で使われるExecutor を紐づけたことで、データベースへのアクセスがメインスレッドで行われるようになったためです。
メインスレッドでの実行は処理の順序を保証してテストをスムーズに進めるために必要なことなので、 Database にかけられている、メインスレッドで実行できない制限を外してあげます。
@RunWith(RobolectricTestRunner::class)
class UserDaoTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var db: UserDatabase
private lateinit var dao: UserDao
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, UserDatabase::class.java)
.setQueryExecutor(testDispatcher.asExecutor())
.setTransactionExecutor(testDispatcher.asExecutor())
.allowMainThreadQueries() // ←追加
.build()
dao = db.userDao()
}
// @After, @Test...
}
これでテストが通るようになります。
※ インストルメンテーションテストの場合、メインスレッドでアクセスしようとした事によるエラーは出ないので、この工程は不要です。
おそらく、インストルメンテーションテスト ではテストを実行するスレッドがメインスレッドとは別スレッドになっているためではないかと思います。
ローカル単体テスト vs インストルメンテーションテスト
今回作ったようなローカル単体テストはJVM上で実行されるため、Javaの実行環境さえあれば自動テストをすることができます。
私の担当するプロダクトでも、 Bitbucket Pipelines を使ってローカル単体テストを自動実行しています。
この自動実行の実績があるため、本記事ではローカル単体テストとしての書き方で説明しました。
一方で、公式ドキュメントではインストルメンテーションテストとしての実施を推奨しています。
公式ドキュメントにも記載のある通り、インストルメンテーションテストの方がテストの忠実度は上がります。
しかし、CI環境で自動実行するためにはエミュレータを起動できる環境が必要で、実行環境の整備など多くの制約やハードルがあります。
ローカル単体テストとインストルメンテーションテストのどちらで実施するかについては、プロダクトやチームの状況によって選択するのが良いと思います。
おわりに
本記事で例に使ったデータベースはIDと名前だけの単純なものでしたが、構造やクエリが複雑になればなるほど単体テストを自動実行したい機会は増えると思います。
テストケースは作れるのに、期待通りに実行されないために単体でのテストができず、その結果開発が非効率的になってしまうのは非常にもったいないことです。
本記事がみなさんの Android アプリ開発の一助になれば幸いです。