Stubbing responses

Mokksy supports all HTTP verbs. Here are some examples.

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
 1// given
 2var expectedResponse = "{\"response\": \"Pong\"}";
 3
 4mokksy.get(spec -> {
 5    spec.path("/ping");
 6    spec.containsHeader("Foo", "bar");
 7}).respondsWith(builder -> builder.body(expectedResponse));
 8
 9// when
10var response = httpClient.send(
11    HttpRequest.newBuilder()
12        .uri(URI.create(mokksy.baseUrl() + "/ping"))
13        .header("Foo", "bar")
14        .GET()
15        .build(),
16    HttpResponse.BodyHandlers.ofString()
17);
18
19// then
20assertThat(response.statusCode()).isEqualTo(200);
21assertThat(response.body()).isEqualTo(expectedResponse);

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

1val notFoundResult = client.get("/ping") {
2  headers.append("Foo", "baz")
3}
4
5notFoundResult.status shouldBe HttpStatusCode.NotFound
 1// Request without the required header → 404
 2var notFound = httpClient.send(
 3    HttpRequest.newBuilder()
 4        .uri(URI.create(mokksy.baseUrl() + "/ping"))
 5        .header("Foo", "baz")
 6        .GET()
 7        .build(),
 8    HttpResponse.BodyHandlers.ofString()
 9);
10
11assertThat(notFound.statusCode()).isEqualTo(404);

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
40result shouldNotBeNull {
41  status shouldBe HttpStatusCode.Created
42  bodyAsText() shouldBe expectedResponse
43  headers["Location"] shouldBe "/things/$id"
44  headers["Foo"] shouldBe "bar"
45}
 1// given
 2var expectedBody = "{\"id\":\"42\",\"name\":\"thing-42\"}";
 3
 4mokksy.post(spec -> {
 5    spec.path("/things");
 6    spec.bodyContains("\"42\"");
 7}).respondsWith(builder -> builder
 8    .body(expectedBody)
 9    .status(201)
10    .header("Location", "/things/42")
11    .header("Foo", "bar"));
12
13// when
14var response = httpClient.send(
15    HttpRequest.newBuilder()
16        .uri(URI.create(mokksy.baseUrl() + "/things"))
17        .header("Content-Type", "application/json")
18        .POST(HttpRequest.BodyPublishers.ofString("{\"id\":\"42\"}"))
19        .build(),
20    HttpResponse.BodyHandlers.ofString()
21);
22
23// then
24assertThat(response.statusCode()).isEqualTo(201);
25assertThat(response.body()).isEqualTo(expectedBody);
26assertThat(response.headers().firstValue("Location")).hasValue("/things/42");
27assertThat(response.headers().firstValue("Foo")).hasValue("bar");

Typed request body

When the request body type is known at compile time, use the reified overloads to let the compiler infer the type — no explicit ::class argument required:

1@Serializable
2@JvmRecord
3data class CreateItemRequest(val name: String, val quantity: Int)
4
5@Serializable
6@JvmRecord
7data class CreateItemResponse(val message: String)
1record CreateItemRequest(String name, int quantity) {}
2record CreateItemResponse(String message) {}

Reified overloads

 1val itemName = "Widget"
 2
 3mokksy.post<CreateItemRequest>(name = "create-item") {
 4  path("/items")
 5  bodyMatchesPredicate("name should match") { it?.name == itemName }
 6} respondsWith {
 7  body = CreateItemResponse("Hello, $itemName!")
 8  httpStatus = HttpStatusCode.Created
 9  headers += "Foo" to "bar"
10}
11
12val result =
13  client.post("/items") {
14    contentType(ContentType.Application.Json)
15    setBody(CreateItemRequest(itemName, quantity = 3))
16  }
17
18result shouldNotBeNull {
19  status shouldBe HttpStatusCode.Created
20  headers["Foo"] shouldBe "bar"
21  body<CreateItemResponse>().message shouldBe "Hello, $itemName!"
22}
 1record CreateItemRequest(String name, int quantity) {}
 2
 3mokksy.post(
 4    CreateItemRequest.class,
 5    spec -> spec
 6        .path("/items")
 7        .bodyMatchesPredicate(request -> "widget".equals(request.name()))
 8).respondsWith(builder -> builder
 9    .body("{\"message\":\"Hello, widget!\"}")
10    .status(201)
11    .header("Foo", "bar"));
12
13var response = httpClient.send(
14    HttpRequest.newBuilder()
15        .uri(URI.create(mokksy.baseUrl() + "/items"))
16        .header("Content-Type", "application/json")
17        .POST(HttpRequest.BodyPublishers.ofString("{\"name\":\"widget\",\"quantity\":3}"))
18        .build(),
19    HttpResponse.BodyHandlers.ofString()
20);
21
22assertThat(response.statusCode()).isEqualTo(201);
23assertThat(response.body()).isEqualTo("{\"message\":\"Hello, widget!\"}");
24assertThat(response.headers().firstValue("Foo")).hasValue("bar");

Reified overloads are provided for all HTTP verbs (get, post, put, delete, patch, head, options) and the generic method function. Two overloads exist per verb: one taking an optional stub name (name: String? = null) and one taking a StubConfiguration.

The deserialized request body is accessible inside the response lambda as request.body().

Explicit Class token

When the type is determined at runtime or when you want an explicit name on the stub, pass a kotlin.reflect.KClass / java.lang.Class token using the named requestType parameter:

 1mokksy.post(requestType = CreateItemRequest::class) {
 2  path("/items/validated")
 3  bodyMatchesPredicate("name=widget and quantity>=5") {
 4    it?.name == "widget" && (it.quantity) >= 5
 5  }
 6} respondsWith {
 7  body = "accepted"
 8  httpStatus = HttpStatusCode.Created
 9}
10
11val accepted =
12  client.post("/items/validated") {
13    contentType(ContentType.Application.Json)
14    setBody(CreateItemRequest("widget", quantity = 10))
15  }
16
17accepted.status shouldBe HttpStatusCode.Created
18accepted.bodyAsText() shouldBe "accepted"
 1mokksy.post(
 2    CreateItemRequest.class,
 3    spec -> spec
 4        .path("/items/validated")
 5        .bodyMatchesPredicate(
 6            "name=widget and quantity>=5",
 7            request -> "widget".equals(request.name()) && request.quantity() >= 5
 8        )
 9).respondsWith(builder -> builder.body("accepted").status(201));
10
11var accepted = httpClient.send(
12    HttpRequest.newBuilder()
13        .uri(URI.create(mokksy.baseUrl() + "/items/validated"))
14        .header("Content-Type", "application/json")
15        .POST(HttpRequest.BodyPublishers.ofString("{\"name\":\"widget\",\"quantity\":10}"))
16        .build(),
17    HttpResponse.BodyHandlers.ofString()
18);
19
20assertThat(accepted.statusCode()).isEqualTo(201);

Java supports typed request bodies too. Pass the request class token directly, as shown in the Java tabs above, and use the Kotlin examples as the canonical shape for typed request-body matching.

Deserialization uses Ktor's ContentNegotiation plugin. For projects that use Jackson instead of kotlinx.serialization, create the server with MokksyJackson.create() (Java API) — see Jackson support below.

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

Jackson support

By default, Mokksy uses kotlinx.serialization for request body deserialization. Java and Kotlin projects that prefer Jackson can configure the server with Ktor's Jackson content negotiation. In the example below JacksonInput is deserialized from the request body, and JacksonOutput is serialized to response body.

 1val jacksonMokksy =
 2  MokksyServer(
 3    configuration =
 4      ServerConfiguration(
 5        verbose = true,
 6        contentNegotiationConfigurer = {
 7          it.jackson { findAndRegisterModules() }
 8        },
 9      ),
10  ).apply { start() }
11
12val jacksonClient =
13  HttpClient(Java) {
14    install(ContentNegotiation) {
15      jackson()
16    }
17    install(DefaultRequest) {
18      url(jacksonMokksy.baseUrl())
19    }
20  }
21
22jacksonMokksy
23  .post(requestType = JacksonInput::class) {
24    path = beEqual("/jackson")
25  }.respondsWith(JacksonOutput::class) {
26    val input = request.body()
27    body = JacksonOutput("Hello, ${input.name}")
28  }
29
30val result =
31  jacksonClient.post("/jackson") {
32    contentType(ContentType.Application.Json)
33    setBody(JacksonInput("Bob"))
34  }
35
36result.status shouldBe HttpStatusCode.OK
37result.bodyAsText() shouldBe """{"pikka-hi":"Hello, Bob"}"""
38
39jacksonMokksy.verifyNoUnexpectedRequests()

For Java-first projects that prefer Jackson, use MokksyJackson.create():

1import dev.mokksy.MokksyJackson;
2
3// Default Jackson ObjectMapper
4Mokksy mokksy = MokksyJackson.create();
5mokksy.start();

To customize the ObjectMapper, pass a configuration lambda:

1import com.fasterxml.jackson.databind.ObjectMapper;
2import dev.mokksy.MokksyJackson;
3
4Mokksy mokksy = MokksyJackson.create(ObjectMapper::findAndRegisterModules);
5mokksy.start();

Status-only responses

Use respondsWithStatus when the test only needs to verify a status code — no body needed. It's an infix function, so it reads naturally next to the stub definition:

1mokksy.get { path("/ping") } respondsWithStatus HttpStatusCode.NoContent
2
3val response = client.get("/ping")
4
5response.status shouldBe HttpStatusCode.NoContent
1mokksy.get(spec -> spec.path("/status-only"))
2    .respondsWithStatus(204);

One-time stubs

Use StubConfiguration(eventuallyRemove = true) when a stub should match exactly once and then become ineligible for future requests. This is the supported property for once-only behavior.

 1mokksy.get(
 2  configuration =
 3    StubConfiguration(
 4      name = "single-use",
 5      eventuallyRemove = true,
 6    ),
 7) {
 8  path("/once")
 9} respondsWith {
10  body = "First and only response"
11}
12
13client.get("/once").status shouldBe HttpStatusCode.OK
14client.get("/once").status shouldBe HttpStatusCode.NotFound
 1mokksy.get(StubConfiguration.once("single-use"), spec -> spec.path("/once"))
 2    .respondsWith(response -> response.body("First and only response"));
 3
 4var first = httpClient.send(
 5    HttpRequest.newBuilder()
 6        .uri(URI.create(mokksy.baseUrl() + "/once"))
 7        .GET()
 8        .build(),
 9    HttpResponse.BodyHandlers.ofString()
10);
11assertThat(first.statusCode()).isEqualTo(200);
12
13var second = httpClient.send(
14    HttpRequest.newBuilder()
15        .uri(URI.create(mokksy.baseUrl() + "/once"))
16        .GET()
17        .build(),
18    HttpResponse.BodyHandlers.ofString()
19);
20assertThat(second.statusCode()).isEqualTo(404);

Run code when a request is matched

respondsWith { ... } and respondsWithStream { ... } are lambdas. Mokksy evaluates the lambda for the matched request immediately before it builds the response, so you can inspect test state, coordinate concurrent code, or build a response from the incoming request.

Use this for assertions that must happen at the moment the dependency is called. In this example, the response lambda checks a coroutine Semaphore before returning the response:

 1val semaphore = Semaphore(permits = 0)
 2
 3mokksy.post {
 4  path("/jobs")
 5} respondsWith {
 6  semaphore.availablePermits shouldBe 0
 7  body = "accepted"
 8  httpStatus = HttpStatusCode.Accepted
 9  semaphore.release()
10}
11
12val response = client.post("/jobs")
13
14response.status shouldBe HttpStatusCode.Accepted
15response.bodyAsText() shouldBe "accepted"
16semaphore.availablePermits shouldBe 1
 1var semaphore = new Semaphore(0);
 2
 3mokksy.post(spec -> spec.path("/jobs"))
 4    .respondsWith(response -> {
 5        assertThat(semaphore.availablePermits()).isZero();
 6        response.status(202).body("accepted");
 7        semaphore.release();
 8    });
 9
10var result = httpClient.send(
11    HttpRequest.newBuilder()
12        .uri(URI.create(mokksy.baseUrl() + "/jobs"))
13        .POST(HttpRequest.BodyPublishers.noBody())
14        .build(),
15    HttpResponse.BodyHandlers.ofString()
16);
17
18assertThat(result.statusCode()).isEqualTo(202);
19assertThat(result.body()).isEqualTo("accepted");
20assertThat(semaphore.availablePermits()).isEqualTo(1);