Android Room database migration and testing in Kotlin
08 June, 2020
The Android Room database is a popular choice for mobile apps. For the planned improvement for the StockTicker app, the database needs to be upgraded from version 1 to 2. This requires a migration path with a test case for the upgrade from version 1 to version 2.
To get started, the migration test is using the Android migration test helper. It is best to start with the test cases first to ensure a proper data base migration.
@RunWith(RobolectricTestRunner::class)
class MigrationTest {
private val TEST_DB = "quotes-db"
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
QuotesDB::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
The test case first creates a database with version 1 and adds sample values, either using the ContentValues, or by using the SQL INSERT statement.
@Test
@Throws(IOException::class)
fun migrate1To2() {
helper.createDatabase(TEST_DB, 1)
.apply {
val values = ContentValues()
values.put("symbol", "ktln")
values.put("name", "kotlin inc")
values.put("last_trade_price", 42)
values.put("change_percent", 4.2)
values.put("change", 1.764)
values.put("exchange", "NYSE")
values.put("currency", "EURO")
values.put("description", "desc")
this.insert("QuoteRow", SQLiteDatabase.CONFLICT_REPLACE, values)
this.execSQL(
"INSERT INTO QuoteRow (symbol, name, last_trade_price, change_percent, change, exchange, currency, description) VALUES ('a1', '123', 1.2, 1.1, 1.0, 'e1', 'Euro', 'desc1');"
)
this.execSQL(
"INSERT INTO QuoteRow (symbol, name, last_trade_price, change_percent, change, exchange, currency, description) VALUES ('a2', '456', 2.2, 2.1, 2.0, 'e2', 'Euro', 'desc2');"
)
this.execSQL("CREATE TABLE IF NOT EXISTS `PropertiesRow` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `quote_symbol` TEXT NOT NULL, `notes` TEXT NOT NULL, `alert_above` REAL NOT NULL, `alert_below` REAL NOT NULL)")
// Prepare for the next version.
this.close()
}
Once the database is created, the migration test runs the migration path to version 2.
// Re-open the database with version 2 and provide
// MIGRATION_1_2 as the migration process.
val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
The next step is to test the content of the converted database.
// MigrationTestHelper automatically verifies the schema changes,
// but you need to validate that the data was migrated properly.
val cursor = db.query("SELECT * FROM QuoteRow")
cursor.moveToFirst()
// Expect 10 columns
assertThat(cursor.columnCount, Matchers.equalTo(10))
// Expect 3 entries
assertThat(cursor.count, Matchers.equalTo(3))
var stringEntry: String = ""
var floatEntry: Float = 0.0f
stringEntry = cursor.getString(cursor.getColumnIndex("symbol"))
assertThat(stringEntry, Matchers.equalTo("ktln"))
stringEntry = cursor.getString(cursor.getColumnIndex("name"))
assertThat(stringEntry, Matchers.equalTo("kotlin inc"))
floatEntry = cursor.getFloat(cursor.getColumnIndex("last_trade_price"))
assertThat(floatEntry, Matchers.equalTo(42f))
floatEntry = cursor.getFloat(cursor.getColumnIndex("change_percent"))
assertThat(floatEntry, Matchers.equalTo(4.2f))
floatEntry = cursor.getFloat(cursor.getColumnIndex("change"))
assertThat(floatEntry, Matchers.equalTo(1.764f))
stringEntry = cursor.getString(cursor.getColumnIndex("exchange"))
assertThat(stringEntry, Matchers.equalTo("NYSE"))
stringEntry = cursor.getString(cursor.getColumnIndex("currency"))
assertThat(stringEntry, Matchers.equalTo("EURO"))
// description column is removed
val index = cursor.getColumnIndex("description")
assertThat(index, Matchers.equalTo(-1))
}
The actual migration is done by the creating a table of the new version, copying over the content from the table of the old version.
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
val TABLE_NAME = "QuoteRow"
val TABLE_NAME_TEMP = "new_QuoteRow"
database.execSQL(
"""
CREATE TABLE `${TABLE_NAME_TEMP}` (
symbol TEXT NOT NULL,
name TEXT NOT NULL,
last_trade_price REAL NOT NULL,
change_percent REAL NOT NULL,
change REAL NOT NULL,
exchange TEXT NOT NULL,
currency TEXT NOT NULL,
is_post_market INTEGER NOT NULL,
annual_dividend_rate REAL NOT NULL,
annual_dividend_yield REAL NOT NULL,
PRIMARY KEY(symbol)
)
""".trimIndent()
)
database.execSQL(
"""
INSERT INTO `${TABLE_NAME_TEMP}` (symbol, name, last_trade_price, change_percent, change, exchange, currency, is_post_market, annual_dividend_rate, annual_dividend_yield)
SELECT symbol, name, last_trade_price, change_percent, change, exchange, currency, 0, 0, 0 FROM `${TABLE_NAME}`
""".trimIndent()
)
database.execSQL("DROP TABLE `${TABLE_NAME}`")
database.execSQL("ALTER TABLE `${TABLE_NAME_TEMP}` RENAME TO `${TABLE_NAME}`")
database.execSQL("CREATE TABLE IF NOT EXISTS `PropertiesRow` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `quote_symbol` TEXT NOT NULL, `notes` TEXT NOT NULL, `alert_above` REAL NOT NULL, `alert_below` REAL NOT NULL)")
}
}
}
The migration function is then used by the database builder to perform the upgrade.
return Room.databaseBuilder(
context.applicationContext,
QuotesDB::class.java, "quotes-db")
.addMigrations(MIGRATION_1_2)
.build()
To ensure you have the latest schema available, add exportSchema = true
to the Database definition.
@Database(
entities = [QuoteRow::class, HoldingRow::class, PropertiesRow::class], version = 2,
exportSchema = true
)
If you run into the problem that the automatic generated schema is not found while the test is run, add this to the app/build.gradle.
sourceSets {
debug.assets.srcDirs += files("$projectDir/schemas".toString())
}