Repository: kroxylicious/kroxylicious
Pull request #483 (2023-07-25 02:10:49)

Support configuring multiple responses in MockServer
### Type of change

- Enhancement / new feature

### Description

Why:
The OutOfBandRequestIT used an unsupported pattern to forward a response from within a sendRequest callback using a reference to the request context. By enabling the mocks server to have a separate response configured per api key we can use a supported pattern, storing the result of the send request in a member field to be augmented into a response later.

Resoves #460

### Checklist

_Please go through this checklist and make sure all applicable tasks have been done_

- [ ] Write tests
- [ ] Make sure all tests pass
- [ ] Review performance test results. Ensure that any degradations to performance numbers are understood and justified.
- [ ] Make sure all Sonar-Lint warnings are addressed or are justifiably ignored.
- [ ] Update documentation
- [ ] Reference relevant issue(s) and close them after merging
- [ ] For user facing changes, update CHANGELOG.md (remember to include changes affecting the API of the test artefacts too).


sonatype-lift[bot]: ## Sonatype Lift is retiring
Sonatype Lift will be retiring on <b>Sep 12, 2023</b>, with its analysis stopping on Aug 12, 2023. We understand that this news may come as a disappointment, and Sonatype is committed to helping you transition off it seamlessly. If you’d like to retain your data, please export your issues from the web console.
We are extremely grateful and thank you for your support over the years.

<b>[📖 Read about the impacts and timeline](https://www.sonatype.com/lift-retirement)</b>

Patch:
diff --git a/pom.xml b/pom.xml
index 390a4b48..1ce0b6e2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -50,6 +50,7 @@
         <zjsonpatch.version>0.4.14</zjsonpatch.version>
         <sundr-builder-annotations.version>0.100.1</sundr-builder-annotations.version>
         <findbugs.annotations.version>3.0.1u2</findbugs.annotations.version>
+        <hamcrest.version>2.2</hamcrest.version>
     </properties>
 
     <name>Kroxylicious Parent</name>
@@ -229,6 +230,11 @@
                 <artifactId>slf4j-simple</artifactId>
                 <version>${slf4j.version}</version>
             </dependency>
+            <dependency>
+                <groupId>org.hamcrest</groupId>
+                <artifactId>hamcrest</artifactId>
+                <version>${hamcrest.version}</version>
+            </dependency>
 
             <!-- third party dependencies - test -->
             <dependency>


Test patch:
diff --git a/integrationtests/src/test/java/io/kroxylicious/proxy/ApiVersionsIT.java b/integrationtests/src/test/java/io/kroxylicious/proxy/ApiVersionsIT.java
index 353796e8..05a36904 100644
--- a/integrationtests/src/test/java/io/kroxylicious/proxy/ApiVersionsIT.java
+++ b/integrationtests/src/test/java/io/kroxylicious/proxy/ApiVersionsIT.java
@@ -84,7 +84,7 @@ public class ApiVersionsIT {
             ApiVersionsResponseData.ApiVersion version = new ApiVersionsResponseData.ApiVersion();
             version.setApiKey((short) 9999).setMinVersion((short) 3).setMaxVersion((short) 4);
             mockResponse.apiKeys().add(version);
-            tester.setMockResponse(new Response(ApiKeys.API_VERSIONS, (short) 3, mockResponse));
+            tester.addMockResponseForApiKey(new Response(ApiKeys.API_VERSIONS, (short) 3, mockResponse));
             Response response = whenGetApiVersionsFromKroxylicious(client);
             assertEquals(ApiKeys.API_VERSIONS, response.apiKeys());
             assertEquals((short) 3, response.apiVersion());
@@ -102,7 +102,7 @@ public class ApiVersionsIT {
         ApiVersionsResponseData.ApiVersion version = new ApiVersionsResponseData.ApiVersion();
         version.setApiKey(keys.id).setMinVersion(minVersion).setMaxVersion(maxVersion);
         mockResponse.apiKeys().add(version);
-        tester.setMockResponse(new Response(ApiKeys.API_VERSIONS, (short) 3, mockResponse));
+        tester.addMockResponseForApiKey(new Response(ApiKeys.API_VERSIONS, (short) 3, mockResponse));
     }
 
     private static Response whenGetApiVersionsFromKroxylicious(KafkaClient client) {
diff --git a/integrationtests/src/test/java/io/kroxylicious/proxy/KrpcFilterIT.java b/integrationtests/src/test/java/io/kroxylicious/proxy/KrpcFilterIT.java
index 5e99eee5..e6ea418c 100644
--- a/integrationtests/src/test/java/io/kroxylicious/proxy/KrpcFilterIT.java
+++ b/integrationtests/src/test/java/io/kroxylicious/proxy/KrpcFilterIT.java
@@ -159,7 +159,7 @@ public class KrpcFilterIT {
 
             // check no topic created on the cluster
             Set<String> names = admin.listTopics().names().get(10, TimeUnit.SECONDS);
-            assertThat(names).isEmpty();
+            assertThat(names).doesNotContain(TOPIC_1);
         }
     }
 
@@ -181,7 +181,7 @@ public class KrpcFilterIT {
 
             // check no topic created on the cluster
             Set<String> names = admin.listTopics().names().get(10, TimeUnit.SECONDS);
-            assertThat(names).isEmpty();
+            assertThat(names).doesNotContain(TOPIC_1);
         }
     }
 
@@ -199,9 +199,9 @@ public class KrpcFilterIT {
                 .addToFilters(new FilterDefinitionBuilder("CompositePrefixingFixedClientId")
                         .withConfig("clientId", "banana", "prefix", "123").build()));
                 var singleRequestClient = tester.singleRequestClient()) {
-            tester.setMockResponse(new Response(METADATA, METADATA.latestVersion(), new MetadataResponseData()));
+            tester.addMockResponseForApiKey(new Response(METADATA, METADATA.latestVersion(), new MetadataResponseData()));
             singleRequestClient.getSync(new Request(METADATA, METADATA.latestVersion(), "client", new MetadataRequestData()));
-            assertEquals("123banana", tester.onlyRequest().clientIdHeader());
+            assertEquals("123banana", tester.getOnlyRequest().clientIdHeader());
         }
     }
 
diff --git a/integrationtests/src/test/java/io/kroxylicious/proxy/OutOfBandRequestIT.java b/integrationtests/src/test/java/io/kroxylicious/proxy/OutOfBandRequestIT.java
index f38d5f54..2316489e 100644
--- a/integrationtests/src/test/java/io/kroxylicious/proxy/OutOfBandRequestIT.java
+++ b/integrationtests/src/test/java/io/kroxylicious/proxy/OutOfBandRequestIT.java
@@ -16,6 +16,7 @@ import org.apache.kafka.common.message.CreateTopicsResponseData;
 import org.apache.kafka.common.message.DescribeClusterRequestData;
 import org.apache.kafka.common.message.DescribeClusterResponseData;
 import org.apache.kafka.common.protocol.ApiKeys;
+import org.apache.kafka.common.protocol.Errors;
 import org.junit.jupiter.api.Test;
 
 import io.kroxylicious.proxy.config.FilterDefinition;
@@ -30,6 +31,7 @@ import io.kroxylicious.test.tester.MockServerKroxyliciousTester;
 import static io.kroxylicious.UnknownTaggedFields.unknownTaggedFieldsToStrings;
 import static io.kroxylicious.proxy.filter.RequestResponseMarkingFilter.FILTER_NAME_TAG;
 import static org.apache.kafka.common.protocol.ApiKeys.CREATE_TOPICS;
+import static org.apache.kafka.common.protocol.ApiKeys.DESCRIBE_CLUSTER;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
 /**
@@ -54,12 +56,21 @@ public class OutOfBandRequestIT {
                 upstreamFilter);
                 var client = tester.singleRequestClient()) {
             givenMockReturnsArbitraryCreateTopicResponse(tester);
+            givenMockReturnsArbitraryDescribeClusterResponse(tester);
             DescribeClusterResponseData responseData = whenDescribeCluster(client);
             thenResponseContainsTagsAugmentedInByUpstreamFilterOnly(responseData);
             andMessageFromOutOfBandRequestToMockHadTagAddedByUpstreamFilterOnly(tester);
+            tester.assertAllMockInteractionsInvoked();
         }
     }
 
+    private static void givenMockReturnsArbitraryDescribeClusterResponse(MockServerKroxyliciousTester tester) {
+        DescribeClusterResponseData message = new DescribeClusterResponseData();
+        message.setErrorMessage("arbitrary");
+        message.setErrorCode(Errors.UNSUPPORTED_VERSION.code());
+        tester.addMockResponseForApiKey(new Response(DESCRIBE_CLUSTER, DESCRIBE_CLUSTER.latestVersion(), message));
+    }
+
     private static MockServerKroxyliciousTester createMockTesterWithFilters(FilterDefinition... definitions) {
         List<FilterDefinition> filterDefs = Arrays.stream(definitions).toList();
         return KroxyliciousTesters.mockKafkaKroxyliciousTester(s -> KroxyliciousConfigUtils.proxy(s).addAllToFilters(filterDefs));
@@ -74,7 +85,7 @@ public class OutOfBandRequestIT {
     }
 
     private static void andMessageFromOutOfBandRequestToMockHadTagAddedByUpstreamFilterOnly(MockServerKroxyliciousTester tester) {
-        Request request = tester.onlyRequest();
+        Request request = tester.getOnlyRequestForApiKey(CREATE_TOPICS);
         String tags = unknownTaggedFieldsToStrings(request.message(), FILTER_NAME_TAG)
                 .collect(Collectors.joining(","));
         assertEquals("RequestResponseMarkingFilter-upstreamOfOutOfBandFilter-request", tags);
@@ -97,6 +108,6 @@ public class OutOfBandRequestIT {
         topic.setReplicationFactor((short) 3);
         topic.setNumPartitions(3);
         message.topics().add(topic);
-        tester.setMockResponse(new Response(CREATE_TOPICS, CREATE_TOPICS.latestVersion(), message));
+        tester.addMockResponseForApiKey(new Response(CREATE_TOPICS, CREATE_TOPICS.latestVersion(), message));
     }
-}
+}
\ No newline at end of file
diff --git a/integrationtests/src/test/java/io/kroxylicious/proxy/ProxyRpcTest.java b/integrationtests/src/test/java/io/kroxylicious/proxy/ProxyRpcTest.java
index ffc2fd78..f402f399 100644
--- a/integrationtests/src/test/java/io/kroxylicious/proxy/ProxyRpcTest.java
+++ b/integrationtests/src/test/java/io/kroxylicious/proxy/ProxyRpcTest.java
@@ -72,10 +72,10 @@ public class ProxyRpcTest {
     @MethodSource("scenarios")
     @ParameterizedTest
     public void testKroxyliciousCanDecodeManipulateAndProxyRPC(Scenario scenario) {
-        mockTester.setMockResponse(scenario.givenMockResponse());
+        mockTester.addMockResponseForApiKey(scenario.givenMockResponse());
         try (KafkaClient kafkaClient = mockTester.singleRequestClient()) {
             Response response = kafkaClient.getSync(scenario.whenSendRequest());
-            assertEquals(scenario.thenMockReceivesRequest(), mockTester.onlyRequest(), "unexpected request received at mock for scenario: " + scenario.name());
+            assertEquals(scenario.thenMockReceivesRequest(), mockTester.getOnlyRequest(), "unexpected request received at mock for scenario: " + scenario.name());
             assertEquals(scenario.thenResponseReceived(), response, "unexpected response received from kroxylicious for scenario: " + scenario.name());
         }
     }
diff --git a/integrationtests/src/test/java/io/kroxylicious/proxy/filter/OutOfBandSendFilter.java b/integrationtests/src/test/java/io/kroxylicious/proxy/filter/OutOfBandSendFilter.java
index 11ed6e01..d599ef74 100644
--- a/integrationtests/src/test/java/io/kroxylicious/proxy/filter/OutOfBandSendFilter.java
+++ b/integrationtests/src/test/java/io/kroxylicious/proxy/filter/OutOfBandSendFilter.java
@@ -13,6 +13,7 @@ import org.apache.kafka.common.message.CreateTopicsRequestData;
 import org.apache.kafka.common.message.DescribeClusterRequestData;
 import org.apache.kafka.common.message.DescribeClusterResponseData;
 import org.apache.kafka.common.message.RequestHeaderData;
+import org.apache.kafka.common.message.ResponseHeaderData;
 import org.apache.kafka.common.protocol.ApiKeys;
 import org.apache.kafka.common.protocol.ApiMessage;
 import org.apache.kafka.common.protocol.Errors;
@@ -31,9 +32,10 @@ import static io.kroxylicious.UnknownTaggedFields.unknownTaggedFieldsToStrings;
  * a DescribeClusterResponse. This exposes what unknown tagged fields were added to the out-of-band response by other
  * filters like {@link RequestResponseMarkingFilter}.
  */
-public class OutOfBandSendFilter implements DescribeClusterRequestFilter {
+public class OutOfBandSendFilter implements DescribeClusterRequestFilter, DescribeClusterResponseFilter {
 
     private final OutOfBandSendFilterConfig config;
+    private String values = "<initial>";
 
     public OutOfBandSendFilter(OutOfBandSendFilterConfig config) {
         this.config = config;
@@ -57,14 +59,17 @@ public class OutOfBandSendFilter implements DescribeClusterRequestFilter {
         ApiKeys apiKeyToSend = config.apiKeyToSend;
         ApiMessage message = createApiMessage(apiKeyToSend);
         context.sendRequest(apiKeyToSend.latestVersion(), message).thenAccept(apiMessage -> {
-            String values = unknownTaggedFieldsToStrings(apiMessage, config.tagIdToCollect).collect(Collectors.joining(","));
-            // WARNING: forwarding from within the completion is not generally supported yet, this only works because the test is strictly controlling
-            // requests being sent to Kroxylicious. If used in a proxy between a real Kafka client and broker, this could cause out-of-order responses to
-            // the client, causing client side errors. We use it here so that we can see the outcome of the sendRequest call with a single request to Kroxylicious.
-            context.forwardResponse(
-                    new DescribeClusterResponseData().setErrorCode(Errors.UNKNOWN_SERVER_ERROR.code())
-                            .setErrorMessage("filterNameTaggedFieldsFromOutOfBandResponse: " + values));
+            // expected to execute before onDescribeClusterResponse becase sendRequest called before forwardRequest
+            values = unknownTaggedFieldsToStrings(apiMessage, config.tagIdToCollect).collect(Collectors.joining(","));
         });
+        context.forwardRequest(header, request);
+    }
+
+    @Override
+    public void onDescribeClusterResponse(short apiVersion, ResponseHeaderData header, DescribeClusterResponseData response, KrpcFilterContext context) {
+        response.setErrorCode(Errors.UNKNOWN_SERVER_ERROR.code())
+                .setErrorMessage("filterNameTaggedFieldsFromOutOfBandResponse: " + values);
+        context.forwardResponse(response);
     }
 
     private static ApiMessage createApiMessage(ApiKeys apiKeyToSend) {
diff --git a/kroxylicious-test-tools/pom.xml b/kroxylicious-test-tools/pom.xml
index 28e3b53d..ab82538d 100644
--- a/kroxylicious-test-tools/pom.xml
+++ b/kroxylicious-test-tools/pom.xml
@@ -82,6 +82,10 @@
             <groupId>io.sundr</groupId>
             <artifactId>builder-annotations</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.hamcrest</groupId>
+            <artifactId>hamcrest</artifactId>
+        </dependency>
 
         <!-- third party dependencies - test -->
         <dependency>
diff --git a/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/server/MockHandler.java b/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/server/MockHandler.java
index 8a432ef6..210d6e5d 100644
--- a/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/server/MockHandler.java
+++ b/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/server/MockHandler.java
@@ -8,14 +8,22 @@ package io.kroxylicious.test.server;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Collectors;
 
 import org.apache.kafka.common.message.ResponseHeaderData;
+import org.apache.kafka.common.protocol.ApiKeys;
 import org.apache.kafka.common.protocol.ApiMessage;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.StringDescription;
+import org.hamcrest.TypeSafeMatcher;
 
 import io.netty.channel.ChannelHandler.Sharable;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.channel.ChannelInboundHandlerAdapter;
 
+import io.kroxylicious.test.Request;
 import io.kroxylicious.test.codec.DecodedRequestFrame;
 import io.kroxylicious.test.codec.DecodedResponseFrame;
 
@@ -31,7 +39,11 @@ import io.kroxylicious.test.codec.DecodedResponseFrame;
 @Sharable
 public class MockHandler extends ChannelInboundHandlerAdapter {
 
-    private ApiMessage message;
+    private record ConditionalMockResponse(Matcher<Request> matcher, ApiMessage message, AtomicLong invocations) {
+
+    }
+
+    private List<ConditionalMockResponse> conditionalMockResponses = new ArrayList<>();
 
     private final List<DecodedRequestFrame<?>> requests = new ArrayList<>();
 
@@ -40,7 +52,9 @@ public class MockHandler extends ChannelInboundHandlerAdapter {
      * @param message message to respond with, nullable
      */
     public MockHandler(ApiMessage message) {
-        this.message = message;
+        if (message != null) {
+            setMockResponseForApiKey(ApiKeys.forId(message.apiKey()), message);
+        }
     }
 
     @Override
@@ -51,13 +65,10 @@ public class MockHandler extends ChannelInboundHandlerAdapter {
 
     private void respond(ChannelHandlerContext ctx, DecodedRequestFrame<?> frame) {
         requests.add(frame);
-        if (message == null) {
-            // we allow a null message to enable tests to start the mock server
-            // before they set the expectation.
-            throw new RuntimeException("response message not set");
-        }
+        ConditionalMockResponse message = conditionalMockResponses.stream().filter(r -> r.matcher.matches(MockServer.toRequest(frame))).findFirst().orElseThrow();
         DecodedResponseFrame<?> responseFrame = new DecodedResponseFrame<>(frame.apiVersion(),
-                frame.correlationId(), new ResponseHeaderData().setCorrelationId(frame.correlationId()), message);
+                frame.correlationId(), new ResponseHeaderData().setCorrelationId(frame.correlationId()), message.message());
+        message.invocations.incrementAndGet();
         ctx.write(responseFrame);
     }
 
@@ -77,8 +88,22 @@ public class MockHandler extends ChannelInboundHandlerAdapter {
      * Set the response
      * @param response response
      */
-    public void setResponse(ApiMessage response) {
-        message = response;
+    public void setMockResponseForApiKey(ApiKeys keys, ApiMessage response) {
+        addMockResponse(new TypeSafeMatcher<>() {
+            @Override
+            protected boolean matchesSafely(Request request) {
+                return request.apiKeys() == keys;
+            }
+
+            @Override
+            public void describeTo(Description description) {
+                description.appendText("has key " + keys);
+            }
+        }, response);
+    }
+
+    public void addMockResponse(Matcher<Request> matcher, ApiMessage response) {
+        conditionalMockResponses.add(new ConditionalMockResponse(matcher, response, new AtomicLong(0)));
     }
 
     /**
@@ -89,10 +114,23 @@ public class MockHandler extends ChannelInboundHandlerAdapter {
         return Collections.unmodifiableList(requests);
     }
 
+    public void assertAllMockInteractionsInvoked() {
+        List<ConditionalMockResponse> anyUninvoked = conditionalMockResponses.stream().filter(r -> r.invocations.get() <= 0).toList();
+        if (!anyUninvoked.isEmpty()) {
+            String collect = anyUninvoked.stream().map(conditionalMockResponse -> {
+                StringDescription stringDescription = new StringDescription();
+                conditionalMockResponse.matcher.describeTo(stringDescription);
+                return "mock response was never invoked: " + stringDescription;
+            }).collect(Collectors.joining(","));
+            throw new AssertionError(collect);
+        }
+    }
+
     /**
      * Clear recorded requests
      */
     public void clear() {
         requests.clear();
+        conditionalMockResponses.clear();
     }
 }
diff --git a/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/server/MockServer.java b/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/server/MockServer.java
index a5a0a990..f7faaba7 100644
--- a/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/server/MockServer.java
+++ b/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/server/MockServer.java
@@ -7,6 +7,9 @@ package io.kroxylicious.test.server;
 
 import java.net.InetSocketAddress;
 import java.util.List;
+import java.util.Objects;
+
+import org.hamcrest.Matcher;
 
 import io.netty.bootstrap.ServerBootstrap;
 import io.netty.channel.Channel;
@@ -42,11 +45,22 @@ public final class MockServer implements AutoCloseable {
     }
 
     /**
-     * Set the response to be served by the MockServer
+     * Adds a response to be served by the MockServer if the request api key matches the response api key
+     * @param response the response (nullable)
+     */
+    public void addMockResponseForApiKey(Response response) {
+        Objects.requireNonNull(response);
+        serverHandler.setMockResponseForApiKey(response.apiKeys(), response.message());
+    }
+
+    /**
+     * Set the response to be served by the MockServer if the request matches.
      * @param response the response (nullable)
      */
-    public void setResponse(Response response) {
-        serverHandler.setResponse(response == null ? null : response.message());
+    public void addMockResponse(Matcher<Request> requestMatcher, Response response) {
+        Objects.requireNonNull(response);
+        Objects.requireNonNull(requestMatcher);
+        serverHandler.addMockResponse(requestMatcher, response.message());
     }
 
     /**
@@ -57,7 +71,7 @@ public final class MockServer implements AutoCloseable {
         return serverHandler.getRequests().stream().map(MockServer::toRequest).toList();
     }
 
-    private static Request toRequest(DecodedRequestFrame<?> decodedRequestFrame) {
+    static Request toRequest(DecodedRequestFrame<?> decodedRequestFrame) {
         return new Request(decodedRequestFrame.apiKey(), decodedRequestFrame.apiVersion(), decodedRequestFrame.header().clientId(), decodedRequestFrame.body());
     }
 
@@ -145,7 +159,10 @@ public final class MockServer implements AutoCloseable {
      * Clear the response and tell the serverHandler to clear its collection of received requests.
      */
     public void clear() {
-        setResponse(null);
         serverHandler.clear();
     }
+
+    public void assertAllMockInteractionsInvoked() {
+        this.serverHandler.assertAllMockInteractionsInvoked();
+    }
 }
diff --git a/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/tester/MockServerKroxyliciousTester.java b/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/tester/MockServerKroxyliciousTester.java
index 5043d378..91866079 100644
--- a/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/tester/MockServerKroxyliciousTester.java
+++ b/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/tester/MockServerKroxyliciousTester.java
@@ -9,6 +9,9 @@ package io.kroxylicious.test.tester;
 import java.util.List;
 import java.util.function.Function;
 
+import org.apache.kafka.common.protocol.ApiKeys;
+import org.hamcrest.Matcher;
+
 import io.kroxylicious.proxy.config.ConfigurationBuilder;
 import io.kroxylicious.test.Request;
 import io.kroxylicious.test.Response;
@@ -31,11 +34,20 @@ public class MockServerKroxyliciousTester extends DefaultKroxyliciousTester {
     }
 
     /**
-     * Set the response to be served by the MockServer
-     * @param response the response (nullable)
+     * Add a response to be served by the MockServer for the ApiKey in the response
+     * @param response the response
+     */
+    public void addMockResponseForApiKey(Response response) {
+        mockServer.addMockResponseForApiKey(response);
+    }
+
+    /**
+     * Add a response to be served by the MockServer for the ApiKey in the response
+     * @param requestMatcher the matcher
+     * @param response the response
      */
-    public void setMockResponse(Response response) {
-        mockServer.setResponse(response);
+    public void addMockResponse(Matcher<Request> requestMatcher, Response response) {
+        mockServer.addMockResponse(requestMatcher, response);
     }
 
     /**
@@ -50,7 +62,7 @@ public class MockServerKroxyliciousTester extends DefaultKroxyliciousTester {
      * @return the only request
      * @throws IllegalStateException if the mock server has recorded more than one request received
      */
-    public Request onlyRequest() {
+    public Request getOnlyRequest() {
         List<Request> requests = mockServer.getReceivedRequests();
         if (requests.size() != 1) {
             throw new IllegalStateException("mock server has recorded " + requests.size() + " requests, expected one");
@@ -58,6 +70,27 @@ public class MockServerKroxyliciousTester extends DefaultKroxyliciousTester {
         return requests.get(0);
     }
 
+    /**
+     * Assert that all configured interactions has at least one interaction.
+     * @throws AssertionError if any mocked interaction has not been invoked
+     */
+    public void assertAllMockInteractionsInvoked() {
+        this.mockServer.assertAllMockInteractionsInvoked();
+    }
+
+    /**
+     * Get the only request recorded by the MockServer for a given ApiKey
+     * @return the only request
+     * @throws IllegalStateException if the mock server has recorded more than one request received for that key
+     */
+    public Request getOnlyRequestForApiKey(ApiKeys apiKeys) {
+        List<Request> requests = mockServer.getReceivedRequests().stream().filter(request -> request.apiKeys() == apiKeys).toList();
+        if (requests.size() != 1) {
+            throw new IllegalStateException("mock server has recorded " + requests.size() + " requests, expected one");
+        }
+        return requests.get(0);
+    }
+
     /**
      * Close the mocks erver and kroxylicious proxy
      */
@@ -66,5 +99,4 @@ public class MockServerKroxyliciousTester extends DefaultKroxyliciousTester {
         mockServer.close();
         super.close();
     }
-
 }
diff --git a/kroxylicious-test-tools/src/test/java/io/kroxylicious/test/tester/KroxyliciousTestersTest.java b/kroxylicious-test-tools/src/test/java/io/kroxylicious/test/tester/KroxyliciousTestersTest.java
index 5eca047f..0c9eadd1 100644
--- a/kroxylicious-test-tools/src/test/java/io/kroxylicious/test/tester/KroxyliciousTestersTest.java
+++ b/kroxylicious-test-tools/src/test/java/io/kroxylicious/test/tester/KroxyliciousTestersTest.java
@@ -193,7 +193,7 @@ public class KroxyliciousTestersTest {
         DescribeAclsResponseData data = new DescribeAclsResponseData();
         data.setErrorMessage("helllo");
         data.setErrorCode(Errors.UNKNOWN_SERVER_ERROR.code());
-        tester.setMockResponse(new Response(ApiKeys.DESCRIBE_ACLS, ApiKeys.DESCRIBE_ACLS.latestVersion(), data));
+        tester.addMockResponseForApiKey(new Response(ApiKeys.DESCRIBE_ACLS, ApiKeys.DESCRIBE_ACLS.latestVersion(), data));
         Response response = kafkaClient
                 .getSync(new Request(ApiKeys.DESCRIBE_ACLS, ApiKeys.DESCRIBE_ACLS.latestVersion(), "client", new DescribeAclsRequestData()));
         assertInstanceOf(DescribeAclsResponseData.class, response.message());