DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’» is a community of 966,904 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Create account Log in
JΓ©rΓ©my CROS for KeyOpsTech

Posted on

Unit test a room migration on Android

If you are using Room for your Android App database, chances are you've had to write a migration script at some point.

Something very simple like:

val MIGRATION_27_28 = object : Migration(27, 28) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE User ADD COLUMN Company TEXT default 'Google'")
    }
}

The real beauty in those scripts though is that they are fully unit testable.
This article will show you step by step how to proceed.

The basic idea of how such a test will be structured is:

  1. Create a database at the previous version
  2. Insert the entity, complying to the previous version's schema
  3. Run the migration script
  4. Open the database, read the inserted entity and check the changes were properly applied

The first step is to add our test file. Because we're going to need to use an actual database on an emulator, it will be an instrumented unit test so let's start by creating our test class under the androidTest folder.

In our class, let's add an helper, part of the androidx.room.testing package as well as a constant for our database name.

private val testDatabase = "migration-test"

@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
    InstrumentationRegistry.getInstrumentation(),
    MyRoomDatabase::class.java.canonicalName,
    FrameworkSQLiteOpenHelperFactory()
)

This helper will do all the migration work as well as well as validating the new database.
Except for the default value which we will have to test manually but we will come back to that later.

Now, a tricky part. If you remember point 2, we need to insert an entity at the previous version.
That means we cannot simply use our usual DAO classes to get the job done, the insertion would fail because we would miss our new field.
The only alternative is to build the object from scratch which is a bit cumbersome but the end result is worth it.

A good way to keep the code clean would be to have a function for that:

private fun buildUserToInsert_V27(): ContentValues {
    val user = ContentValues()
    user.put("id", "1")
    user.put("first_name", "john")
    user.put("last_name", "doe")
    user.put("age", "33")
    return user
}

I personally like to use an "Arrange / Act / Assert" way of organising my tests and the arrange part is now ready :)

// Arrange
val previousVersionUser = buildUserToInsert_V27()

helper.createDatabase(testDatabase, 27).apply {
    insert("User", SQLiteDatabase.CONFLICT_REPLACE, previousVersionUser)
    close()
}

Let's run our migration script:

// Act
helper.runMigrationsAndValidate(testDatabase, 28, true, MIGRATION_27_28)

All that's left is to check that our migrated user has the correct default company.
We need to open the migrated database. Again, nicely tucked in a function as it will be reused for all our migrations tests:

private fun getMigratedRoomDatabase(): MyRoomDatabase {
    return Room.databaseBuilder(
        InstrumentationRegistry.getInstrumentation().targetContext,
        MyRoomDatabase::class.java,
        testDatabase
    ).addMigrations(
        MIGRATION_27_28
    ).build().apply {
        openHelper.writableDatabase
        close()
    }
}

We now have access to our handy DAOs and the assert part is pretty straightforward:

// Assert
val migratedDatabase = getMigratedRoomDatabase()
val migratedUser = migratedDatabase.userDao().getById(1)
assertEquals("Google", migratedUser.company)

All done! :)
As you can see, there's unfortunately a fair bit of boilerplate to set up everything.
Good thing is, most of it is only done for the first test you'll write and can be reused for all subsequent tests.

The end result for our test class:

class MigrationsTest {
    private val testDatabase = "migration-test"

    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        MyRoomDatabase::class.java.canonicalName,
        FrameworkSQLiteOpenHelperFactory()
    )

    @Test
    @Throws(IOException::class)
    fun migrate27To28() {
        // Arrange
        val previousVersionUser = buildUserToInsert_V27()

        helper.createDatabase(testDatabase, 27).apply {
            insert("User", SQLiteDatabase.CONFLICT_REPLACE, previousVersionUser)
            close()
        }

        // Act
        helper.runMigrationsAndValidate(testDatabase, 28, true, MIGRATION_27_28)

        // Assert
        val migratedDatabase = getMigratedRoomDatabase()
        val migratedUser = migratedDatabase.userDao().getById(1)
        assertEquals("Google", migratedUser.company)
    }

    private fun getMigratedRoomDatabase(): MyRoomDatabase {
        return Room.databaseBuilder(
            InstrumentationRegistry.getInstrumentation().targetContext,
            MyRoomDatabase::class.java,
            testDatabase
        ).addMigrations(
            MIGRATION_27_28
        ).build().apply {
            openHelper.writableDatabase
            close()
        }
    }

    private fun buildUserToInsert_V27(): ContentValues {
        val user = ContentValues()
        user.put("id", "1")
        user.put("first_name", "john")
        user.put("last_name", "doe")
        user.put("age", "33")
        return user
    }
}

Leave me a question if anything is unclear and happy unit testing :)
And a big thanks to my teammates for reviewing this article!

Top comments (1)

Collapse
 
eclipsekio profile image
Guoguang Mao

Version 28 also seems to use SQL instead of Dao,otherwise, the test cannot be used during the next upgrade

🌚 Friends don't let friends browse without dark mode.

Sorry, it's true.