Saltar al contenido
K
Favoritos con RoomPaso a paso33 min Avanzado

Probar la migración

Al terminar: Demostrar con un test que la base migra sin perder los favoritos y ver qué pasa si falta cada pieza.

La base ya está en la versión 2 y declara cómo llegar hasta ella, pero nadie ha comprobado que funcione. Esa es la parte peligrosa de una migración: compila, los demás tests pasan y el fallo solo aparece en el teléfono de quien ya tenía la app. Al terminar esta lección habrá un test que se comporta como esa persona, habrás visto el error que evita y tus tres favoritos sobrevivirán a una instalación encima de la anterior.

Cómo se prueba una migración

Un test normal crea las cosas como son hoy. Una migración no se puede probar así: hay que crear la base como era antes, meterle una fila de aquella época, hacerla migrar y leer lo que queda. Es el guion de lo que le pasa a una persona real, y tiene cuatro partes:

  1. abrir una base vacía en la versión 1;
  2. insertar una fila con SQL de la versión 1;
  3. pedirle a Room que la lleve a la versión 2;
  4. leer la fila y comprobar que sigue ahí y qué hay en la columna nueva.

Antes de verlo en Kotlin, el mismo guion en SQL, con la tabla real de favoritos. Es un ejemplo solo para leer: no tienes que ejecutarlo. CREATE TABLE crea una tabla con sus columnas, INTEGER y TEXT son los tipos de columna (número entero y texto) y PRIMARY KEY marca la columna que identifica cada fila, que no se repite. Lo que sigue es lo que imprimiría sqlite3, el motor que usa Room, si guardaras el ejemplo en un fichero y lo ejecutaras con sqlite3 -header -column :memory: < favoritos.sql (esas dos opciones piden los nombres de columna y la salida alineada):

ejemplo mínimo: el guion de una migración en SQL (con comentarios)
-- 1. La tabla tal como era en la versión 1: sin la columna del tipo.
CREATE TABLE favorite (pokemonId INTEGER NOT NULL, name TEXT NOT NULL, addedAt INTEGER NOT NULL, PRIMARY KEY(pokemonId));
 
-- 2. Una fila de aquella época: Pikachu, guardado cuando no había tipo.
INSERT INTO favorite (pokemonId, name, addedAt) VALUES (25, 'pikachu', 1000);
SELECT pokemonId, name FROM favorite;
 
-- 3. La migración: lo que Room escribe por ti con AutoMigration.
ALTER TABLE favorite ADD COLUMN primaryType TEXT NOT NULL DEFAULT '';
 
-- 4. Leer: la fila sigue y la columna nueva trae el valor por defecto.
SELECT pokemonId, name, primaryType FROM favorite;

Lo que imprime:

pokemonId  name   
---------  -------
25         pikachu
pokemonId  name     primaryType
---------  -------  -----------
25         pikachu             

Pikachu sigue ahí y la columna nueva está vacía, que es exactamente lo que dirá el test de Kotlin, con Room haciendo el paso 3.

gradle/libs.versions.tomlse modifica (paso 1)
shared
build.gradle.ktsse modifica (paso 1)
src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/local/MigrationTest.ktse crea (pasos 2 y 3)
Dos ficheros de Gradle cambian y uno de tests se crea (módulo shared).

Paso 1 · Instala room3-testing

MigrationTestHelper, el ayudante que abre una base en una versión vieja y aplica la migración, viene en una librería de pruebas que aún no has instalado: room3-testing. Es una dependencia de solo tests de la JVM, como la koin-test que instalaste en Koin: un solo grafo en commonMain: no viaja con la app.

1. El catálogo. Abre gradle/libs.versions.toml y, al final de [libraries] (sin repetir la cabecera), añade la línea. Reutiliza la versión room, porque las librerías de Room van siempre a la misma:

gradle/libs.versions.toml
androidx-room-testing = { module = "androidx.room3:room3-testing", version.ref = "room" }

2. El módulo. Abre shared/build.gradle.kts y, dentro de jvmTest.dependencies { … } (junto a koin-test; si no lo ves, está dentro de sourceSets { … }), añade:

shared/build.gradle.kts
            implementation(libs.androidx.room.testing)   // MigrationTestHelper, solo para tests de la JVM

3. Sync Now. Sin rojo (si sale en rojo, mira la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto). El error típico: si escribes androidx.room:room-testing (sin el 3), Gradle no encuentra la librería, porque Room 3 vive en el grupo androidx.room3.

Paso 2 · El test

El archivo. Se crea. En el panel (vista Project), clic derecho sobre shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/local (la carpeta donde ya tienes FavoriteDaoTest) → New → Kotlin Class/File → File, y el nombre MigrationTest. Vive en jvmTest porque MigrationTestHelper lee los JSON de schemas por una ruta de fichero relativa a la carpeta del módulo (lo verás en el paso 3), y los tests de la JVM se ejecutan desde esa carpeta; los del simulador de iOS no. La migración es la misma en las tres plataformas, porque el motor va incrustado (BundledSQLiteDriver). Primero, el ayudante, que necesita tres cosas: la carpeta de esquemas, un fichero temporal y el driver (el «conductor» que habla con SQLite). Escribe todo el fichero, y en el paso siguiente añadirás el test dentro de la clase:

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/local/MigrationTest.kt
package com.sebastiannarvaez.pokedex.data.local
 
import androidx.room3.testing.MigrationTestHelper
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.sqlite.execSQL
import kotlin.io.path.Path
import kotlin.io.path.createTempDirectory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.coroutines.test.runTest
 
/**
 * El unico test que simula al usuario que ya tenia la app instalada.
 *
 * Abre una base de datos en la version 1 con una fila dentro, aplica la
 * migracion y comprueba que la fila sigue ahi. Es lo que separa una migracion
 * que compila de una que no borra los favoritos de nadie.
 *
 * Vive en `jvmTest` porque `MigrationTestHelper` necesita rutas de fichero y en
 * el simulador de iOS eso se complica. La migracion es la misma en las tres
 * plataformas: el motor va incrustado.
 */
class MigrationTest {
 
    // Crea una carpeta temporal para el fichero de la base de prueba.
    private val temporal = createTempDirectory("pokedex-migracion")
 
    private val helper = MigrationTestHelper(
        // El directorio donde estan los esquemas exportados. Si no se
        // guardaran en el proyecto, este test no podria existir.
        schemaDirectoryPath = Path("schemas"),
        databasePath = temporal.resolve("migracion.db"),   // `resolve` une la carpeta y el nombre del fichero
        driver = BundledSQLiteDriver(),
        databaseClass = PokedexDatabase::class,
    )
}

El schemaDirectoryPath = Path("schemas") es relativo a la carpeta del módulo shared, que es donde Gradle ejecuta los tests de la JVM: por eso encuentra los JSON sin más.

Paso 3 · El guion, línea a línea

Y el test es el guion de arriba en Kotlin. Añádelo dentro de la clase, antes de su última llave:

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/local/MigrationTest.kt
    @Test
    fun losFavoritosSobrevivenALaVersionDos() = runTest {
        // Version 1: la tabla todavia no tiene la columna del tipo.
        // `use` abre la conexión y la cierra sola al terminar el bloque.
        helper.createDatabase(version = 1).use { conexion ->
            // execSQL ejecuta una sentencia SQL tal cual, sin esperar filas.
            conexion.execSQL(
                "INSERT INTO favorite (pokemonId, name, addedAt) VALUES (25, 'pikachu', 1000)",
            )
        }
 
        // Room aplica la migracion automatica y valida que el esquema resultante
        // coincide exactamente con el que declara la version 2.
        helper.runMigrationsAndValidate(version = 2).use { conexion ->
            // prepare compila la consulta; `fila` es esa consulta y apunta a la
            // fila que toca leer del resultado.
            conexion.prepare("SELECT pokemonId, name, primaryType FROM favorite").use { fila ->
                // step() avanza a la fila siguiente y dice si había una; check()
                // lanza un error con este mensaje si la condición es falsa.
                check(fila.step()) { "la fila desaparecio en la migracion" }
                assertEquals(25, fila.getInt(0))         // columna 0: pokemonId
                assertEquals("pikachu", fila.getText(1)) // columna 1: name
                // La columna nueva llega con el valor por defecto: sin
                // `defaultValue`, Room no habria podido generar la migracion.
                assertEquals("", fila.getText(2))
            }
        }
    }

Fíjate en el INSERT a mano. No se usa el DAO, porque el DAO de hoy conoce una columna que en la versión 1 no existía: la fila se mete con SQL de la época, que es justo lo que hay en el teléfono de otra persona.

Ejecútalo desde la Terminal de Android Studio, que se abre en la carpeta de tu proyecto:

./gradlew :shared:jvmTest --tests "*MigrationTest*"

Debe terminar en BUILD SUCCESSFUL con 1 test. Con ./gradlew :shared:jvmTest entero pasan también los tests de favoritos de lecciones anteriores.

Qué pasa si falta una de las tres piezas

Una migración automática necesita tres cosas a la vez: subir la versión, declarar AutoMigration(from, to) y conservar 1.json tal como estaba. Ya viste qué pasa sin 1.json: un error al compilar. Así quedan los otros dos casos:

Sin la migración declarada (versión 2, columna nueva, sin autoMigrations): compila sin una queja y MigrationTest falla.

java.lang.IllegalStateException: A migration from 1 to 2 was required but not found. Please provide the necessary Migration path via RoomDatabase.Builder.addMigration(...) or allow for destructive migrations via one of the RoomDatabase.Builder.fallbackToDestructiveMigration* functions.

En la Terminal, Gradle solo enseña el nombre de la excepción; el mensaje entero está en el informe de tests: abre shared/build/reports/tests/jvmTest/index.html (la última línea de la salida de Gradle da el enlace), entra en la clase MigrationTest y después en el test que falla, o ejecuta el test con el triángulo verde junto a @Test y mira el panel de tests de Android Studio. Provócalo si quieres: quita autoMigrations = [AutoMigration(from = 1, to = 2)] de @Database, ejecuta el test y vuelve a poner la línea. En un teléfono sería una app que falla al abrir la base, solo en los dispositivos que ya tenían la versión anterior (en tu máquina no lo verías: si desinstalas, la base se crea de cero). El mensaje ofrece una salida, fallbackToDestructiveMigration, que arregla el fallo borrando todos los favoritos de quien actualice: no es una migración, es perder los datos.

Sin subir la versión (version = 1 con la columna nueva): Room sobrescribe 1.json con el esquema nuevo, y ese fichero deja de ser la foto de la versión anterior. Ni el compilador ni MigrationTest te lo avisan: el test pasa, porque cuando se ejecuta 1.json ya cambió y la migración que sale de comparar dos esquemas iguales no hace nada. Qué te protege:

  • Gradle, a veces. Puede detenerse en :shared:copyRoomSchemas con Inconsistency detected exporting Room schema files … The listed files differ in content but were copied into the same schema directory, si una compilación anterior de otro destino (Android, el simulador de iOS o el iPhone) dejó allí su copia del esquema. Si no, compila sin una queja. Si se detiene, no lo silencies borrando ficheros: sube la versión.
  • Revisar 1.json, siempre. Ábrelo (Navigate → File…, 1.json) y busca createSql: el original no menciona primaryType, y si lo menciona, alguien lo sobrescribió.
  • En un teléfono con la base vieja saldría Room cannot verify the data integrity. Looks like you've changed schema but forgot to update the version number (Room añade detrás la huella esperada y la encontrada).

No hace falta que pruebes este caso; si lo haces, antes copia 1.json en la carpeta de tu proyecto (con cp, que copia un fichero) y al terminar devuelve tu copia a su sitio y borra la carpeta shared/build (o ejecuta ./gradlew clean): con devolver la copia puede no bastar, porque en shared/build puede quedar una versión mala de 1.json y Room la copiaría encima en la siguiente compilación.

Paso 4 · Instala encima, sin desinstalar

Un test que pasa en la JVM no es lo mismo que una instalación encima de la anterior. Ejecuta de nuevo la app de Android (configuración androidApp) sin desinstalar: Android actualiza la instalación y conserva los datos. Tus tres favoritos de la lección anterior siguen ahí, y salen sin punto de color y sin nombre de tipo: la columna nueva llegó vacía, que es lo que dice el valor por defecto. Desmárcalos y márcalos otra vez con el corazón de la tarjeta, en la pestaña Pokédex: ahora el tipo aparece. (Si en vez de eso los quitas deslizando en Favoritos y pulsas «Deshacer», vuelven tal como estaban, sin tipo.)

Eso es lo que hay que entender de las migraciones: la migración no rellena hacia atrás. Guarda un hueco con la forma correcta y el dato llega cuando alguien lo vuelve a escribir. Si quisieras rellenarlo, tendrías que pedir el detalle de cada favorito antiguo, con red, y decidir qué hacer si no hay.

Punto de control

  • ./gradlew :shared:jvmTest --tests "*MigrationTest*" termina en BUILD SUCCESSFUL con 1 test.
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 52 tests en la JVM, 40 en Android host y 42 en iOS, sin contar los de la carpeta ejercicios: uno más en la JVM que al terminar la lección anterior (51 / 40 / 42), el de hoy.
  • Instalar la versión nueva encima de la anterior conserva los tres favoritos.
  • Los favoritos guardados antes de la migración se ven sin tipo, no con un tipo inventado.
  • Quitar AutoMigration(from = 1, to = 2) hace fallar MigrationTest con «A migration from 1 to 2 was required but not found».

La base de datos que acabas de dejar en la versión 2 guarda solo favoritos. La lección siguiente de este módulo la vuelve a usar con otro fin: que la lista de Pokémon también salga de ella y siga viéndose sin conexión. Empieza en La base como fuente de verdad, y es ahí donde la base pasa a la versión 3, con la misma receta que acabas de aprender.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.En MigrationTest, ¿por qué la fila de la versión 1 se inserta con SQL a mano y no con el DAO?

  2. 2.¿Qué comprueba runMigrationsAndValidate que un SELECT sobre los datos no ve?

  3. 3.Cambias el esquema y olvidas subir la versión. ¿Qué te protege antes de que el fallo llegue a un teléfono?

  4. 4.Al probar sin la migración declarada, el mensaje de Room ofrece fallbackToDestructiveMigration. ¿Por qué no es una buena solución?

  5. 5.¿Por qué MigrationTest vive en jvmTest si la base también se usa en Android e iOS?

Faltan 5 de 5