Testing Room Database (DAO Testing)

5. Testing Room Database (DAO Testing) 

What

Quick recap of Room's 3 main pieces, since this topic builds directly on them:

Room pieceWhat it is
EntityA data class marked @Entity — each one becomes a table in the database
DAO (Data Access Object)An interface where you write the queries you need — Room generates the actual implementation for you
DatabaseAn abstract class that ties everything together and gives you access to the DAO

Here's the key question this topic answers: out of all of that, what should you actually write tests for?

Not Room itself — Google has already tested that `@Insert`, `@Update`, and friends work correctly; that's the framework's job, and re-testing it wastes your time. What genuinely needs testing is the custom queries and logic you wrote yourself things like "get all courses," "delete everything," or "get a course by ID." These are your own SQL/logic, so they're exactly where your own bugs would hide.

Analogy: Room is like a filing cabinet that a professional company already built and safety-tested — you don't need to re-check that the drawers open and close properly. But the specific filing instructions you wrote ("file new records here," "remove everything matching this label") are yours, and only you can check whether those actually work as intended.

Like Espresso tests (Topic 3), these DAO tests are Instrumented Tests — they need a real environment to run against (this time, a real SQLite engine), not just the plain JVM.

Why

Custom queries are hand-written SQL/logic — exactly the kind of code most likely to contain a typo, a wrong WHERE clause, or an off-by-one mistake.

  • Your entire app's data layer sits on top of these DAO methods — if insert or delete is subtly broken, everything built above it (ViewModel, UI) inherits that bug silently.
  • Catching a broken query in a fast, isolated test is far better than discovering it later as "missing data" in production.
  • The in-memory database pattern shown here is a very common interview topic for Android roles — knowing why and how it's used is a strong signal of real testing experience.

Comparison: Real Database vs. In-Memory Database (for tests)

AspectReal / Persistent DBIn-Memory DB (for tests)
Where data livesSaved to the device's file storageOnly in RAM — never written to disk
Survives app/process restart?YesNo — gone the instant it closes
Used forActual app usageTesting only — each test gets a clean, fresh database
Created withRoom.databaseBuilder(...)Room.inMemoryDatabaseBuilder(...)

Why this matters: tests are supposed to run in isolation — one test's data shouldn't leak into another. A brand-new in-memory database, built fresh before each test and thrown away right after, guarantees that.

Mermaid Diagram

graph TD
    A[Before - build a fresh in-memory database] --> B[Test - call a DAO suspend function]
    B --> C[Get the result via getOrAwaitValue]
    C --> D[Assert the expected result]
    D --> E[After - close the database]

Code Example

We'll continue the same running example: a Course entity, matching the course app from Topic 3.

Step 1 — Entity, DAO, and Database (quick recap)

@Entity(tableName = "courses")
data class Course(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val text: String,
    val author: String
)

@Dao
interface CourseDao {

    @Insert
    suspend fun insertCourse(course: Course)

    @Update
    suspend fun updateCourse(course: Course)

    @Query("DELETE FROM courses")
    suspend fun deleteAllCourses()

    @Query("SELECT * FROM courses")
    fun getCourses(): LiveData<List<Course>>

    @Query("SELECT * FROM courses WHERE id = :courseId")
    fun getCourseById(courseId: Int): LiveData<Course>
}

@Database(entities = [Course::class], version = 1)
abstract class CourseDatabase : RoomDatabase() {
    abstract fun courseDao(): CourseDao
}

Step 2 — A small helper to read LiveData synchronously in tests

LiveData is asynchronous by nature — it hands you a value "whenever it's ready," not instantly. But a test needs to wait for that value before it can assert anything. This small, widely-used helper (originally from Google's architecture-components samples) solves exactly that:

// Usually saved in its own file, e.g. LiveDataTestUtil.kt, and reused across all your tests.
fun <T> LiveData<T>.getOrAwaitValue(
    time: Long = 2,
    timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
    var data: T? = null
    val latch = CountDownLatch(1) // a simple "waiting gate" - starts at 1

    val observer = object : Observer<T> {
        override fun onChanged(value: T) {
            data = value
            latch.countDown()   // value arrived - open the gate
            this@getOrAwaitValue.removeObserver(this)
        }
    }

    this.observeForever(observer)

    // Block right here until the gate opens, or until the timeout passes
    if (!latch.await(time, timeUnit)) {
        throw TimeoutException("LiveData value was never set.")
    }

    @Suppress("UNCHECKED_CAST")
    return data as T
}
In plain words: `CountDownLatch(1)` is a gate that starts closed, needing exactly 1 "signal" to open. We observe the LiveData; the moment it emits a value, we save it and count the gate down to 0, which opens it. `latch.await(...)` pauses the test right there — like waiting for a reply before continuing a conversation — until the gate opens, or until 2 seconds pass and it gives up with a `TimeoutException`.

Step 3 — The actual test class

@RunWith(AndroidJUnit4::class)
class CourseDaoTest {

    // Needed so LiveData/Architecture Component code runs instantly on the calling
    // thread during tests, instead of asynchronously in the background - otherwise
    // our assertions could run before the data has actually arrived.
    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    private lateinit var database: CourseDatabase
    private lateinit var dao: CourseDao

    @Before
    fun setup() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            CourseDatabase::class.java
        )
            .allowMainThreadQueries() // fine for tests: keeps execution simple and synchronous
            .build()

        dao = database.courseDao()
    }

    @After
    fun teardown() {
        database.close() // fresh database next test - nothing carries over
    }

    @Test
    fun insertCourse() = runTest {
        val course = Course(text = "Kotlin Basics", author = "Mahesh")
        dao.insertCourse(course)

        val result = dao.getCourses().getOrAwaitValue()

        assertEquals(1, result.size)
        assertEquals(course.text, result[0].text)
    }

    @Test
    fun deleteAllCourses() = runTest {
        val course = Course(text = "Kotlin Basics", author = "Mahesh")
        dao.insertCourse(course)
        dao.deleteAllCourses()

        val result = dao.getCourses().getOrAwaitValue()

        assertEquals(0, result.size) // nothing left after delete
    }
}

In plain words: setup() builds a brand-new, empty in-memory database before every single test. The insert test adds one course and checks it's really there. The delete test adds a course, deletes everything, and confirms the table is now empty — same pattern, just with the expected count changed from 1 to 0.

Quick glossary of the syntax used above

CodePlain meaning
Room.inMemoryDatabaseBuilder(context, DbClass::class.java)"Build a database that only exists in RAM — perfect for a clean test"
.allowMainThreadQueries()"Allow database queries to run on the main thread" — normally forbidden in real apps, but fine for simple, synchronous tests
@Before / @After"Run this before/after every test" — used here to build a fresh database, then close it
InstantTaskExecutorRule()"Make Architecture Component background work (like LiveData) run instantly on the calling thread," so results are ready the moment you ask for them in a test
CountDownLatch(1)"A gate that opens once exactly one signal arrives" — used to pause the test until async data shows up
getOrAwaitValue()"Block until this LiveData actually has a value, then hand it back"
runTest { }Coroutine test builder — lets you call suspend DAO functions (like insertCourse) directly inside a test

Common Mistakes

  • Forgetting InstantTaskExecutorRule — LiveData-based tests can hang or fail unpredictably without it, since Architecture Components expect a background executor that isn't really there in a plain test environment.
  • Testing against a real, persisted database instead of an in-memory one — leftover data from a previous run can silently break the next test's assertions.
  • Forgetting allowMainThreadQueries() in the test setup — Room normally throws an exception if you query from the main thread; this call is what makes the simple, synchronous test pattern above possible.
  • Forgetting to close the database in @After — can leak resources and cause odd interference between tests that shouldn't be related at all.
  • Re-testing Room's own guarantees (e.g., writing a test to prove @Insert inserts a row) — that's the framework's job and it's already tested; focus your effort on your own custom queries and logic instead.
  • Asserting on a LiveData's value without waiting for it first — since LiveData delivers data asynchronously, checking it too early gives you null or a stale value, causing a flaky, confusing failure that has nothing to do with your actual query logic.

Interview Q&A (Fresher Level)

Q: Why are Room DAO tests considered instrumented tests instead of plain unit tests?

A: Because they need a real SQLite engine to actually run queries against — that's only available through the Android runtime, not on the plain JVM that regular unit tests use.

Q: What is an in-memory database, and why use one for tests?

A: A database that lives only in RAM and is never written to disk. Using one for tests means every test starts with a completely clean, isolated database that disappears the moment it's closed — no leftover data from previous runs.

Q: What does InstantTaskExecutorRule do, and why do you need it here?

A: It forces Architecture Component background work (like LiveData updates) to execute instantly, on the calling thread, instead of asynchronously — so the value is already available the moment your test asks for it.

Q: Why is `allowMainThreadQueries()` acceptable in a test but never in production code?

A: In a real app, running database queries on the main thread would freeze the UI. In a test, there's no UI to freeze, so running queries synchronously is safe and keeps the test simple and predictable.

Q: How do you read a LiveData's value synchronously inside a test?

A: With a small helper extension function (commonly getOrAwaitValue()) that observes the LiveData and blocks — using a CountDownLatch — until a value arrives or a timeout is hit.

Q: What should you actually write tests for in a Room DAO, and what should you skip?

A: Test your own custom queries and logic (insert, update, custom @Query methods) — skip re-testing Room's built-in guarantees, since Room itself is already tested by its maintainers.