Learn Android integration testing from beginner to interview level: test boundaries, repository strategies, fake and real data sources, cache-first and network-first behaviour, offline support, synchronization, model/error mapping, Room DAO tests, database constraints and migration testing.

Sample Architecture Used in These Notes

flowchart LR
    UI["ViewModel"]
    REPO["TaskRepository"]
    LOCAL["Local source<br/>Room DAO"]
    REMOTE["Remote source<br/>API"]
    DB["SQLite database"]
    SERVER["Backend"]

    UI --> REPO
    REPO --> LOCAL --> DB
    REPO --> REMOTE --> SERVER

Domain model

data class Task(
    val id: Long,
    val title: String,
    val isCompleted: Boolean
)

Database entity

@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey
    val id: Long,
    val title: String,
    val isCompleted: Boolean,
    val updatedAt: Long
)

DAO

@Dao
interface TaskDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(task: TaskEntity)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(tasks: List<TaskEntity>)

    @Query("SELECT * FROM tasks ORDER BY title ASC")
    fun observeTasks(): Flow<List<TaskEntity>>

    @Query("SELECT * FROM tasks WHERE id = :id")
    suspend fun getById(id: Long): TaskEntity?

    @Update
    suspend fun update(task: TaskEntity)

    @Delete
    suspend fun delete(task: TaskEntity)

    @Query("DELETE FROM tasks")
    suspend fun deleteAll()
}


1. What Is Integration Testing?

Integration testing verifies that two or more components work correctly together.

Examples:

  • Repository + fake API + fake DAO
  • Repository + real Room database
  • DAO + SQLite
  • Retrofit + JSON converter
  • Repository + mapper + cache
  • Database migration + existing data

Example

A repository receives API data, maps it into entities, stores it in Room and returns domain models.

flowchart LR
    API["API response"]
    MAP1["Network mapper"]
    REPO["Repository"]
    DAO["Room DAO"]
    MAP2["Database mapper"]
    DOMAIN["Domain model"]

    API --> MAP1 --> REPO
    REPO --> DAO --> MAP2 --> DOMAIN

An integration test checks that the connected components agree about:

  • Data format
  • Method contracts
  • Error handling
  • Ordering
  • Persistence
  • Synchronization


2. Unit Tests vs Integration Tests

PointUnit testIntegration test
ScopeOne componentMultiple components
DependenciesReplaced with doublesSome dependencies are real
SpeedVery fastSlower
Failure diagnosisEasyModerate
ConfidenceLogic confidenceCollaboration confidence
Android requiredUsually noSometimes
ExampleMapper testRepository + Room
MaintenanceLowerHigher

Unit-test example

Real repository
Fake local source
Fake remote source

Integration-test example

Real repository
Real Room database
Fake HTTP server
Real mappers

Use unit tests for individual logic and integration tests for important boundaries between components.


3. Integration-Test Boundaries

A test boundary defines which components are real and which are replaced.

Repository integration boundary

flowchart LR
    VM["ViewModel<br/>Excluded"]
    REPO["Real repository"]
    MAP["Real mappers"]
    ROOM["Real Room database"]
    API["Fake remote source"]

    VM -.-> REPO
    REPO --> MAP
    REPO --> ROOM
    REPO --> API

Before writing the test, define:

  • Subject under test
  • Real collaborators
  • Fake collaborators
  • Inputs
  • Observable result
  • Failure being detected

Boundary examples

TestReal componentsReplaced components
Repository unit testRepositoryAPI and DAO
Repository integration testRepository and RoomBackend
DAO testRoom, DAO and SQLiteNothing below DAO
API integration testRetrofit and converterBackend via fake server
E2E testMost application layersFew or none

Do not make every dependency real. Use the smallest boundary that provides the required confidence.

\

4. Repository-Testing Strategy

A repository coordinates application data and hides data-source details from higher layers.

Test the repository’s observable contract:

  • Which source provides reads?
  • When is the network called?
  • When is the cache updated?
  • How are models mapped?
  • How are errors mapped?
  • What happens offline?
  • How are conflicting values resolved?
  • What is returned when data is stale?
  • What happens during partial failure?

Recommended repository test matrix

ScenarioExpected result
Cache contains fresh dataReturn cache
Cache is emptyFetch remote data
Remote request succeedsSave and expose data
Remote request fails with cacheReturn cache or stale state
Remote request fails without cacheReturn error
Invalid remote modelMap to controlled error
Database write failsReturn persistence error
Local update pendingPreserve pending change
Sync succeedsMark data synchronized
Conflict occursApply defined conflict policy

Android architecture guidance recommends that each repository define a clear source of truth. Android data-layer guidance.


5. Repository Tests with Fake Data Sources

Fakes provide controlled data without using a real database or network.

Interfaces

interface TaskLocalDataSource {
    fun observeTasks(): Flow<List<TaskEntity>>
    suspend fun saveTasks(tasks: List<TaskEntity>)
}

interface TaskRemoteDataSource {
    suspend fun fetchTasks(): List<TaskDto>
}

Fake local source

class FakeTaskLocalDataSource : TaskLocalDataSource {

    private val tasks =
        MutableStateFlow<List<TaskEntity>>(emptyList())

    var saveException: Exception? = null

    override fun observeTasks(): Flow<List<TaskEntity>> {
        return tasks
    }

    override suspend fun saveTasks(
        tasks: List<TaskEntity>
    ) {
        saveException?.let { throw it }
        this.tasks.value = tasks
    }
}

Fake remote source

class FakeTaskRemoteDataSource(
    var tasks: List<TaskDto> = emptyList(),
    var exception: Exception? = null
) : TaskRemoteDataSource {

    var requestCount = 0

    override suspend fun fetchTasks(): List<TaskDto> {
        requestCount++
        exception?.let { throw it }
        return tasks
    }
}

Repository test

@Test
fun refresh_remoteSucceeds_savesDataLocally() = runTest {
    val local = FakeTaskLocalDataSource()
    val remote = FakeTaskRemoteDataSource(
        tasks = listOf(
            TaskDto(1, "Integration testing", false)
        )
    )

    val repository = TaskRepositoryImpl(
        local = local,
        remote = remote
    )

    repository.refresh()

    val result = local.observeTasks().first()

    assertEquals(1, result.size)
    assertEquals("Integration testing", result.first().title)
}

Fake data sources make repository tests fast and deterministic. Test doubles are specifically useful for controlling dependency behaviour. Android test-double guidance.


6. Repository Tests with Real Components

Use real components when you need confidence that actual integration works.

Possible setup:

Real TaskRepositoryImpl
Real mappers
Real Room database
Fake remote API

Example setup

@RunWith(AndroidJUnit4::class)
class TaskRepositoryIntegrationTest {

    private lateinit var database: AppDatabase
    private lateinit var repository: TaskRepository

    @Before
    fun setUp() {
        val context =
            ApplicationProvider.getApplicationContext<Context>()

        database = Room.inMemoryDatabaseBuilder(
            context,
            AppDatabase::class.java
        ).allowMainThreadQueries().build()

        repository = TaskRepositoryImpl(
            dao = database.taskDao(),
            remote = FakeTaskRemoteDataSource()
        )
    }

    @After
    fun tearDown() {
        database.close()
    }
}

This can detect:

  • Incorrect SQL
  • Wrong entity mapping
  • Conflict-strategy problems
  • Flow update problems
  • Data not being persisted
  • Incorrect transaction behaviour

Room tests commonly run as instrumented tests. Robolectric can be considered when JVM-based Android simulation fits the project.


7. Local and Remote Data Coordination

A repository often coordinates:

  • Local database
  • Remote API
  • Cache freshness
  • Pending local changes
  • Sync metadata

Single source of truth pattern

flowchart TD
    UI["UI observes data"]
    DB["Room database<br/>Source of truth"]
    REPO["Repository"]
    API["Remote API"]

    DB --> UI
    REPO --> API
    API --> REPO
    REPO --> DB

Recommended flow:

1. UI observes Room.
2. Repository fetches remote data.
3. Repository writes remote data into Room.
4. Room emits updated data.
5. UI receives the update.

This avoids returning one value from the network while exposing a different value from the database.

In an offline-first architecture, Android recommends using a local source as the canonical source of truth and synchronizing it with the network. Offline-first architecture.

Test

@Test
fun refresh_updatesObservedLocalData() = runTest {
    repository.observeTasks().test {
        assertEquals(emptyList<Task>(), awaitItem())

        repository.refresh()

        assertEquals(expectedTasks, awaitItem())
    }
}


8. Cache-First Behaviour

Cache-first means the repository reads cached data before requesting remote data.

Typical decision

flowchart TD
    R["Request data"]
    C{"Fresh cache exists?"}
    LC["Return local cache"]
    N["Fetch network"]
    S["Save locally"]
    O["Return/observe data"]

    R --> C
    C -->|Yes| LC
    C -->|No| N
    N --> S --> O

Important tests

1. Fresh cache is returned.
2. Network is not called for fresh cache.
3. Empty cache triggers network.
4. Expired cache triggers network.
5. Network success updates cache.
6. Network failure returns stale cache if policy permits.
7. Network failure without cache returns error.

@Test
fun freshCacheExists_networkIsNotCalled() = runTest {
    local.tasks = cachedTasks
    local.lastUpdated = 10_000
    clock.currentTime = 11_000

    val result = repository.getTasks()

    assertEquals(cachedTasks, result)
    assertEquals(0, remote.requestCount)
}

Inject a clock so cache-expiry tests do not use real time.


9. Network-First Behaviour

Network-first tries the remote source before local data.

flowchart TD
    R["Request data"]
    N["Fetch remote"]
    OK{"Successful?"}
    S["Save in Room"]
    L["Read local fallback"]
    E["Return error"]

    R --> N --> OK
    OK -->|Yes| S
    OK -->|No, cache exists| L
    OK -->|No cache| E

Important tests

  • Successful network response returned
  • Successful response cached
  • Network failure falls back to cache
  • Network failure without cache returns error
  • Malformed response is not stored
  • Existing cache is preserved after network failure

@Test
fun networkFails_cachedDataIsReturned() = runTest {
    local.tasks = cachedTasks
    remote.exception = IOException()

    val result = repository.getTasks()

    assertEquals(cachedTasks, result)
}

Network-first is useful when freshness matters more than immediate response speed.


10. Offline Behaviour

Offline behaviour defines what the app can do without connectivity.

An offline-first app should, at minimum, support critical reads without network access. Android offline-first guidance.

Offline scenarios

ScenarioExpected behaviour
Network unavailable, cache existsShow cached data
Network unavailable, cache emptyShow offline error
User creates data offlineSave as pending
Connectivity returnsSynchronize pending data
Sync failsKeep pending data
Server rejects updateMark conflict/error
App restartsPending operation remains

Pending entity example

@Entity
data class PendingTaskEntity(
    @PrimaryKey
    val localId: String,
    val title: String,
    val syncStatus: String
)

Possible statuses:

enum class SyncStatus {
    PENDING,
    SYNCING,
    SYNCED,
    FAILED
}

Test

@Test
fun createTaskOffline_isStoredAsPending() = runTest {
    remote.exception = IOException()

    repository.createTask("Offline task")

    val stored = pendingTaskDao.getAll()

    assertEquals(1, stored.size)
    assertEquals(
        SyncStatus.PENDING,
        stored.first().syncStatus
    )
}


11. Data Synchronization

Synchronization keeps local and remote data consistent.

Synchronization flow

flowchart TD
    P["Read pending local changes"]
    U["Upload to server"]
    OK{"Upload successful?"}
    M["Merge server response"]
    S["Mark synchronized"]
    F["Keep pending/failed"]

    P --> U --> OK
    OK -->|Yes| M --> S
    OK -->|No| F

Test cases

  • Only pending records are uploaded
  • Successful items become synchronized
  • Failed items remain pending
  • Duplicate uploads are prevented
  • Sync can resume after interruption
  • Deletion synchronizes correctly
  • Partial success is handled
  • Conflicts use the defined strategy
  • Multiple sync runs remain idempotent

Common conflict policies

PolicyMeaning
Server winsRemote version replaces local
Client winsLocal version replaces remote
Last write winsLatest timestamp wins
Field-level mergeIndividual fields are merged
Manual resolutionUser chooses the correct value

Do not rely only on device time for critical conflict resolution because device clocks may be incorrect.


12. Domain-Model Mapping

Different layers often use different models:

TaskDto → Network representation 
TaskEntity → Database representation 
Task → Domain representation 
TaskUiModel → UI representation
flowchart LR
    DTO["TaskDto"]
    ENTITY["TaskEntity"]
    DOMAIN["Task"]
    UI["TaskUiModel"]

    DTO --> ENTITY --> DOMAIN --> UI

Mapper

fun TaskEntity.toDomain(): Task {
    return Task(
        id = id,
        title = title,
        isCompleted = isCompleted
    )
}

Test

@Test
fun entity_mapsToDomainModel() {
    val entity = TaskEntity(
        id = 1,
        title = "Room testing",
        isCompleted = false,
        updatedAt = 1_000
    )

    val result = entity.toDomain()

    assertEquals(
        Task(
            id = 1,
            title = "Room testing",
            isCompleted = false
        ),
        result
    )
}

Test:

  • Every field
  • Null handling
  • Default values
  • Enum conversion
  • Date conversion
  • Unknown server values
  • Precision
  • Invalid identifiers

Mapper unit tests are useful, but repository integration tests should also verify that mappers are correctly connected.


13. API Error Mapping

The data layer should convert technical failures into stable application errors.

sealed interface DataError {
    data object NetworkUnavailable : DataError
    data object Timeout : DataError
    data object Unauthorized : DataError
    data object NotFound : DataError
    data object Server : DataError
    data object InvalidResponse : DataError
    data object Unknown : DataError
}

Mapping example

fun Throwable.toDataError(): DataError {
    return when (this) {
        is UnknownHostException ->
            DataError.NetworkUnavailable

        is SocketTimeoutException ->
            DataError.Timeout

        is JsonDataException ->
            DataError.InvalidResponse

        else ->
            DataError.Unknown
    }
}

HTTP status mapping might include:

FailureDomain error
No internetNetworkUnavailable
TimeoutTimeout
HTTP 401Unauthorized
HTTP 404NotFound
HTTP 5xxServer
Invalid JSONInvalidResponse

Test

@Test
fun timeoutException_mapsToTimeoutError() {
    val error =
        SocketTimeoutException().toDataError()

    assertEquals(
        DataError.Timeout,
        error
    )
}

Repository tests should confirm that technical exceptions do not escape unexpectedly.


14. Database Error Mapping

Database operations can fail because of:

  • Constraint violations
  • Full storage
  • Corrupt database
  • Closed database
  • Failed migration
  • Invalid SQL
  • Disk I/O failure

Map infrastructure exceptions into stable data-layer errors.

sealed interface StorageError {
    data object ConstraintViolation : StorageError
    data object StorageUnavailable : StorageError
    data object CorruptedData : StorageError
    data object Unknown : StorageError
}

Test

@Test
fun localSaveFails_repositoryReturnsStorageError() = runTest {
    local.saveException =
        SQLiteConstraintException()

    val result = repository.save(expectedTask)

    assertEquals(
        SaveResult.Failure(
            StorageError.ConstraintViolation
        ),
        result
    )
}

Avoid showing raw database error messages to users.


15. Room Database Testing

Room testing verifies:

  • DAO queries
  • Inserts
  • Updates
  • Deletes
  • Relationships
  • Constraints
  • Conflict strategies
  • Transactions
  • Flow emissions
  • Migrations

Room tests are important because SQL queries are not fully verified by ordinary mock-based tests.

Android’s Room guidance emphasizes testing database stability and protecting user data. Room database testing.

Recommended setup

@RunWith(AndroidJUnit4::class)
class TaskDaoTest {

    private lateinit var database: AppDatabase
    private lateinit var dao: TaskDao

    @Before
    fun createDatabase() {
        val context =
            ApplicationProvider.getApplicationContext<Context>()

        database = Room.inMemoryDatabaseBuilder(
            context,
            AppDatabase::class.java
        ).allowMainThreadQueries().build()

        dao = database.taskDao()
    }

    @After
    fun closeDatabase() {
        database.close()
    }
}

Place instrumented Room tests under:

app/src/androidTest/java/


16. In-Memory Room Database

An in-memory database stores data in memory rather than a persistent file.

database = Room.inMemoryDatabaseBuilder(
    context,
    AppDatabase::class.java
).build()

Advantages

  • Fresh database for every test
  • Fast
  • No persistent test data
  • Test isolation
  • Uses real Room and SQLite behaviour

Limitations

  • Data disappears when the database closes
  • Does not simulate all disk failures
  • Cannot replace migration testing
  • Still requires proper setup and cleanup

allowMainThreadQueries() is acceptable in tightly controlled tests, but should generally not be enabled in production.

        

17. DAO Insert Testing

Test:

  • Single insert
  • Multiple inserts
  • Generated ID
  • Duplicate primary key
  • Conflict strategy
  • Nullability and defaults

@Test
fun insertTask_taskCanBeRead() = runTest {
    val task = TaskEntity(
        id = 1,
        title = "Learn Room",
        isCompleted = false,
        updatedAt = 1_000
    )

    dao.insert(task)

    assertEquals(
        task,
        dao.getById(1)
    )
}

Conflict-strategy test

@Test
fun insertSameId_replaceStrategyUpdatesTask() = runTest {
    dao.insert(
        createTask(id = 1, title = "Old")
    )

    dao.insert(
        createTask(id = 1, title = "New")
    )

    assertEquals(
        "New",
        dao.getById(1)?.title
    )
}

The expected result depends on whether the DAO uses REPLACE, IGNORE, ABORT or another strategy.


18. DAO Read Testing

Test:

  • Read existing record
  • Read missing record
  • Empty table
  • Filtering
  • Flow observation
  • Relations
  • Nullable fields

@Test
fun getById_unknownId_returnsNull() = runTest {
    val result = dao.getById(999)

    assertNull(result)
}

Testing Room Flow

@Test
fun observeTasks_afterInsert_emitsUpdatedList() = runTest {
    dao.observeTasks().test {
        assertEquals(
            emptyList<TaskEntity>(),
            awaitItem()
        )

        val task = createTask(
            id = 1,
            title = "Integration testing"
        )

        dao.insert(task)

        assertEquals(
            listOf(task),
            awaitItem()
        )
    }
}

Room observable queries normally emit again when a relevant table changes.


19. DAO Update and Delete Testing

Update test

@Test
fun updateTask_changesStoredRecord() = runTest {
    val original = createTask(
        id = 1,
        title = "Old title"
    )

    dao.insert(original)

    val updated = original.copy(
        title = "Updated title",
        isCompleted = true
    )

    dao.update(updated)

    assertEquals(
        updated,
        dao.getById(1)
    )
}

Delete test

@Test
fun deleteTask_removesRecord() = runTest {
    val task = createTask(id = 1)

    dao.insert(task)
    dao.delete(task)

    assertNull(dao.getById(1))
}

Also test:

  • Updating a missing record
  • Deleting a missing record
  • Delete-all operation
  • Cascading delete
  • Transaction rollback

Where meaningful, return affected row counts from DAO methods and assert them.


20. Query and Ordering Tests

Queries can fail logically even if they compile.

Test:

  • Ascending order
  • Descending order
  • Filtering
  • Search
  • Pagination
  • Case sensitivity
  • Null ordering
  • Duplicate values
  • Date range
  • Empty result

DAO query

@Query(
    """
    SELECT * FROM tasks
    WHERE isCompleted = :completed
    ORDER BY title ASC
    """
)
suspend fun getTasksByStatus(
    completed: Boolean
): List<TaskEntity>

Test

@Test
fun completedTasks_areSortedByTitle() = runTest {
    dao.insertAll(
        listOf(
            createTask(1, "Zebra", true),
            createTask(2, "Android", true),
            createTask(3, "Kotlin", false)
        )
    )

    val result =
        dao.getTasksByStatus(completed = true)

    assertEquals(
        listOf("Android", "Zebra"),
        result.map { it.title }
    )
}

Never assume database ordering unless the SQL query contains ORDER BY.


21. Database Constraint Testing

Database constraints protect data integrity.

Common constraints:

  • Primary key
  • Unique index
  • Foreign key
  • Non-null column
  • Check constraint where supported
  • Referential actions such as cascade delete

Unique-index example

@Entity(
    tableName = "users",
    indices = [
        Index(
            value = ["email"],
            unique = true
        )
    ]
)
data class UserEntity(
    @PrimaryKey val id: Long,
    val email: String
)

Test

@Test
fun duplicateEmail_throwsConstraintException() = runTest {
    dao.insertUser(
        UserEntity(1, "user@example.com")
    )

    assertThrows(
        SQLiteConstraintException::class.java
    ) {
        runTest {
            dao.insertUser(
                UserEntity(2, "user@example.com")
            )
        }
    }
}

Foreign-key scenarios

Test:

  • Child with valid parent
  • Child without parent
  • Parent deletion with CASCADE
  • Parent deletion with RESTRICT
  • Parent-key update behaviour

Constraints should be tested using real Room/SQLite, not mocks.


22. Room Migration Testing

A migration changes the database schema while preserving existing user data.

Examples:

  • Add column
  • Rename table
  • Create index
  • Split one table into two
  • Change relationship
  • Transform existing values

Why migration tests are critical

A migration may work for a fresh installation but fail for an existing user upgrading from an older version.

flowchart LR
    V1["Database v1<br/>Existing data"]
    M["Migration 1 → 2"]
    V2["Database v2<br/>New schema"]
    C["Verify schema and data"]

    V1 --> M --> V2 --> C

Export schemas

android {
    defaultConfig {
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += mapOf(
                    "room.schemaLocation" to
                        "$projectDir/schemas"
                )
            }
        }
    }
}

For KSP-based Room configuration, configure the equivalent Room schema directory according to the project’s Room plugin/KSP setup.

Commit exported schema files to version control.

Migration

val MIGRATION_1_2 =
    object : Migration(1, 2) {

        override fun migrate(db: SupportSQLiteDatabase) {
            db.execSQL(
                """
                ALTER TABLE tasks
                ADD COLUMN priority INTEGER NOT NULL DEFAULT 0
                """.trimIndent()
            )
        }
    }

Migration test

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

@Test
fun migrate1To2_preservesTasksAndAddsPriority() {
    helper.createDatabase(TEST_DB, 1).apply {
        execSQL(
            """
            INSERT INTO tasks
            (id, title, isCompleted, updatedAt)
            VALUES (1, 'Existing task', 0, 1000)
            """.trimIndent()
        )
        close()
    }

    helper.runMigrationsAndValidate(
        TEST_DB,
        2,
        true,
        MIGRATION_1_2
    ).use { database ->
        database.query(
            "SELECT title, priority FROM tasks WHERE id = 1"
        ).use { cursor ->
            assertTrue(cursor.moveToFirst())
            assertEquals(
                "Existing task",
                cursor.getString(0)
            )
            assertEquals(0, cursor.getInt(1))
        }
    }
}

MigrationTestHelper uses exported schemas to create old database versions and validate migrated schemas. Room migration testing.

Test every supported migration path

If the current version is 4, consider testing:

1 → 2
2 → 3
3 → 4
1 → 4
2 → 4

Also test:

  • Existing rows preserved
  • New columns receive correct defaults
  • Indexes created
  • Relationships remain valid
  • Data transformations are correct
  • Complete migration path succeeds

Avoid fallbackToDestructiveMigration() when user data must be preserved.


Complete Repository Use Case

Requirement

The task list should:

1. Display Room data immediately.
2. Refresh from the server.
3. Save the server response into Room.
4. Continue displaying cached data offline.
5. Preserve locally created pending tasks.
6. Synchronize pending tasks when connectivity returns.

Test matrix

ScenarioComponentsExpected result
Initial local dataRepository + RoomCached data emitted
Refresh successRepository + Room + fake remoteDatabase updated
Refresh failureRepository + Room + failing remoteCache preserved
No cache and no networkRepository + RoomOffline error
Local task creationRepository + RoomPending record saved
Successful synchronizationAll except real serverRecord marked synced
Partial synchronizationRepository + RoomFailed records remain pending
Server/local conflictRepository + policyDefined winner/merge
Upgrade old databaseMigration helperData and schema preserved


Common Mistakes

1. Calling a repository test an integration test when every dependency is mocked
2. Making the entire application real in one test
3. Using a production backend
4. Sharing one database between tests
5. Forgetting to close the database
6. Depending on test execution order
7. Returning network data without updating the source of truth
8. Deleting valid cache after a network failure
9. Testing only the online path
10. Ignoring partial synchronization
11. Using device time as the only conflict authority
12. Exposing DTOs or entities as domain models
13. Leaking Retrofit or SQLite exceptions to the UI
14. Assuming query ordering without ORDER BY
15. Testing DAO behaviour with mocks
16. Not testing duplicate primary keys
17. Ignoring foreign-key behaviour
18. Using destructive migration for valuable user data
19. Testing only fresh database creation
20. Not committing exported Room schemas
21. Forgetting older-to-latest migration paths
22. Using allowMainThreadQueries() in production
23. Using arbitrary delays for Room Flow tests
24. Failing to test empty database results
25. Clearing pending offline changes during refresh


Interview Questions and Answers

1. What is integration testing?

Integration testing verifies that multiple components communicate and behave correctly together.

2. Unit test vs integration test?

A unit test isolates one component. An integration test connects multiple real components across a meaningful boundary.

3. Give an Android integration-test example.

Testing a real repository with a real Room database and a fake remote data source.

4. What is an integration-test boundary?

It defines which components are real, which are replaced and which behaviour the test verifies.

5. Why should the boundary remain small?

A smaller boundary makes tests faster and failures easier to diagnose.

6. What should be tested in a repository?

Source selection, mapping, caching, synchronization, fallback behaviour and error handling.

7. How do you test a repository with fake data sources?

Provide configurable fake local and remote implementations, execute the repository operation and assert outputs and stored state.

8. What is the benefit of fake data sources?

They are fast, deterministic and can simulate success, failure and different data states.

9. Why test a repository with real Room?

It detects SQL, mapping, persistence, transaction and Flow-notification problems that fakes may miss.

10. Should a repository integration test use the production server?

Normally no. Use a fake server or fake remote source to keep tests deterministic.

11. What is a single source of truth?

It is the authoritative source from which higher layers read consistent application data.

12. What is commonly the source of truth in an offline-first app?

A local persistent source such as Room.

13. How should network data reach the UI in an offline-first design?

The repository stores it locally, and the UI observes the local source.

14. What is cache-first behaviour?

The repository uses fresh cached data first and fetches the network when the cache is missing or stale.

15. What is network-first behaviour?

The repository requests current remote data first and falls back to local data when allowed.

16. Cache-first vs network-first?

Cache-first prioritizes speed and offline availability. Network-first prioritizes freshness.

17. What should happen when the network fails but cache exists?

According to the product policy, return or continue showing cached data, usually with a stale/offline indicator.

18. What should happen when both network and cache are unavailable?

Return a controlled offline or unavailable-data error.

19. What is offline-first architecture?

It makes critical functionality available from local data and synchronizes local and remote state when connectivity permits.

20. How are offline writes handled?

Store them locally with a pending status and upload them later.

21. What is data synchronization?

It reconciles local and remote data while preserving consistency and tracking pending operations.

22. What are common synchronization conflict strategies?

Server wins, client wins, last write wins, field-level merge and manual resolution.

23. What is idempotent synchronization?

Repeating the same sync operation does not create an incorrect additional effect or duplicate record.

24. Why use separate DTO, entity and domain models?

Each layer has different requirements, and separation prevents network or database details from leaking across architecture layers.

25. What should a mapper test verify?

Field mapping, null handling, defaults, types, formatting and unknown values.

26. How should API errors be handled?

Convert HTTP, connectivity, timeout and parsing failures into stable data/domain errors.

27. How should database errors be handled?

Map storage and constraint exceptions into meaningful application-level errors without exposing raw messages.

28. Why test Room DAOs with a real database?

DAO behaviour depends on generated Room code and SQLite semantics, which mocks cannot verify.

29. What is an in-memory Room database?

A temporary Room database stored in memory and discarded when closed.

30. Why use an in-memory database in tests?

It provides isolation, speed and real Room/SQLite behaviour without persistent test files.

31. What is the limitation of an in-memory database?

It does not test real upgrade files or every disk-related failure and cannot replace migration tests.

32. What should be tested for DAO inserts?

Inserted data, generated IDs, conflict strategies, defaults and constraint failures.

33. What should be tested for DAO reads?

Existing, missing and empty results, filters, relations and observable updates.

34. What should be tested for updates?

The correct row and fields change while unrelated data remains unchanged.

35. What should be tested for deletes?

The row is removed and foreign-key cascade or restriction behaviour is correct.

36. Is database result order guaranteed?

No. Use ORDER BY when ordering is required.

37. What database constraints should be tested?

Primary keys, unique indexes, foreign keys, nullability and referential actions.

38. What is Room migration testing?

It verifies that an older schema upgrades to a newer schema without losing or corrupting user data.

39. What is MigrationTestHelper?

It creates databases at old schema versions, applies migrations and validates the resulting schema.

40. Why export Room schemas?

Migration tests use them for schema creation and validation, and they provide versioned schema history.

41. Should Room schema files be committed?

Yes. They should normally be version-controlled.

42. Why test older-to-latest migration paths?

Real users may upgrade from several older application versions, not only the immediately previous version.

43. What is destructive migration?

It recreates the database when a valid migration path is unavailable, causing existing local data to be lost.

44. When is destructive migration dangerous?

When the database contains user-created, business-critical or offline-pending data.


Quick Revision

Unit test:
One component + controlled dependencies

Integration test:
Multiple real collaborating components

Repository test:
Source selection
Mapping
Caching
Fallback
Synchronization
Error handling

Cache first:
Local → network when stale/missing

Network first:
Network → local fallback

Offline first:
Room is commonly the source of truth
UI reads Room
Network synchronizes Room

Room tests:
Insert
Read
Update
Delete
Query/order
Constraints
Flow emissions
Migrations

Migration test:
Old schema + existing data
→ Apply migration
→ Validate new schema
→ Verify data preserved