skip to content
私的歌詞倉庫

KMPに対応したRoomのテストを書く(desktop向け)

/ 9 min read

Updated:
Table of Contents

はじめに

最近KMP(Kotlin Multiplatform)を使ってdesktop向けのメモアプリを作っています。メモの内容を保存したいなと思い調べたところ、Android開発ではお馴染みのRoomがKMP対応をされているのを見つけ、実装してみました。

Room(Kotlin マルチプラットフォーム)  |  Android Developers

簡単にdesktopアプリでもRoomを使うところまではできたのですが、テストを書こうと思うと書き方がよく分からず、すぐにはできませんでした。

今回は特に引っかかったテスト周りを中心に紹介します。紹介するコードはこちらのリポジトリから抜粋する形で紹介しています。

GitHub - Tatsumi0000/nemomemo

実装

まずは今回使っているライブラリなどのバージョンです。特にRoomはalpha版を使っているので、この記事を参考にしているタイミングでは変更があるかもです。DIライブラリとしてkoinを使っています。

[versions]
kotlin = "2.0.21"
kotlinx-coroutines = "1.9.0"
room = "2.7.0-alpha11"
ksp = "2.0.21-1.0.27"
sqlite-bundled-jvm = "2.5.0-alpha11"
koin = "4.0.0"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
## Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-sqlite-bundled-jvm = { group = "androidx.sqlite", name = "sqlite-bundled-jvm", version.ref = "sqlite-bundled-jvm" }
## koin
koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" }
koin-core = { group = "io.insert-koin", name = "koin-core" }
koin-compose = { group = "io.insert-koin", name = "koin-compose" }
[plugins]
androidxRoom = { id = "androidx.room", version.ref = "room" }

実装

実際にどのようにRoomを使うかです。と言っても、ほぼ公式のドキュメント通りに実装するだけでした。

基本的にcommonMainに実装し、環境によって差異が発生したらactualとexpectを使ってその環境の差異を吸収してあげます。

今回実装するDBのテーブルを表すdata classです。頭はRails脳なのでidをAUTO_INCREMENT で作ったり、created_atやupdated_atを作ったりしてますが、上手に使い切れてません…;;

autoGenerate で個人的に躓いたところがあって、デフォルト引数として0を定義したら最初に挿入されるレコードのidは1 始まりになります。0 始まりだとばかり思って時間を溶かしました…

@Entity(tableName = "memos")
data class Memo(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val text: String = "",
@ColumnInfo(name = "created_at")
val createdAt: Date,
@ColumnInfo(name = "updated_at")
val updatedAd: Date
)

data classで定義したcreated_atとupdated_atのDateは、Roomで認識できない型なのでコンバータを準備します。

Room を使用して複雑なデータを参照する  |  Android Developers

class DateConverter {
@TypeConverter
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
}
@TypeConverter
fun dateToTimestamp(date: Date?): Long? {
return date?.time?.toLong()
}
}

DBを操作するDAOです。

@Dao
interface MemoDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(memo: Memo)
@Update(onConflict = OnConflictStrategy.IGNORE)
suspend fun update(memo: Memo)
@Query("SELECT * FROM memos ORDER BY id ASC")
fun getAllMemosOrderByIdAsc(): Flow<List<Memo>>
@Query("SELECT * FROM memos WHERE id = :id")
fun getMemoById(id: Int): Flow<Memo>
}

RoomDatabaseのビルダーです。RoomはAndroid、iOS、desktopでRoomDatabaseのビルダーの生成方法が違います。そこで、KMP固有の記法である、expectとactualを使って関数を作っていきます。

まずはビルダー生成する関数をcommonMainにexpect関数として定義します。

expect fun getDatabaseBuilder(): RoomDatabase.Builder<MemoDatabase>

今回はdesktop向けに作りたいので先ほど定義したexpectのactual関数をdesktopMainに作ります。生成方法はそのまま公式ドキュメントを参考にしました。

Room(Kotlin マルチプラットフォーム)  |  Android Developers

actual fun getDatabaseBuilder(): RoomDatabase.Builder<MemoDatabase> {
val dbFile = File(System.getProperty("java.io.tmpdir"), "nemomemo.db")
return Room.databaseBuilder<MemoDatabase>(
name = dbFile.absolutePath,
)
}

ここまで定義したコードを使ってRoomインスタンスを生成する関数を定義します。

@Database(entities = [Memo::class], version = 1)
@ConstructedBy(MemoDatabaseConstructor::class)
@TypeConverters(DateConverter::class)
abstract class MemoDatabase: RoomDatabase() {
abstract fun getDao(): MemoDao
}
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object MemoDatabaseConstructor : RoomDatabaseConstructor<MemoDatabase> {
override fun initialize(): MemoDatabase
}
fun getRoomDatabase(
builder: RoomDatabase.Builder<MemoDatabase>
): MemoDatabase {
return builder
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
}

koin経由で取得するためにmoduleとして登録します。ここも環境によって呼び出すRoomが違うのでexpectとactualを使って登録します。

expect fun databaseModule(): Module

シングルトンで欲しいのでsingleを使って登録します。

actual fun databaseModule() = module {
single<MemoDatabase> { getRoomDatabase(getDatabaseBuilder()) }
}

これでRoomを使う準備ができました。実際にどのように呼び出しているかはリポジトリを参照して下さい(レポジトリのコードではRepository層にDIしてRepository経由で呼び出すようにしました)。

テスト

次にテストです。今回はDAOのテストを書きます。

テストを書く際にはメモリ上にDBを展開して簡単にデータの初期化をできるようにしてあげるのがスタンダードなやり方のようです(リンクはAndroidの場合のテストですが参考になります)。

データベースをテストしてデバッグする  |  Android Developers

テストは基本的にcommonTestに追加し、desktop固有のコードの場合だけdesktopTestに追加します。

メモリ上にDBを展開するためのビルダーを作ります。commonTestにテスト用のビルダーを生成するexpect関数を定義します。

expect fun getInMemoryDataBase(): RoomDatabase.Builder<MemoDatabase>

desktop向けのビルダーをactual関数で定義します。desktopはAndroidと違ってシンプルですね。

自分はinMemoryDatabaseBuilder の引数に手こずりました。iOSだと実装するDatabaseクラスの::class.instantiateImpl() を使っていたのですが、コードを書くとそもそもinstantiateImpl() がなかったりで分からん!となっていました。desktopだと引数などは特に書かなくても良いみたいですね…

actual fun getInMemoryDataBase(): RoomDatabase.Builder<MemoDatabase> {
return Room.inMemoryDatabaseBuilder<MemoDatabase>()
}

メモリにDBを展開する準備は整ったので実際に呼び出してテストを書きます。

class MemoDatabaseTest {
private lateinit var db: MemoDatabase
private lateinit var dao: MemoDao
@BeforeTest
fun setUp() {
db = getInMemoryDataBase().setDriver(BundledSQLiteDriver()).build()
dao = db.getDao()
}
@AfterTest
fun tearDown() {
db.close()
}
@Test
fun getAllMemosOrderByIdAscTest() = runTest {
val testLength = 3
testData(testLength).map {
dao.insert(it)
}
val result = dao.getAllMemosOrderByIdAsc().first()
assertEquals(testLength, result.size)
}
private fun testData(testLength: Int): List<Memo> {
val testData = List(testLength) { count ->
val count = count + 1
Memo(id = 0, text = "Hoge-$count", createdAt = Date(), updatedAd = Date())
}
return testData
}
}

終わりに

KMPのdesktop向けにRoomを使う方法とテストの書き方について紹介しました。AndroidとiOSは参考になるコードや、テストも見つけたのですが、desktopだけは全然なくて今回記事を書きました。

Roomを使って初めてKMP特有の記法であるexpectとactualを使いました。これを使うことで、あまり環境を意識せずにコードが書けて便利だなと思いました。

ViewModelに続いてRoomもKMPに対応して、AndroidエンジニアはKMPのハードルは下がった気がします。

参考文献