n4nAI

Unit testing LLM API calls in Kotlin with MockWebServer

Step-by-step kotlin mockwebserver llm api testing tutorial: mock OpenAI-compatible endpoints, assert requests, and run deterministic LLM client unit tests.

n4n Team3 min read766 words

Audio narration

Coming soon — every post will get a voice note here.

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 refused and a false sense of “network code works.”
  • Shared state between tests: always create a fresh MockWebServer per 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.

Tagskotlintestingmockwebserverllm-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All kotlin & android llm integration posts →