Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory. Co-authored-by: Junie <junie@jetbrains.com>
5.6 KiB
Networking — Testing & DI
MockEngine testing patterns and Koin/Hilt DI integration for Ktor client. For core HttpClient setup see networking-ktor.md. For error handling patterns see networking-ktor-architecture.md.
References:
Testing with MockEngine
Setup
// commonTest
testImplementation("io.ktor:ktor-client-mock:$ktor_version")
Testing API calls
@Test
fun `getItem returns mapped domain model`() = runTest {
val mockEngine = MockEngine { request ->
assertEquals("/items/123", request.url.encodedPath)
respond(
content = """{"id":"123","name":"Test","status":"active","created_at":1700000000}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = createHttpClient(mockEngine, "https://api.example.com/")
val repo = ItemRepositoryImpl(ItemApi(client))
val result = repo.getItem("123")
assertEquals("Test", result.name)
}
Testing error handling
@Test
fun `getItem throws on 404`() = runTest {
val mockEngine = MockEngine {
respond(content = """{"error":"not found"}""", status = HttpStatusCode.NotFound)
}
val client = HttpClient(mockEngine) {
expectSuccess = true
install(ContentNegotiation) { json() }
}
val api = ItemApi(client)
assertFailsWith<ClientRequestException> { api.getItem("999") }
}
If using a safeRequest wrapper (see networking-ktor-architecture.md), test the wrapper's return type instead:
@Test
fun `safeRequest returns failure on 404`() = runTest {
val mockEngine = MockEngine {
respond(content = """{"error":"not found"}""", status = HttpStatusCode.NotFound)
}
val client = HttpClient(mockEngine) {
expectSuccess = false
install(ContentNegotiation) { json() }
}
val result = client.safeRequest<ItemDto> { url("items/999") }
assertTrue(result.isFailure) // or check sealed class variant
}
Request assertions
Verify request method, headers, body, and query parameters:
@Test
fun `createItem sends correct request`() = runTest {
val mockEngine = MockEngine { request ->
assertEquals(HttpMethod.Post, request.method)
assertEquals("application/json", request.body.contentType?.toString())
val body = (request.body as TextContent).text
assertTrue(body.contains("\"name\":\"Widget\""))
respond(
content = """{"id":"1","name":"Widget","status":"active","created_at":1700000000}""",
status = HttpStatusCode.Created,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = createHttpClient(mockEngine, "https://api.example.com/")
val api = ItemApi(client)
val result = api.createItem(CreateItemRequest(name = "Widget"))
assertEquals("Widget", result.name)
}
Multiple responses
MockEngine can return different responses based on path:
val mockEngine = MockEngine { request ->
when (request.url.encodedPath) {
"/items" -> respond(
content = """{"items":[],"total":0}""",
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
"/items/1" -> respond(
content = """{"id":"1","name":"Test","status":"active","created_at":1700000000}""",
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
else -> respondError(HttpStatusCode.NotFound)
}
}
Engine injection for testability
Accept HttpClientEngine as a constructor parameter so you can inject MockEngine in tests:
// Production: ItemApi(createHttpClient(OkHttp.create(), baseUrl))
// Test: ItemApi(createHttpClient(MockEngine { ... }, baseUrl))
Share the same createHttpClient factory in production and tests to keep plugin configuration consistent.
DI Integration
Provide HttpClient and HttpClientEngine as singletons. Use expect/actual platform modules for engine selection (OkHttp on Android, Darwin on iOS):
// Koin: single { createHttpClient(engine = get(), baseUrl = "https://api.example.com/") }
// Hilt: @Provides @Singleton fun provideHttpClient(): HttpClient = createHttpClient(...)
For full Koin module patterns (including platform engine modules), see koin.md. For Hilt module patterns, see hilt.md.
Anti-Patterns
| Anti-pattern | Why it hurts | Better replacement |
|---|---|---|
| DTOs used directly in UI state | UI coupled to API contract, breaks on API changes | Map to domain models at repository boundary |
| Network calls in composables | Violates UDF, untestable, reruns on recomposition | Call from ViewModel, expose via StateFlow |
| No timeout configuration | Requests hang indefinitely on bad networks | Set connectTimeoutMillis, requestTimeoutMillis, socketTimeoutMillis |
| Hardcoded base URLs | Can't switch environments (dev/staging/prod) | Inject base URL via config or DI |
| Parsing/mapping in the API service | Mixes concerns, harder to test | API service returns DTOs; repository maps to domain |
Creating a new HttpClient per test |
Tests miss plugin-config mismatches | Use the same createHttpClient factory with MockEngine |
| No compression | Wastes bandwidth on text-heavy APIs | install(ContentEncoding) { gzip() } |