Lesson 41 of 83 advanced

Room/Repository/Network Testing: Mocks vs Fakes

Test your data layer with confidence — in-memory databases, MockWebServer, and fake repositories

Open interactive version (quiz + challenge)

Real-world analogy

Testing your data layer is like testing a restaurant kitchen without real customers. MockWebServer is the fake food supplier — you control exactly what they deliver (good fish, spoiled fish, delayed delivery). An in-memory Room database is a temporary kitchen that resets after every service — same equipment, zero cleanup headaches. Fake repositories are the chef's training kitchen — looks and feels real but nothing actually leaves the building.

What is it?

Data layer testing covers DAOs (with in-memory Room), network clients (with MockWebServer), and repositories (with fakes or mocks). In-memory Room databases provide a real SQLite engine with zero setup and instant reset. MockWebServer provides a real HTTP server on localhost, making Retrofit tests fully deterministic without network access. Fake repositories simulate real caching and state behavior without a database. Together these tools let you test your entire data layer — including offline-first sync, error handling, and JSON contracts — reliably and fast.

Real-world relevance

An enterprise field operations app had an offline-first sync that was breaking intermittently in production: sometimes the app showed stale data after syncing. The bug was in the repository: it updated the in-memory cache before saving to the local database, so if the app crashed mid-sync, the cache showed new data but Room still had old data. An integration test with in-memory Room + MockWebServer caught this by crashing the repository mid-sync and verifying that the database and cache were consistent. The fix — writing to Room first, then updating the cache from the Room Flow — was verified by the test and eliminated the intermittent stale data reports.

Key points

Code example

// In-memory Room database test (instrumentation test with Robolectric or emulator)
@RunWith(AndroidJUnit4::class)
class WorkOrderDaoTest {

    private lateinit var db: AppDatabase
    private lateinit var dao: WorkOrderDao

    @Before
    fun setUp() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
            .allowMainThreadQueries()   // OK for tests only
            .build()
        dao = db.workOrderDao()
    }

    @After
    fun tearDown() {
        db.close()  // releases in-memory database, resets all state
    }

    @Test
    fun insertAndQueryWorkOrder() = runTest {
        val order = WorkOrder(id = "w1", title = "Fix Elevator", status = "OPEN")
        dao.insert(order)

        val retrieved = dao.getById("w1")
        assertThat(retrieved?.title).isEqualTo("Fix Elevator")
        assertThat(retrieved?.status).isEqualTo("OPEN")
    }

    @Test
    fun updateWorkOrderStatus() = runTest {
        dao.insert(WorkOrder(id = "w1", title = "Fix Elevator", status = "OPEN"))
        dao.updateStatus("w1", "COMPLETED")
        assertThat(dao.getById("w1")?.status).isEqualTo("COMPLETED")
    }

    @Test
    fun workOrderFlow_emitsOnInsert() = runTest {
        dao.getAllOrdersFlow().test {
            assertThat(awaitItem()).isEmpty()         // initial empty state

            dao.insert(WorkOrder("w1", "Fix Elevator", "OPEN"))
            assertThat(awaitItem()).hasSize(1)         // emits after insert

            dao.insert(WorkOrder("w2", "Replace HVAC", "OPEN"))
            assertThat(awaitItem()).hasSize(2)         // emits after second insert
            cancel()
        }
    }
}

// MockWebServer for Retrofit testing
class WorkOrderApiTest {

    private lateinit var server: MockWebServer
    private lateinit var api: WorkOrderApi

    @Before
    fun setUp() {
        server = MockWebServer()
        server.start()
        api = Retrofit.Builder()
            .baseUrl(server.url("/"))        // points to local MockWebServer
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
            .create(WorkOrderApi::class.java)
    }

    @After
    fun tearDown() {
        server.shutdown()
    }

    @Test
    fun `fetchOrders parses response correctly`() = runTest {
        val json = """
            [{"id":"w1","title":"Fix Elevator","status":"OPEN"},
             {"id":"w2","title":"Replace HVAC","status":"OPEN"}]
        """.trimIndent()
        server.enqueue(MockResponse().setBody(json).setResponseCode(200))

        val orders = api.fetchOrders()

        assertThat(orders).hasSize(2)
        assertThat(orders[0].id).isEqualTo("w1")
        assertThat(orders[1].title).isEqualTo("Replace HVAC")

        // Verify the request was correct
        val request = server.takeRequest()
        assertThat(request.path).isEqualTo("/orders")
        assertThat(request.method).isEqualTo("GET")
    }

    @Test
    fun `fetchOrders throws on 500 response`() = runTest {
        server.enqueue(MockResponse().setResponseCode(500))

        assertThrows<HttpException> {
            api.fetchOrders()
        }
    }

    @Test
    fun `createOrder sends correct request body`() = runTest {
        server.enqueue(MockResponse().setBody("""{"id":"w3","title":"New Order","status":"OPEN"}"""))

        api.createOrder(CreateOrderRequest(title = "New Order", priority = "HIGH"))

        val request = server.takeRequest()
        assertThat(request.method).isEqualTo("POST")
        assertThat(request.path).isEqualTo("/orders")
        val body = request.body.readUtf8()
        assertThat(body).contains(""title":"New Order"")
        assertThat(body).contains(""priority":"HIGH"")
    }
}

Line-by-line walkthrough

  1. 1. Room.inMemoryDatabaseBuilder() creates a real SQLite database in RAM — all Room features (queries, transactions, Flow) work identically to a file database.
  2. 2. allowMainThreadQueries() is acceptable in tests — it removes the Room restriction that prevents running queries on the main thread, simplifying test code.
  3. 3. db.close() in @After is critical — it releases the in-memory SQLite engine and discards all data, ensuring the next test starts completely fresh.
  4. 4. dao.getAllOrdersFlow().test { } from Turbine collects Flow emissions from the DAO — Room automatically emits a new list every time the underlying table changes.
  5. 5. awaitItem() after dao.insert() works because Room's Flow support watches the table and emits on every write — Turbine suspends until that emission arrives.
  6. 6. MockWebServer.start() binds to a random available port on localhost — server.url('/') returns the base URL including the port, which you pass to Retrofit.
  7. 7. server.enqueue() pre-loads responses in a FIFO queue — the first HTTP request gets the first enqueued response, the second request gets the second, and so on.
  8. 8. server.takeRequest() dequeues the RecordedRequest for the last HTTP call — use it after making the API call to inspect what your Retrofit client actually sent.
  9. 9. assertThat(request.body.readUtf8()).contains() verifies the JSON request body — catches bugs where a field is missing or has the wrong key name.
  10. 10. server.shutdown() in @After stops the local HTTP server — failing to shut down leaks server threads, causing test suites to hang or subsequent tests to fail with port conflicts.

Spot the bug

class StudentRepositoryIntegrationTest {

    private lateinit var db: AppDatabase
    private lateinit var server: MockWebServer
    private lateinit var repository: StudentRepository

    // Bug 1
    @Before
    fun setUp() {
        server = MockWebServer()
        // server.start() missing

        val api = Retrofit.Builder()
            .baseUrl("https://api.schoolapp.com/")   // Bug 2
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
            .create(StudentApi::class.java)

        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        ).build()  // Bug 3 — missing allowMainThreadQueries() for test simplicity

        repository = StudentRepositoryImpl(api, db.studentDao())
    }

    @After
    fun tearDown() {
        db.close()
        // Bug 4 — server.shutdown() missing
    }

    @Test
    fun `fetchStudents returns local data first then network data`() = runTest {
        // Pre-populate local DB
        db.studentDao().insert(Student("s1", "Alice", gradeLevel = 10))

        server.enqueue(
            MockResponse()
                .setBody("""[{"id":"s1","name":"Alice Updated","gradeLevel":10}]""")
                .setResponseCode(200)
        )

        repository.getStudents().test {
            val localFirst = awaitItem()
            assertThat(localFirst).hasSize(1)
            assertThat(localFirst[0].name).isEqualTo("Alice")

            // Bug 5
            val networkUpdated = awaitItem()
            assertThat(networkUpdated[0].name).isEqualTo("Alice Updated")
        }
    }
}
Need a hint?
Check the server setup, Retrofit base URL, Room query thread, server shutdown, and Turbine test block closure.
Show answer
Bug 1: server.start() is missing after creating the MockWebServer instance. Without server.start(), the MockWebServer is not bound to any port and is not listening for connections. When Retrofit tries to connect, it will fail with a ConnectException. Fix: add server.start() in setUp() before creating the Retrofit instance. Bug 2: The Retrofit baseUrl is hardcoded to 'https://api.schoolapp.com/' instead of server.url('/') (which returns the MockWebServer's localhost URL). This means Retrofit makes real network calls to the production API during tests — non-deterministic, requires network access, and could accidentally modify production data if the test makes POST/PUT calls. Fix: change to .baseUrl(server.url('/')) after server.start(). Bug 3: Room.inMemoryDatabaseBuilder() without allowMainThreadQueries() means any DAO call on the main test thread will throw an IllegalStateException ('Cannot access database on the main thread'). Since tests typically run on the main thread, this will cause the test to crash immediately. Fix: add .allowMainThreadQueries() to the builder chain for test simplicity, or configure a TestCoroutineDispatcher for the DAO calls. Bug 4: server.shutdown() is missing from @After. This leaves the MockWebServer's background thread running after the test completes, leaking resources. In test suites with many tests, this accumulates leaked threads and can cause port conflicts or OOM. Fix: add server.shutdown() after db.close() in tearDown(). Bug 5: The Turbine test { } block is never closed — cancel() is missing at the end. For a Flow that never completes (like a Room Flow), Turbine will wait indefinitely after the last awaitItem() for more emissions, causing the test to hang until the Turbine timeout (default 1 second) triggers a test failure with a confusing 'No more events' or timeout error. Fix: add cancel() as the last statement inside the test { } block to signal that you are done collecting.

Explain like I'm 5

Testing your database is like practicing cooking in a pop-up kitchen — everything works the same as the real kitchen but when you are done, everything disappears and the next chef starts fresh. MockWebServer is like a pretend food supplier you completely control — you tell them to send you fresh fish, rotten fish, or nothing at all, and you see how your recipe handles each situation.

Fun fact

MockWebServer is part of OkHttp and was originally built by Square for testing their own Square Point-of-Sale and Cash App payment infrastructure. It has since become the standard for Retrofit testing across the Android ecosystem. The library processes real HTTP/1.1 and HTTP/2 traffic — meaning your tests catch real networking bugs like incorrect Content-Type headers, missing authentication tokens, and malformed JSON that only surface in actual HTTP calls.

Hands-on challenge

Build a complete integration test for an offline-first SchoolRepository that: (a) fetches student records from a local Room database first, (b) then fetches from a REST API via Retrofit, (c) saves the network response to Room, and (d) emits both the local-first and network-updated data via a Flow. Use in-memory Room, MockWebServer, and Turbine. Test the happy path AND the scenario where the network call fails (local data should still be served).

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery