Testing LLM integrations without hitting live servers is non-negotiable for CI. This guide walks through kotlin mockwebserver llm api testing to mock HTTP calls, assert request shapes, and verify client logic offline.
Step 1: Add dependencies for MockWebServer and JSON handling
You need mockwebserver from OkHttp and a JSON library. I prefer kotlinx.serialization for concise mapping, but Moshi or Gson work equally well.
// build.gradle.kts
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}
MockWebServer ships inside the OkHttp artifact, so version alignment is trivial. Keep the version identical to your OkHttp client to avoid NoSuchMethodError surprises at runtime. If you use a multi-module project, declare the test dependency only where tests live.
Step 2: Manage MockWebServer lifecycle in tests
Spin up the server before each test and shut it down after. JUnit 5 with @BeforeEach/@AfterEach is the cleanest.
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.*
class LlmClientTest {
private lateinit var server: MockWebServer
@BeforeEach
fun setUp() {
server = MockWebServer()
server.start()
}
@AfterEach
fun tearDown() {
server.shutdown()
}
}
The server binds to a random available port on localhost. Access its URL via server.url("/"). This returns an HttpUrl you convert to string for client config. Never hardcode localhost:8080 in tests; the random port prevents collisions in parallel CI jobs.
Step 3: Point your LLM client at the mock
Write a minimal client that posts to an OpenAI-compatible /v1/chat/completions endpoint. If your production code targets n4n.ai—an OpenRouter-class gateway with one OpenAI-compatible endpoint covering 240+ models—you swap the base URL to the mock in tests. The client should accept a baseUrl parameter and an optional auth token.
class LlmClient(
private val baseUrl: String,
private val apiKey: String = "test",
private val http: OkHttpClient = OkHttpClient()
) {
suspend fun chat(model: String, prompt: String): String {
val body = Json.encodeToString(
ChatRequest(model, listOf(Message("user", prompt)))
)
val req = Request.Builder()
.url("$baseUrl/v1/chat/completions")
.addHeader("Authorization", "Bearer $apiKey")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
val resp = http.newCall(req).execute()
if (!resp.isSuccessful) error("HTTP ${resp.code}")
val json = resp.body!!.string()
return Json.decodeFromString<ChatResponse>(json).choices.first().message.content
}
}
@Serializable
data class ChatRequest(val model: String, val messages: List<Message>)
@Serializable
data class ChatResponse(val choices: List<Choice>)
@Serializable
data class Choice(val message: Message)
@Serializable
data class Message(val role: String, val content: String)
In the test, instantiate LlmClient(server.url("").toString()). No real network leaves the JVM. The serialization layer is exercised exactly as in production.
Step 4: Enqueue simulated LLM responses
MockWebServer uses a queue. Call enqueue with a MockResponse. Provide a realistic JSON payload that matches the structured response you expect.
val mockJson = """
{
"choices": [
{"message": {"role": "assistant", "content": "The capital of France is Paris."}}
]
}
""".trimIndent()
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(mockJson)
)
You can enqueue multiple responses to simulate retries or pagination. If you need to test provider cache-control hints, set the appropriate headers in the MockResponse (e.g., setHeader("X-Cache", "HIT")). The server dispatches them in FIFO order.
Step 5: Write the test that exercises your call
Use runTest from coroutines-test. Call the client and capture the result.
@Test
fun `chat returns parsed content`() = runTest {
server.enqueue(
MockResponse().setResponseCode(200)
.setBody("""{"choices":[{"message":{"role":"assistant","content":"42"}}]}""")
)
val client = LlmClient(server.url("").toString())
val answer = client.chat("gpt-4o-mini", "What is the answer?")
assertEquals("42", answer)
}
This test fails if the client misparses or if the endpoint path is wrong, because MockWebServer records exactly what it received. The coroutine test runner also catches unconfined dispatchers that would otherwise hang.
Step 6: Assert on the recorded request
The real value of kotlin mockwebserver llm api testing is verifying outgoing shape. Pull the RecordedRequest after the call.
val recorded = server.takeRequest()
assertEquals("POST", recorded.method)
assertEquals("/v1/chat/completions", recorded.path)
assertEquals("Bearer test", recorded.getHeader("Authorization"))
val sentBody = Json.parseToJsonElement(recorded.body.readUtf8())
assertEquals("gpt-4o-mini", sentBody.jsonObject["model"]?.jsonPrimitive?.content)
val messages = sentBody.jsonObject["messages"]?.jsonArray
assertEquals(1, messages?.size)
assertEquals("user", messages?.get(0)?.jsonObject?.get("role")?.jsonPrimitive?.content)
If you forward provider routing directives or cache-control hints, assert those headers exist. For instance, recorded.getHeader("X-Route-Model") should match your intent. This catches regressions where a refactor drops a header.
Step 7: Simulate errors and fallback logic
Production LLM gateways degrade. Test your retry or fallback path by enqueuing a 500 then a 200.
server.enqueue(MockResponse().setResponseCode(500))
server.enqueue(MockResponse().setBody("""{"choices":[{"message":{"content":"recovered"}}]}"""))
val client = LlmClient(server.url("").toString())
var result: String? = null
repeat(2) {
runCatching { result = client.chat("model", "hi") }
}
assertEquals("recovered", result)
If you rely on automatic fallback when a provider is rate-limited, mock the 429 with a Retry-After header and confirm your client backs off or switches model. A 429 response with body {"error":"rate_limit"} is easy to craft.
server.enqueue(
MockResponse()
.setResponseCode(429)
.setHeader("Retry-After", "1")
.setBody("""{"error":"rate_limit"}""")
)
Step 8: Test streaming and partial responses
Many LLM APIs support SSE streams. MockWebServer can emit chunks with setBodyDelay or by manually writing to the socket, but for unit tests it is simpler to simulate a non-streaming fallback.
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "text/event-stream")
.setBody("data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n")
)
Assert your client either handles the stream or throws a clear unsupported error. Keep streaming tests at the transport boundary; parse the SSE frame in a separate pure-function test.
Step 9: Verify success and integrate with CI
A green test run with zero external DNS lookups proves your kotlin mockwebserver llm api testing setup works. Verify by asserting the request count matches enqueued calls.
assertEquals(1, server.requestCount)
In CI, disable network access for the test task to catch accidental real calls. Gradle’s test task can be wrapped with a firewall rule or run in a container with no egress. Keep tests deterministic: never use Thread.sleep for timing; use takeRequest(longTimeout) if you must wait.
Common pitfalls
- Forgetting to start the server: leads to
Connection refusedand a false sense of “network code works.” - Shared state between tests: always create a fresh
MockWebServerper test method. - Asserting on pretty-printed JSON: compare parsed structures, not raw strings, to avoid whitespace diffs.
- Ignoring response headers: cache directives and rate-limit headers are part of the contract.
Write these as separate @Test functions. The MockWebServer queue is per-instance, so each test gets a fresh server in @BeforeEach.
Final note on structure
Separate the transport (OkHttp) from the domain mapping. That lets you test the mapping with plain objects and reserve MockWebServer for end-to-end request/response cycles. If you use a generated OpenAI Kotlin client, wrap it so the base URL is injectable. The technique scales to any OpenAI-compatible endpoint, including local proxies and multi-model gateways.
By following these steps, you get fast, offline, and deterministic coverage of the HTTP contract between your Kotlin code and the LLM API.