diff --git a/src/main/java/io/github/ktestify/match/MatchContext.java b/src/main/java/io/github/ktestify/match/MatchContext.java
index 58dba8b..70710aa 100644
--- a/src/main/java/io/github/ktestify/match/MatchContext.java
+++ b/src/main/java/io/github/ktestify/match/MatchContext.java
@@ -73,6 +73,20 @@ public class MatchContext {
/** Expected value for {@link #matchKey}. */
String matchValue;
+ /**
+ * Multiple key/value pairs for multi-field inline matching.
+ *
+ *
When non-empty, the matcher validates that every key in the map has the corresponding expected value
+ * in the actual record. This is the multi-field equivalent of {@link #matchKey} / {@link #matchValue}.
+ *
+ *
Single-field callers should continue to use {@link #matchKey} and {@link #matchValue}; this map should be left
+ * empty (the default).
+ *
+ * @since 1.1.1
+ */
+ @Builder.Default
+ Map matchKeyValues = Collections.emptyMap();
+
/**
* Expected transport-attribute key/value pairs (see
* {@link io.github.ktestify.models.ConsumedRecord#getAttributes()}).
@@ -90,7 +104,7 @@ public class MatchContext {
* {@code null} if the list is empty.
*/
public String getMatchFilePath() {
- return matchFilePaths != null && !matchFilePaths.isEmpty() ? matchFilePaths.get(0) : null;
+ return matchFilePaths != null && !matchFilePaths.isEmpty() ? matchFilePaths.getFirst() : null;
}
/**
diff --git a/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java
index f6220ed..d19e3d6 100644
--- a/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java
+++ b/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java
@@ -23,7 +23,9 @@
import io.github.ktestify.utils.FileUtils;
import io.github.ktestify.utils.serdes.AvroDeserializer;
import io.github.ktestify.utils.serdes.AvroUtils;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.avro.generic.GenericRecord;
@@ -44,11 +46,16 @@ public class AvroFieldsRecordMatcher implements RecordMatcher {
public MatchResult match(List> records, MatchContext context)
throws ComparisonException {
+ // Multi-field inline matching (keys/values columns)
+ if (context.getMatchKeyValues() != null && !context.getMatchKeyValues().isEmpty()) {
+ return matchMultipleFields(records, context);
+ }
+
if (context.getMatchKey() == null || context.getMatchKey().isBlank()) {
throw new ComparisonException("AvroFieldsRecordMatcher requires matchKey (the field name) to be set.");
}
- GenericRecord value = records.get(0).getValue();
+ GenericRecord value = records.getFirst().getValue();
String actualValue = toJson(value);
String key = context.getMatchKey();
@@ -83,6 +90,40 @@ public MatchResult match(List> records, MatchConte
throw new ComparisonException("AvroFieldsRecordMatcher requires either matchValue or matchFilePath to be set.");
}
+ /**
+ * Validates every key/value pair in {@link MatchContext#getMatchKeyValues()} against the actual Avro record.
+ *
+ * All pairs must match for the result to pass. The first mismatch is reported in the diff.
+ *
+ * @param records the consumed records (only the first is examined)
+ * @param context the match context carrying the key/value pairs
+ * @return a {@link MatchResult} indicating whether all fields matched
+ * @throws ComparisonException if the key/value map is empty (should not happen, guarded by the caller)
+ * @since 1.1.1
+ */
+ private MatchResult matchMultipleFields(List> records, MatchContext context)
+ throws ComparisonException {
+ GenericRecord value = records.getFirst().getValue();
+ String actualValue = toJson(value);
+ Map keyValues = context.getMatchKeyValues();
+
+ log.debug("Avro multi-field match against inline values : pairs: {}", keyValues);
+
+ List mismatches = new ArrayList<>();
+ for (Map.Entry entry : keyValues.entrySet()) {
+ String key = entry.getKey();
+ String expectedVal = entry.getValue();
+ if (!AvroUtils.doesAvroValueFromKeyMatchesRecord(expectedVal, key, actualValue)) {
+ mismatches.add("Avro field '" + key + "' does not match expected value '" + expectedVal + "'.");
+ }
+ }
+
+ if (mismatches.isEmpty()) {
+ return MatchResult.pass(keyValues.toString(), actualValue);
+ }
+ return MatchResult.fail(String.join(" ", mismatches), keyValues.toString(), actualValue);
+ }
+
private String toJson(GenericRecord value) {
if (value.getSchema() != null) {
return AvroUtils.getPrettyAvroValue(
diff --git a/src/test/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcherTest.java b/src/test/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcherTest.java
index 38a0dfd..97d622c 100644
--- a/src/test/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcherTest.java
+++ b/src/test/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcherTest.java
@@ -21,6 +21,8 @@
import io.github.ktestify.exceptions.ComparisonException;
import io.github.ktestify.match.MatchContext;
import io.github.ktestify.match.MatchResult;
+import java.util.LinkedHashMap;
+import java.util.Map;
import org.apache.avro.Schema;
import org.apache.avro.SchemaBuilder;
import org.apache.avro.generic.GenericData;
@@ -77,6 +79,62 @@ void fieldDoesNotMatchInlineValue() throws ComparisonException {
}
}
+ @Nested
+ @DisplayName("Multi-field inline value matching")
+ class MultiFieldValue {
+
+ @Test
+ @DisplayName("passes when all specified fields match their inline expected values")
+ void allFieldsMatchInlineValues() throws ComparisonException {
+ Map keyValues = new LinkedHashMap<>();
+ keyValues.put("status", "CREATED");
+ keyValues.put("customerId", "CUST-42");
+ MatchResult result = matcher.match(
+ avroRecord("key", buildRecord("ORD-001", "CUST-42", 99.99, "CREATED")),
+ ctxWithKeyValues(keyValues));
+ assertTrue(result.isPassed());
+ }
+
+ @Test
+ @DisplayName("fails when one of the specified fields does not match")
+ void oneFieldDoesNotMatch() throws ComparisonException {
+ Map keyValues = new LinkedHashMap<>();
+ keyValues.put("status", "CREATED");
+ keyValues.put("customerId", "CUST-99");
+ MatchResult result = matcher.match(
+ avroRecord("key", buildRecord("ORD-001", "CUST-42", 99.99, "SHIPPED")),
+ ctxWithKeyValues(keyValues));
+ assertFalse(result.isPassed());
+ assertTrue(result.getDiff().contains("status"));
+ assertTrue(result.getDiff().contains("customerId"));
+ }
+
+ @Test
+ @DisplayName("fails when all specified fields do not match")
+ void noFieldsMatch() throws ComparisonException {
+ Map keyValues = new LinkedHashMap<>();
+ keyValues.put("status", "CREATED");
+ keyValues.put("orderId", "ORD-999");
+ MatchResult result = matcher.match(
+ avroRecord("key", buildRecord("ORD-001", "CUST-42", 99.99, "SHIPPED")),
+ ctxWithKeyValues(keyValues));
+ assertFalse(result.isPassed());
+ assertTrue(result.getDiff().contains("status"));
+ assertTrue(result.getDiff().contains("orderId"));
+ }
+
+ @Test
+ @DisplayName("passes with a single entry in the key/value map")
+ void singleEntryKeyValues() throws ComparisonException {
+ Map keyValues = new LinkedHashMap<>();
+ keyValues.put("status", "CREATED");
+ MatchResult result = matcher.match(
+ avroRecord("key", buildRecord("ORD-001", "CUST-42", 99.99, "CREATED")),
+ ctxWithKeyValues(keyValues));
+ assertTrue(result.isPassed());
+ }
+ }
+
@Nested
@DisplayName("File-based matching")
class FileBased {
diff --git a/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java b/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java
index 16036a5..4ed40c3 100644
--- a/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java
+++ b/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java
@@ -111,4 +111,9 @@ static MatchContext ctxWithKey(String matchKey) {
static MatchContext ctxWithKeyAndValue(String matchKey, String matchValue) {
return MatchContext.builder().matchKey(matchKey).matchValue(matchValue).build();
}
+
+ /** Builds a {@link MatchContext} with multiple key/value pairs for multi-field inline matching. */
+ static MatchContext ctxWithKeyValues(Map keyValues) {
+ return MatchContext.builder().matchKeyValues(keyValues).build();
+ }
}