-->

Mokksy

Mokksy is a mock HTTP server built with Kotlin and Ktor.

Why? Wiremock does not support true SSE and streaming responses. Mokksy is here to address those limitations. It's particularly useful for integration testing LLM clients.

Add Mokksy to your project dependencies:

1// For JVM projects
2testImplementation("dev.mokksy.mokksy.mokksy-jvm:$latestVersion")
3
4// For Kotlin Multiplatform projects
5testImplementation("dev.mokksy.mokksy.mokksy:$latestVersion")
1
2<dependency>
3  <groupId>dev.mokksy</groupId>
4  <artifactId>mokksy-jvm</artifactId>
5  <version>[LATEST_VERSION]</version>
6  <scope>test</scope>
7</dependency>
 1// Create and start Mokksy instance
 2val mokksy = Mokksy()
 3
 4// Configure a response for a GET request
 5mokksy.get {
 6  path("/ping")
 7} respondsWith {
 8  // language=json
 9  body = """{"response": "Pong"}"""
10}
11
12// Use the server URL in your client
13val serverUrl = mokksy.baseUrl
14
15// [create a client and send a request here]
16
17// Shutdown Mokksy when done
18mokksy.shutdown()

This snippet shows how to use the Mokksy server for testing.
It starts and configures a server so that any HTTP GET request to /ping returns {"response": "Pong"}.
It also retrieves the server’s base URL for client requests and demonstrates how to shut down the server after testing.

 1// given
 2val expectedResponse =
 3    // language=json
 4    """
 5    {
 6        "response": "Pong"
 7    }
 8    """.trimIndent()
 9
10mokksy.get {
11    path = beEqual("/ping")
12    containsHeader("Foo", "bar")
13} respondsWith {
14    body = expectedResponse
15}
16
17// when
18val result = client.get("/ping") { 
19    headers.append("Foo", "bar")
20}
21
22// then
23result.status shouldBe HttpStatusCode.OK
24result.bodyAsText() shouldBe expectedResponse

POST Request

 1// given
 2val id = Random.nextInt()
 3val expectedResponse =
 4    // language=json
 5    """
 6    {
 7        "id": "$id",
 8        "name": "thing-$id"
 9    }
10    """.trimIndent()
11
12mokksy.post {
13    path = beEqual("/things")
14    bodyContains("\"$id\"")
15} respondsWith {
16    body = expectedResponse
17    httpStatus = HttpStatusCode.Created
18    headers {
19        // type-safe builder style
20        append(HttpHeaders.Location, "/things/$id")
21    }
22    headers += "Foo" to "bar" // list style
23}
24
25// when
26val result =
27    client.post("/things") {
28        headers.append("Content-Type", "application/json")
29        setBody(
30            // language=json
31            """
32            {
33                "id": "$id"
34            }
35            """.trimIndent(),
36        )
37    }
38
39// then
40assertThat(result.status).isEqualTo(HttpStatusCode.Created)
41assertThat(result.bodyAsText()).isEqualTo(expectedResponse)
42assertThat(result.headers["Location"]).isEqualTo("/things/$id")
43assertThat(result.headers["Foo"]).isEqualTo("bar")

Mokksy provides various matcher types to specify conditions for matching incoming HTTP requests:

  • Path Matchers

    • path - Exact match for request path
    • Example: path("/things")
  • Content Matchers

    • bodyContains - Checks if the body contains specific text
    • Example: bodyContains("value") or bodyString += contain("value")
  • Header Matchers

    • containsHeader - Checks if the request contains a specific header with value
    • Example: containsHeader("X-Request-ID", "RequestID")
  • Predicate Matchers

    • predicateMatcher - Custom predicate function to match against request body
    • Example: bodyMatchesPredicate { it?.name == "foo" }
  • Call Matchers

    • successCallMatcher - Matches if a function call with the request body succeeds
    • Example: requestSatisfies { input -> input.shouldNotBeNull() }

Server-Side Events (SSE) is a technology that allows a server to push updates to the client over a single, long-lived HTTP connection. This enables real-time updates without requiring the client to continuously poll the server for new data.

SSE streams events in a standardized format, making it easy for clients to consume the data and handle events as they arrive. It's lightweight and efficient, particularly well-suited for applications requiring real-time updates like live notifications or feed updates.

 1mokksy.post {
 2    path = beEqual("/sse")
 3} respondsWithSseStream {
 4    flow =
 5        flow {
 6            delay(200.milliseconds)
 7            emit(
 8                ServerSentEvent(
 9                    data = "One",
10                ),
11            )
12            delay(50.milliseconds)
13            emit(
14                ServerSentEvent(
15                    data = "Two",
16                ),
17            )
18        }
19}
20
21// when
22val result = client.post("/sse")
23
24// then
25assertThat(result.status)
26    .isEqualTo(HttpStatusCode.OK)
27assertThat(result.contentType())
28    .isEqualTo(ContentType.Text.EventStream.withCharsetIfNeeded(Charsets.UTF_8))
29assertThat(result.bodyAsText())
30    .isEqualTo("data: One\r\ndata: Two\r\n")

Mokksy provides two complementary verification methods that check opposite sides of the stub/request contract.

checkForUnmatchedStubs() fails if any registered stub was never matched by an incoming request. Use this to catch stubs you set up but that were never actually called — a sign the code under test took a different path than expected.

1// Fails if any stub has matchCount == 0
2mokksy.checkForUnmatchedStubs()

checkForUnmatchedRequests() fails if any HTTP request arrived at the server but no stub matched it. These requests are recorded in the request journal and reported together.

1// Fails if any request arrived with no matching stub (unexpected request)
2mokksy.checkForUnmatchedRequests()

Run both checks after every test to catch mismatch in either direction:

 1class MyTest {
 2
 3    private val mokksy = Mokksy()
 4
 5    @AfterEach
 6    fun afterEach() {
 7      mokksy.checkForUnmatchedRequests() // no unexpected HTTP calls
 8      mokksy.checkForUnmatchedStubs()    // no unused stubs
 9    }
10
11  @Test
12    fun testSomething() {
13        TODO("Write your test here")
14    }
15}

Use the find* variants to retrieve the unmatched items directly for custom assertions:

1// List<RecordedRequest> — HTTP requests with no matching stub
2val unmatchedRequests: List<RecordedRequest> = mokksy.findAllUnmatchedRequests()
3
4// List<RequestSpecification<*>> — stubs that were never triggered
5val unmatchedStubs: List<RequestSpecification<*>> = mokksy.findAllUnmatchedStubs()

RecordedRequest is an immutable snapshot that captures the method, uri, and headers of the incoming request.

Mokksy records incoming requests in a RequestJournal. The recording mode is controlled by JournalMode in ServerConfiguration:

ModeBehaviour
JournalMode.LEAN (default)Records only requests with no matching stub. Lower overhead. Sufficient for checkForUnmatchedRequests().
JournalMode.FULLRecords all incoming requests — both matched and unmatched.
1val mokksy = Mokksy(
2  configuration = ServerConfiguration(
3    journalMode = JournalMode.FULL
4  )
5)

Use FULL mode when you need a complete picture of traffic, for example to debug unexpected call patterns by combining both lists:

1val unmatched = mokksy.findAllUnmatchedRequests()
2// unmatched is empty only if every request found a matching stub

Call resetMatchCounts() between tests to clear both stub match counts and the journal:

1@AfterEach
2fun afterEach() {
3  mokksy.resetMatchCounts()
4}