Testing Migrations

It would be nice if your migrations worked. Users, in particular, appreciate working code… or, perhaps more correctly, they get rather angry with non-working code.

Hence, you might want to test the migrations.

This gets a bit tricky, though. The code-generated Room classes are expecting the latest-and-greatest schema version, so you cannot use your DAO for testing older schemas. Besides, RoomDatabase.Builder wants to set up your database with that latest-and-greatest schema automatically.

Fortunately, Room ships with some testing code to help you test your schemas in isolation… though you bypass most of Room to do that.

Adding the Artifact

This testing code is in a separate androidx.room:room-testing artifact, one that you can add via androidTestCompile to put in your instrumentation tests but leave out of your production code:

dependencies {
  implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
  implementation "androidx.arch.core:core-runtime:2.1.0"
  implementation "androidx.room:room-runtime:$room_version"
  implementation "androidx.room:room-ktx:$room_version"
  kapt "androidx.room:room-compiler:$room_version"
  androidTestImplementation 'androidx.test:runner:1.4.0'
  androidTestImplementation "androidx.test.ext:junit:1.1.3"
  androidTestImplementation "androidx.arch.core:core-testing:2.1.0"
  androidTestImplementation "androidx.room:room-testing:$room_version"
  androidTestImplementation "com.natpryce:hamkrest:1.7.0.0"
}

Adding the Schemas

Remember those exported schemas? While we used them for helping us write the migrations, their primary use is for this testing support code.

By default, those schemas are stored outside of anything that goes into your app. After all, you do not need those JSON files cluttering up your production builds. However, this also means that those schemas are not available to your test code, by default.

However, we can fix that, by adding those schemas to the assets/ used in the androidTest source set, by having this closure in your android closure of your module’s build.gradle file:

  sourceSets {
    androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
  }

Here, "$projectDir/schemas".toString() is the same value that we used for the room.schemaLocation annotation processor argument. This snippet tells Gradle to include the contents of that schemas/ directory as part of our assets/.

The result is that our instrumentation test APK will have those directories named after our RoomDatabase classes (e.g., com.commonsware.room.migration.NoteDatabase/) in the root of assets/. If you have code that uses assets/, make sure that you are taking steps to ignore these extra directories.

Creating a MigrationTestHelper

The testing support comes in the form of a MigrationTestHelper that you can employ in your instrumentation tests.

MigrationTestHelper is a JUnit4 rule, which you add to your test case class via the @Rule annotation:

  @get:Rule
  val migrationTestHelper = MigrationTestHelper(
    InstrumentationRegistry.getInstrumentation(),
    NoteDatabase::class.java.canonicalName
  )

The MigrationTestHelper constructor takes two parameters, both of which are a bit unusual.

First, it takes an Instrumentation object. We use those in our test code, but it is rare that we pass them as a parameter. You get your Instrumentation by calling getInstrumentation() on the InstrumentationRegistry.

Then, it takes what appears to be the fully-qualified class name of the RoomDatabase whose migrations we wish to test. Technically speaking, this is actually the relative path, inside of assets/, where the schema JSON files are for this particular RoomDatabase. Given the above configuration, each database’s schemas are put into a directory named after the fully-qualified class name of the RoomDatabase, which is why this works. However, if you change the configuration to put the schemas somewhere else in assets/, you would need to modify this parameter to match.

Creating a Database for a Schema Version

There are two main methods on MigrationTestHelper that we will use in testing. One is createDatabase(). This creates the database, as a specific database file, for a specific schema version… including any of our historical ones found in those schema JSON files. Here, we ask the helper to create a database named DB_NAME for schema version 1:

    val initialDb = migrationTestHelper.createDatabase(DB_NAME, 1)

This gives you a SupportSQLiteDatabase, not a Room database (e.g., NoteDatabase). That is because you may be using a historical schema version, and the Room-generated code only exists for the most recent schema version.

As part of testing a migration, you will need to add some sample data to the database, using whatever schema you asked to be used, so that you can confirm that the migration worked as expected and did not wreck the existing data. This code will not be very Room-ish, but more like classic SQLite Android programming:

    val initialDb = migrationTestHelper.createDatabase(DB_NAME, 1)

    initialDb.execSQL(
      "INSERT INTO notes (id, title, text, version) VALUES (?, ?, ?, ?)",
      arrayOf(TEST_ID, TEST_TITLE, TEST_TEXT, TEST_VERSION)
    )

    initialDb.query("SELECT COUNT(*) FROM notes").use {
      assertThat(it.count, equalTo(1))
      it.moveToFirst()
      assertThat(it.getInt(0), equalTo(1))
    }

    initialDb.close()

Testing a Migration

The other method of note on MigrationTestHelper is runMigrationsAndValidate(). After you have set up a database in its starting conditions via createDatabase() and CRUD operations, runMigrationsAndValidate() will migrate that database from its original schema version to the one that you specify:

    val db = migrationTestHelper.runMigrationsAndValidate(
      DB_NAME,
      2,
      true,
      MIGRATION_1_2
    )

You need to supply the same database name (DB_NAME), a higher schema version (2), and the specific Migration that you want to use (MIGRATION_1_2).

Not only does this method perform the migration, but it validates the resulting schema against what the entities have set up for that schema version, based on the schema JSON files. If there is something wrong — your migration forgot a newly-added column, for example — your test will fail with an assertion violation. The true parameter shown above determines whether this schema validation will be checked for un-dropped tables. true means that if you have unnecessary tables in the database, the test fails; false means that unnecessary tables are fine and will be ignored.

However, all MigrationTestHelper can do is confirm that you set up the new schema properly and give you a SupportSQLiteDatabase representing the migrated database. It cannot determine whether the data is any good after the migration. That you would need to test yourself:

    val db = migrationTestHelper.runMigrationsAndValidate(
      DB_NAME,
      2,
      true,
      MIGRATION_1_2
    )

    db.query("SELECT id, title, text, version, andNowForSomethingCompletelyDifferent FROM notes")
      .use {
        assertThat(it.count, equalTo(1))
        it.moveToFirst()
        assertThat(it.getInt(0), equalTo(TEST_ID))
        assertThat(it.getString(1), equalTo(TEST_TITLE))
        assertThat(it.getString(2), equalTo(TEST_TEXT))
        assertThat(it.getInt(3), equalTo(TEST_VERSION))
        assertThat(it.getString(4), absent())
      }

In many cases, there is little to test, particularly if you are just setting up empty tables as we are doing in this migration. However, if you had a complex table change, perhaps requiring a temp table and statements like INSERT INTO ... SELECT FROM ..., you could write test code that confirms the data is OK. However, as shown above, you cannot use the Room DAO for this either. Instead, you will use the SupportSQLiteDatabase and work with the tables “the old-fashioned way”, using query() and Cursor and similar constructs.


Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.