Skip to content
All posts
March 12, 20264 min read

Testing Android Room Database: A Complete Guide with Kotlin

Room database logic is often undertested because developers don't know where to start. Here's how to write comprehensive tests for DAOs, migrations, and complex queries using JUnit and Room's testing utilities.

AndroidDataKotlinTesting
Share:

Database logic is some of the most important code in your app — and some of the most undertested. When a Room query returns the wrong data or a migration corrupts the database, users lose data. That's unrecoverable trust damage.

Here's how to test it properly.


The Setup: In-Memory Test Database

Room's in-memory database builder creates a database that exists only for the duration of the test, making tests fast and isolated:

kotlin
@RunWith(AndroidJUnit4::class)
class TaskDaoTest {
    private lateinit var db: AppDatabase
    private lateinit var taskDao: TaskDao
    
    @Before
    fun createDb() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
            .allowMainThreadQueries() // Only for tests!
            .build()
        taskDao = db.taskDao()
    }
    
    @After
    fun closeDb() {
        db.close()
    }
}

code
allowMainThreadQueries()
is safe in tests because tests run in a controlled environment. Never use it in production code.


Testing Basic CRUD

kotlin
@Test
fun insertAndRetrieveTask() = runTest {
    val task = TaskEntity(
        id = "task-1",
        title = "Buy groceries",
        completed = false,
        createdAt = System.currentTimeMillis()
    )
    
    taskDao.insert(task)
    
    val retrieved = taskDao.getById("task-1")
    assertNotNull(retrieved)
    assertEquals("Buy groceries", retrieved!!.title)
    assertFalse(retrieved.completed)
}

@Test
fun updateTask() = runTest {
    val task = TaskEntity(id = "task-1", title = "Original", completed = false, createdAt = 0)
    taskDao.insert(task)
    
    taskDao.update(task.copy(completed = true))
    
    val updated = taskDao.getById("task-1")
    assertTrue(updated!!.completed)
}

@Test
fun deleteTask() = runTest {
    val task = TaskEntity(id = "task-1", title = "Delete me", completed = false, createdAt = 0)
    taskDao.insert(task)
    
    taskDao.delete("task-1")
    
    val result = taskDao.getById("task-1")
    assertNull(result)
}

Testing Queries and Filters

kotlin
@Test
fun getAllActiveTasks_returnsOnlyIncompleteTasks() = runTest {
    taskDao.insert(TaskEntity("1", "Active task 1", completed = false, createdAt = 1))
    taskDao.insert(TaskEntity("2", "Active task 2", completed = false, createdAt = 2))
    taskDao.insert(TaskEntity("3", "Completed task", completed = true, createdAt = 3))
    
    val activeTasks = taskDao.getActiveTasks()
    
    assertEquals(2, activeTasks.size)
    assertTrue(activeTasks.all { !it.completed })
}

@Test
fun getTasksByDateRange() = runTest {
    val base = 1_000_000L
    taskDao.insert(TaskEntity("1", "Too early", completed = false, createdAt = base - 1000))
    taskDao.insert(TaskEntity("2", "In range", completed = false, createdAt = base + 500))
    taskDao.insert(TaskEntity("3", "Too late", completed = false, createdAt = base + 2000))
    
    val result = taskDao.getTasksBetween(base, base + 1000)
    
    assertEquals(1, result.size)
    assertEquals("2", result.first().id)
}

Testing Flow Queries (Reactive)

Room queries that return

code
Flow
need special handling in tests:

kotlin
@Test
fun taskListFlow_emitsUpdatesOnInsert() = runTest {
    val flowResult = mutableListOf<List<TaskEntity>>()
    
    // Collect emissions
    val job = launch {
        taskDao.getAllTasksFlow().collect { tasks ->
            flowResult.add(tasks)
        }
    }
    
    // Insert triggers a new emission
    taskDao.insert(TaskEntity("1", "New task", false, 0))
    
    advanceUntilIdle()
    job.cancel()
    
    assertTrue(flowResult.size >= 2) // Initial empty + after insert
    assertTrue(flowResult.last().any { it.id == "1" })
}

Or use Turbine for cleaner Flow testing:

kotlin
@Test
fun taskListFlow_emitsUpdatesOnInsert() = runTest {
    taskDao.getAllTasksFlow().test {
        // Initial emission
        assertEquals(0, awaitItem().size)
        
        // Insert task
        taskDao.insert(TaskEntity("1", "New task", false, 0))
        
        // Flow emits updated list
        val updated = awaitItem()
        assertEquals(1, updated.size)
        assertEquals("New task", updated.first().title)
        
        cancelAndIgnoreRemainingEvents()
    }
}

Testing Room Migrations

This is where most teams fall short. Every database migration should have a test:

kotlin
@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"
    
    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java
    )
    
    @Test
    fun migrate1To2() {
        // Create version 1 database
        helper.createDatabase(TEST_DB, 1).apply {
            execSQL("INSERT INTO tasks VALUES ('task-1', 'Old task', 0)")
            close()
        }
        
        // Run migration to version 2
        val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
        
        // Verify migrated data is intact
        val cursor = db.query("SELECT * FROM tasks WHERE id = 'task-1'")
        assertTrue(cursor.moveToFirst())
        assertEquals("Old task", cursor.getString(cursor.getColumnIndexOrThrow("title")))
        
        // Verify new column exists with default value
        val priorityColumnIndex = cursor.getColumnIndex("priority")
        assertTrue(priorityColumnIndex >= 0)
        assertEquals(0, cursor.getInt(priorityColumnIndex)) // Default value
        
        cursor.close()
    }
}

Migration tests are the most critical database tests. A failed migration corrupts all user data on update.


Testing Transactions

kotlin
@Test
fun transactionRollbackOnError() = runTest {
    val taskWithDependency = TaskEntity("1", "Parent task", false, 0)
    taskDao.insert(taskWithDependency)
    
    try {
        db.withTransaction {
            taskDao.update(taskWithDependency.copy(completed = true))
            throw IllegalStateException("Simulated error")
        }
    } catch (e: Exception) {
        // Expected
    }
    
    // Transaction should have rolled back — task should still be incomplete
    val task = taskDao.getById("1")
    assertFalse(task!!.completed)
}

Test Data Builders

For complex entities, use builder functions to avoid boilerplate:

kotlin
fun buildTask(
    id: String = UUID.randomUUID().toString(),
    title: String = "Test task",
    completed: Boolean = false,
    createdAt: Long = System.currentTimeMillis()
) = TaskEntity(id = id, title = title, completed = completed, createdAt = createdAt)

// Usage — only specify what matters for each test
taskDao.insert(buildTask(title = "Specific title for this test"))
taskDao.insert(buildTask(completed = true))

Takeaways

  • Use Room's in-memory database builder for isolated, fast tests
  • Always test migration — a broken migration corrupts all user data on update
  • Use Turbine for clean Flow testing — it eliminates coroutine timing complexity
  • Test data builders reduce boilerplate and make tests focus on what matters
  • Never use
    code
    allowMainThreadQueries()
    in production — only in tests
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this