Migrando :core:database de Android a Kotlin Multiplatform en GasGuru, uno de los primeros obstáculos fue llevar los tests de Room. La lógica parecía sencilla: si las entidades y los DAOs viven en commonMain, los tests deberían ir en commonTest. No funciona.
Este post recoge los dos errores que me encontré y cómo los resolví.
El problema con commonTest
Al mover los tests de DAO a commonTest usando la nueva API de Room KMP:
db = Room.inMemoryDatabaseBuilder<GasGuruDatabase>()
.setDriver(BundledSQLiteDriver())
.build()
El compilador devuelve esto:
error: No value passed for parameter 'context'
error: No value passed for parameter 'klass'
El motivo: Room.inMemoryDatabaseBuilder<T>() sin contexto solo existe en el target JVM. commonTest compila para todos los targets activos del módulo — incluyendo Android — y en Android esa sobrecarga no existe. El compilador encuentra la antigua inMemoryDatabaseBuilder(context, Class) y se queja de los parámetros que faltan.
El problema con androidUnitTest
Moviéndolo a src/test/kotlin/ (source set androidUnitTest), compila. Pero al ejecutarlo:
java.lang.UnsatisfiedLinkError: no sqliteJni in java.library.path
El artefacto jvmAndroid de sqlite-bundled incluye una librería JNI compilada para ARM Android. Cuando los tests corren en la JVM del host (macOS o Linux), esa librería no puede cargarse porque no es el binario nativo correcto.
La solución: jvmTest
El source set correcto es jvmTest. Es el source set exclusivo del target jvm puro, donde:
inMemoryDatabaseBuilder<T>()sin contexto existe.BundledSQLiteDrivercarga la versión nativa del host, no la ARM de Android.
Para activarlo hay que añadir el target jvm() al módulo KMP y declarar las dependencias. Importante: los ksp* van en el bloque dependencies {} raíz, no dentro de kotlin {}:
// build.gradle.kts
kotlin {
androidTarget()
iosSimulatorArm64()
iosArm64()
iosX64()
jvm() // ← necesario para jvmTest
sourceSets {
jvmTest.dependencies {
implementation(libs.junit5.api)
runtimeOnly(libs.junit5.engine)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.sqlite.bundled)
}
}
}
// Fuera del bloque kotlin {}
dependencies {
add("kspAndroid", libs.androidx.room.compiler)
add("kspIosSimulatorArm64", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room.compiler)
add("kspIosX64", libs.androidx.room.compiler)
add("kspJvm", libs.androidx.room.compiler) // ← para el target JVM de tests
// añadir un ksp* por cada target que use el módulo
}
Y el patrón de test queda limpio:
@BeforeEach
fun createDb() {
db = Room.inMemoryDatabaseBuilder<GasGuruDatabase>()
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
}
@AfterEach
fun closeDb() = db.close()
expect/actual en Room KMP
Hay otra pieza que Room KMP requiere y que no existe en la versión Android clásica: @ConstructedBy. En Android, Room usaba reflexión para instanciar la base de datos. En KMP, la reflexión no está disponible en iOS, así que Room necesita saber en tiempo de compilación cómo construirla.
La solución es el patrón expect/actual de Kotlin:
// commonMain — se escribe manualmente
@Suppress("KotlinNoActualForExpect", "EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
expect object GasGuruDatabaseConstructor : RoomDatabaseConstructor<GasGuruDatabase> {
override fun initialize(): GasGuruDatabase
}
@Database(entities = [...], version = 17)
@ConstructedBy(GasGuruDatabaseConstructor::class)
abstract class GasGuruDatabase : RoomDatabase() { ... }
El actual no se escribe. Room KSP lo genera durante la compilación para cada target activo:
// androidMain — generado por KSP
actual object GasGuruDatabaseConstructor : RoomDatabaseConstructor<GasGuruDatabase> {
override fun initialize(): GasGuruDatabase = GasGuruDatabase_Impl()
}
// iosMain — generado por KSP (para cada target iOS declarado)
actual object GasGuruDatabaseConstructor : RoomDatabaseConstructor<GasGuruDatabase> {
override fun initialize(): GasGuruDatabase = GasGuruDatabase_Impl()
}
El @Suppress("KotlinNoActualForExpect") silencia el warning de Kotlin de que no hay actual en src/ — porque existe en build/generated/ksp/. Sin el ksp* correspondiente por cada target, Room no generará el actual para esa plataforma y la compilación fallará.
⚠️ En iOS, el DatabaseModule usa NSFileManager para construir el path de la base de datos, ya que no existe Context:
// iosMain/di/DatabaseModule.kt
val dbPath = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)!!.path + "/fuel-pump-database"
Room.databaseBuilder<GasGuruDatabase>(name = dbPath)
.addMigrations(...)
.build()
Cada plataforma tiene su DatabaseModule propio; la base de datos compartida (entidades, DAOs, migraciones) vive en commonMain.
Resumen
| Source set | ¿Funciona? | Motivo |
|---|---|---|
commonTest | ❌ | inMemoryDatabaseBuilder<T>() no compila en target Android |
androidUnitTest | ❌ | BundledSQLiteDriver falla en runtime con UnsatisfiedLinkError |
jvmTest | ✅ | API correcta + binario nativo del host |