Integration tests verify that multiple components work together correctly. Where unit tests isolate one class with mocks, integration tests use real implementations — a real database, a real HTTP client against a fake server, real coroutines with real dispatchers.
They are slower than unit tests but catch a class of bugs that mocks cannot: incorrect SQL queries, wrong HTTP request shapes, mapping errors between layers, and transaction behavior.
The Test Pyramid
E2E / UI tests ← few, slow, high confidence
Integration tests ← some, medium speed
Unit tests ← many, fast, low coupling
Integration tests live in the middle. You should have more unit tests than integration tests, but integration tests give you confidence that real systems communicate correctly.
Room Integration Tests
Test Room with an in-memory database. It behaves identically to a disk database but is destroyed after each test:
@RunWith(AndroidJUnit4::class)
class TaskDaoTest {
private lateinit var db: AppDatabase
private lateinit var dao: TaskDao
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).allowMainThreadQueries().build()
dao = db.taskDao()
}
@After
fun teardown() {
db.close()
}
@Test
fun insertAndObserveTasks() = runTest {
val task = TaskEntity(id = "1", title = "Write tests", completed = false)
dao.upsert(task)
val tasks = dao.observeTasks().first()
assertEquals(1, tasks.size)
assertEquals("Write tests", tasks[0].title)
}
@Test
fun deleteRemovesTask() = runTest {
val task = TaskEntity(id = "1", title = "Delete me", completed = false)
dao.upsert(task)
dao.delete(task)
val tasks = dao.observeTasks().first()
assertTrue(tasks.isEmpty())
}
@Test
fun upsertReplacesExistingTask() = runTest {
val original = TaskEntity(id = "1", title = "Original", completed = false)
val updated = original.copy(title = "Updated", completed = true)
dao.upsert(original)
dao.upsert(updated)
val tasks = dao.observeTasks().first()
assertEquals(1, tasks.size)
assertEquals("Updated", tasks[0].title)
assertTrue(tasks[0].completed)
}
}
allowMainThreadQueries() is acceptable in tests. Never use it in production code.
MockWebServer for Retrofit Tests
OkHttp's MockWebServer lets you control exactly what the server returns, without a real network or server.
class UserRepositoryTest {
private lateinit var mockWebServer: MockWebServer
private lateinit var api: UserApi
private lateinit var repository: UserRepository
@Before
fun setup() {
mockWebServer = MockWebServer()
mockWebServer.start()
val retrofit = Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(MoshiConverterFactory.create())
.build()
api = retrofit.create(UserApi::class.java)
repository = UserRepositoryImpl(api)
}
@After
fun teardown() {
mockWebServer.shutdown()
}
@Test
fun `getUser returns mapped User on 200`() = runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{"id":"1","name":"Akshay","email":"a@b.com"}""")
)
val result = repository.getUser("1")
assertTrue(result.isSuccess)
assertEquals("Akshay", result.getOrNull()?.name)
}
@Test
fun `getUser returns failure on 500`() = runTest {
mockWebServer.enqueue(MockResponse().setResponseCode(500))
val result = repository.getUser("1")
assertTrue(result.isFailure)
}
@Test
fun `getUser sends correct request path`() = runTest {
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
repository.getUser("user-42")
val request = mockWebServer.takeRequest()
assertEquals("/users/user-42", request.path)
assertEquals("GET", request.method)
}
}
enqueue sets the next response. takeRequest lets you inspect what the client actually sent — useful for verifying headers, paths, and request bodies.
Testing Repository with Both Layers
Test the repository's caching and sync logic with both in-memory Room and MockWebServer together:
@Test
fun `sync fetches from network and writes to database`() = runTest {
mockWebServer.enqueue(
MockResponse().setBody("""[{"id":"1","title":"Task A","completed":false}]""")
)
repository.sync()
val storedTasks = dao.observeTasks().first()
assertEquals(1, storedTasks.size)
assertEquals("Task A", storedTasks[0].title)
}
This test crosses three real layers (Retrofit → repository → Room) and catches mapping errors that unit tests with mocks would miss.
Hilt in Integration Tests
Replace production modules with test fakes using @TestInstallIn and run tests with HiltAndroidRule:
@HiltAndroidTest
class ProfileRepositoryTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var repository: ProfileRepository
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun `repository returns injected fake data`() = runTest {
val result = repository.getProfile("user-1")
assertTrue(result.isSuccess)
}
}
@TestInstallIn replaces the production module with your test module for the duration of the test. The rest of the DI graph remains unchanged.
What Belongs in Integration Tests
| Test | Type |
|---|---|
| SQL query correctness | Integration (Room) |
| HTTP request shape + response mapping | Integration (MockWebServer) |
| Repository caching logic | Integration (Room + MockWebServer) |
| ViewModel business logic | Unit (MockK) |
| Composable rendering | UI (ComposeTestRule) |
| Full user flow | E2E (Espresso / Compose) |
Practice
Write an integration test for a NoteRepository that:
- Calls
sync()which fetches notes from a MockWebServer endpoint - Verifies the notes are stored in an in-memory Room database
- Asserts that
observeNotes()returns the fetched notes - Enqueues a 404 response and asserts
sync()returns aResult.failure
Summary
Integration tests use real implementations — in-memory Room for database tests, MockWebServer for HTTP tests — and catch bugs that mocks cannot. Test DAO queries directly, verify request paths and response mapping with MockWebServer, and combine both layers to test repository caching and sync logic. Keep them focused and fast; avoid replacing unit tests with integration tests.