mokksy

Project Url: mokksy/mokksy
Introduction: Mokksy is a mock HTTP server built with Kotlin and Ktor
More: Author   ReportBugs   OfficialWebsite   
Tags:

Maven Central Build

Codacy Badge Codacy Badge codecov

Kotlin API Java Kotlin Multiplatform GitHub License

API Reference Ask DeepWiki

mokksy-mascot-256.png

Mokksy - Mock HTTP Server, built with Kotlin and Ktor.

Check out the AI-Mocks project for advanced LLM and A2A protocol mocking capabilities.

[!NOTE] Mokksy server was a part of the AI-Mocks project and has now moved to a separate repository. No artefact relocation is required.

Buy me a Coffee

Table of Contents

Why Mokksy?

Wiremock does not support true SSE and streaming responses.

Mokksy is here to address those limitations. Particularly, it might be useful for integration testing LLM clients.

Key Features

  • Streaming Support: True support for streaming responses and Server-Side Events (SSE)
  • Response Control: Flexibility to control server responses directly via ApplicationCall object
  • Delay Simulation: Support for simulating response delays and delays between chunks
  • Modern API: Fluent Kotlin DSL API with Kotest Assertions
  • Error Simulation: Ability to mock negative scenarios and error responses
  • Specificity-Based Matching: When multiple stubs match a request, Mokksy automatically selects the most specific one — no explicit priority configuration required for common cases

Quick start

  1. Add dependencies:

    Gradle build.gradle.kts:

    dependencies {               
         // for multiplatform projects
        implementation("dev.mokksy:mokksy:$latestVersion")
         // for JVM projects
        implementation("dev.mokksy:mokksy-jvm:$latestVersion")
    }
    

    pom.xml:

     <dependency>
         <groupId>dev.mokksy</groupId>
         <artifactId>mokksy-jvm</artifactId>
         <version>[LATEST_VERSION]</version>
         <scope>test</scope>
     </dependency>
    
  1. Create and start Mokksy server:

    JVM (blocking):

    val mokksy = Mokksy().apply {
        runBlocking { 
            startSuspend() 
        }
    }
    
  2. Configure http client using Mokksy server's as baseUrl in your application:

val client = HttpClient {
  install(DefaultRequest) {
    url(mokksy.baseUrl())
  }
}

Responding with predefined responses

Mokksy supports all HTTP verbs. Here are some examples.

GET request

GET request example:

// given
val expectedResponse =
  // language=json
  """
    {
        "response": "Pong"

    }
    """.trimIndent()

mokksy.get {
  path = beEqual("/ping")
  containsHeader("Foo", "bar")
} respondsWith {
  body = expectedResponse
}

// when
val result = client.get("/ping") {
  headers.append("Foo", "bar")
}

// then
result.status shouldBe HttpStatusCode.OK
result.bodyAsText() shouldBe expectedResponse

When the request does not match - Mokksy server returns 404 (Not Found):

val notFoundResult = client.get("/ping") {
  headers.append("Foo", "baz")
}

notFoundResult.status shouldBe HttpStatusCode.NotFound

POST request

POST request example:

// given
val id = Random.nextInt()
val expectedResponse =
  // language=json
  """
    {
        "id": "$id",
        "name": "thing-$id"
    }
    """.trimIndent()

mokksy.post {
  path = beEqual("/things")
  bodyContains("\"$id\"")
} respondsWith {
  body = expectedResponse
  httpStatus = HttpStatusCode.Created
  headers {
    // type-safe builder style
    append(HttpHeaders.Location, "/things/$id")
  }
  headers += "Foo" to "bar" // list style
}

// when
val result =
  client.post("/things") {
    headers.append("Content-Type", "application/json")
    setBody(
      // language=json
      """
            {
                "id": "$id"
            }
            """.trimIndent(),
    )
  }

// then
result.status shouldBe HttpStatusCode.Created
result.bodyAsText() shouldBe expectedResponse
result.headers["Location"] shouldBe "/things/$id"
result.headers["Foo"] shouldBe "bar"

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.

Server-Side Events (SSE) example:

mokksy.post {
  path = beEqual("/sse")
} respondsWithSseStream {
  flow =
    flow {
      delay(200.milliseconds)
      emit(
        ServerSentEvent(
          data = "One",
        ),
      )
      delay(50.milliseconds)
      emit(
        ServerSentEvent(
          data = "Two",
        ),
      )
    }
}

// when
val result = client.post("/sse")

// then
result.status shouldBe HttpStatusCode.OK
result.contentType() shouldBe ContentType.Text.EventStream.withCharsetIfNeeded(Charsets.UTF_8)
result.bodyAsText() shouldBe "data: One\r\ndata: Two\r\n"

Request Specification Matchers

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

  • Path matcherspath("/things") or path = beEqual("/things")
  • Header matcherscontainsHeader("X-Request-ID", "abc") checks for a header with an exact value
  • Content matchersbodyContains("value") checks if the raw body string contains a substring; bodyString += contain("value") adds a Kotest matcher directly
  • Predicate matchersbodyMatchesPredicate { it?.name == "foo" } matches against the typed, deserialized request body
  • Call matcherssuccessCallMatcher matches if a function called with the body does not throw
  • Prioritypriority = 10 on RequestSpecificationBuilder sets the RequestSpecification.priority of the stub; lower values indicate higher priority. Default is Int.MAX_VALUE. Priority is a tiebreaker: it applies only when two stubs match with an equal number of conditions satisfied. For most cases, specificity-based matching (see below) selects the right stub automatically.

Stub Specificity

When multiple stubs could match the same request, Mokksy scores each one by counting how many conditions it satisfies, then selects the highest-scoring stub. A stub with two matching conditions beats a stub with one, regardless of registration order.

// Generic: matches any POST to /users
mokksy.post {
    path("/users")
} respondsWith {
    body = "any user"
}

// Specific: matches only requests whose body contains "admin" — two conditions
mokksy.post {
    path("/users")
    bodyContains("admin")
} respondsWith {
    body = "admin user"
}

// Admin request → specific stub wins (score 2 beats score 1)
val adminResult = client.post("/users") { setBody("admin") }
adminResult.bodyAsText() shouldBe "admin user"

// Other request → only the generic stub matches
val genericResult = client.post("/users") { setBody("regular") }
genericResult.bodyAsText() shouldBe "any user"

When no stub matches and verbose mode is enabled (Mokksy(verbose = true)), Mokksy logs the closest partial match and its failed conditions to help you diagnose the mismatch.

Priority Example

If multiple stubs match with the same specificity score, the one with the lower priority value wins:

// Catch-all stub with low priority (high value)
mokksy.get {
  path = contain("/things")
  priority = 99
} respondsWith {
  body = "Generic Thing"
}

// Specific stub with high priority (low value)
mokksy.get {
  path = beEqual("/things/special")
  priority = 1
} respondsWith {
  body = "Special Thing"
}

// when
val generic = client.get("/things/123")
val special = client.get("/things/special")

// then
generic.bodyAsText() shouldBe "Generic Thing"
special.bodyAsText() shouldBe "Special Thing"

Verifying Requests

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

Verify all stubs were triggered

verifyNoUnmatchedStubs() 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.

// Fails if any stub has never been matched
mokksy.verifyNoUnmatchedStubs()

Note: Be careful when running tests in parallel against a single MokksyServer instance. Some stubs might be unmatched when one test completes. Avoid calling this in @AfterEach/@AfterTest unless each test owns its own server instance.

Verify no unexpected requests arrived

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

// Fails if any request arrived with no matching stub
mokksy.verifyNoUnexpectedRequests()

Recommended AfterEach setup

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

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class MyTest {

    val mokksy = Mokksy()
    lateinit var client: HttpClient

    @BeforeAll
    suspend fun setup() {
        mokksy.startSuspend()
        mokksy.awaitStarted() // port() and baseUrl() are safe after this point
        client = HttpClient {
            install(DefaultRequest) {
                url(mokksy.baseUrl())
            }
        }
    }

    @Test
    suspend fun testSomething() {
        mokksy.get {
            path("/hi")
        } respondsWith {
            delay = 100.milliseconds // wait 100ms, then reply
            body = "Hello"
        }

        // when
        val response = client.get("/hi")

        // then
        response.status shouldBe HttpStatusCode.OK
        response.bodyAsText() shouldBe "Hello"
    }

    @AfterEach
    fun afterEach() {
        mokksy.verifyNoUnexpectedRequests()
    }

    @AfterAll
    suspend fun afterAll() {
        client.close()
        mokksy.shutdownSuspend()
    }
}

Inspecting unmatched items

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

// List<RecordedRequest> — HTTP requests with no matching stub
val unmatchedRequests: List<RecordedRequest> = mokksy.findAllUnexpectedRequests()

// List<RequestSpecification<*>> — stubs that were never triggered
val unmatchedStubs: List<RequestSpecification<*>> = mokksy.findAllUnmatchedStubs()

RecordedRequest is an immutable snapshot that captures 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 verifyNoUnexpectedRequests().
JournalMode.FULL Records all incoming requests — both matched and unmatched.
val mokksy = Mokksy(
    configuration = ServerConfiguration(
        journalMode = JournalMode.FULL,
    ),
)

Call resetMatchState() between scenarios to clear stub match state and the journal:

@AfterTest
fun afterEach() {
    mokksy.resetMatchState()
}

Note: Stubs configured with eventuallyRemove = true are permanently removed from the registry on first match and cannot be re-armed by resetMatchState(). Re-register them before the next scenario.

Apps
About Me
GitHub: Trinea
Facebook: Dev Tools