Files
vnidrop/.codex/skills/compose-skill/references/networking-ktor-testing.md
Hammed Abass fd4fff2c86 docs: add compose development guidelines and skills
Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory.

Co-authored-by: Junie <junie@jetbrains.com>
2026-07-07 16:55:43 +02:00

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() }