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.
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.
On this page
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.
Room's in-memory database builder creates a database that exists only for the duration of the test, making tests fast and isolated:
@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()
}
}allowMainThreadQueries()@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)
}@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)
}Room queries that return
Flow@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:
@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()
}
}This is where most teams fall short. Every database migration should have a test:
@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.
@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)
}For complex entities, use builder functions to avoid boilerplate:
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))allowMainThreadQueries()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.
Related Posts
Related Apps
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 meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read