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.
Core Features
- Flexibility to control server response directly via
ApplicationCallobject - Built with Kotest Assertions
- Fluent modern Kotlin DSL API
- Support for simulating streamed responses and Server-Side Events (SSE)
Installation
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>
Basic Usage
Creating Mokksy Server
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.
Responding with Predefined Responses
GET Request
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")
Request Specification Matchers
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")orbodyString += 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) Response
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")
Verifying Requests
Mokksy provides two complementary verification methods that check opposite sides of the stub/request contract.
Verify all stubs were triggered
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()
Verify no unexpected requests arrived
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()
Recommended AfterEach setup
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}
Inspecting unmatched items
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.
Request Journal
Mokksy records incoming requests in a RequestJournal. The recording mode is controlled by JournalMode in
ServerConfiguration:
| Mode | Behaviour |
|---|---|
JournalMode.LEAN (default) | Records only requests with no matching stub. Lower overhead. Sufficient for checkForUnmatchedRequests(). |
JournalMode.FULL | Records 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}