You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@qpid.apache.org by ta...@apache.org on 2023/04/18 19:52:32 UTC

[qpid-protonj2] branch main updated: PROTON-2711 Add withProperty API to expectations

This is an automated email from the ASF dual-hosted git repository.

tabish pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/qpid-protonj2.git


The following commit(s) were added to refs/heads/main by this push:
     new ad91624c PROTON-2711 Add withProperty API to expectations
ad91624c is described below

commit ad91624cf391b045b0dd0f0016374babe19af8c9
Author: Timothy Bish <ta...@gmail.com>
AuthorDate: Tue Apr 18 15:42:46 2023 -0400

    PROTON-2711 Add withProperty API to expectations
    
    Allow for expectations to script single withProperty test expressions vs
    creating and adding a full map of entries.  Tests can just add an expectation
    for a single property value to be checked for.
---
 .../driver/expectations/AttachExpectation.java     |  10 +
 .../test/driver/expectations/BeginExpectation.java |  10 +
 .../test/driver/expectations/FlowExpectation.java  |  10 +
 .../test/driver/expectations/OpenExpectation.java  |  10 +
 .../test/driver/matchers/MapContentsMatcher.java   | 223 +++++++++++++++++++++
 .../driver/matchers/messaging/ModifiedMatcher.java |  21 +-
 .../driver/matchers/transport/AttachMatcher.java   |  21 +-
 .../driver/matchers/transport/BeginMatcher.java    |  21 +-
 .../driver/matchers/transport/FlowMatcher.java     |  26 ++-
 .../driver/matchers/transport/OpenMatcher.java     |  21 +-
 .../protonj2/test/driver/SenderHandlingTest.java   |  86 ++++++++
 .../driver/matcher/MapContentsMatcherTest.java     | 187 +++++++++++++++++
 12 files changed, 641 insertions(+), 5 deletions(-)

diff --git a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/AttachExpectation.java b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/AttachExpectation.java
index 6038afe2..7c7fbbd9 100644
--- a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/AttachExpectation.java
+++ b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/AttachExpectation.java
@@ -458,6 +458,16 @@ public class AttachExpectation extends AbstractExpectation<Attach> {
         return this;
     }
 
+    public AttachExpectation withProperty(String key, Object value) {
+        matcher.withProperty(key, value);
+        return this;
+    }
+
+    public AttachExpectation withProperty(Symbol key, Object value) {
+        matcher.withProperty(key, value);
+        return this;
+    }
+
     @Override
     protected Matcher<ListDescribedType> getExpectationMatcher() {
         return matcher;
diff --git a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/BeginExpectation.java b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/BeginExpectation.java
index 620cf643..3f7e4500 100644
--- a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/BeginExpectation.java
+++ b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/BeginExpectation.java
@@ -241,6 +241,16 @@ public class BeginExpectation extends AbstractExpectation<Begin> {
         return this;
     }
 
+    public BeginExpectation withProperty(String key, Object value) {
+        matcher.withProperty(key, value);
+        return this;
+    }
+
+    public BeginExpectation withProperty(Symbol key, Object value) {
+        matcher.withProperty(key, value);
+        return this;
+    }
+
     @Override
     protected Matcher<ListDescribedType> getExpectationMatcher() {
         return matcher;
diff --git a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/FlowExpectation.java b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/FlowExpectation.java
index ecb7266e..a68e7546 100644
--- a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/FlowExpectation.java
+++ b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/FlowExpectation.java
@@ -287,6 +287,16 @@ public class FlowExpectation extends AbstractExpectation<Flow> {
         return this;
     }
 
+    public FlowExpectation withProperty(String key, Object value) {
+        matcher.withProperty(key, value);
+        return this;
+    }
+
+    public FlowExpectation withProperty(Symbol key, Object value) {
+        matcher.withProperty(key, value);
+        return this;
+    }
+
     @Override
     protected Matcher<ListDescribedType> getExpectationMatcher() {
         return matcher;
diff --git a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/OpenExpectation.java b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/OpenExpectation.java
index ffac3cb6..ee888d5a 100644
--- a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/OpenExpectation.java
+++ b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/expectations/OpenExpectation.java
@@ -283,6 +283,16 @@ public class OpenExpectation extends AbstractExpectation<Open> {
         return this;
     }
 
+    public OpenExpectation withProperty(String key, Object value) {
+        matcher.withProperty(key, value);
+        return this;
+    }
+
+    public OpenExpectation withProperty(Symbol key, Object value) {
+        matcher.withProperty(key, value);
+        return this;
+    }
+
     @Override
     protected Matcher<ListDescribedType> getExpectationMatcher() {
         return matcher;
diff --git a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/MapContentsMatcher.java b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/MapContentsMatcher.java
new file mode 100644
index 00000000..b92e05eb
--- /dev/null
+++ b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/MapContentsMatcher.java
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.qpid.protonj2.test.driver.matchers;
+
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Objects;
+
+import org.hamcrest.Description;
+import org.hamcrest.TypeSafeMatcher;
+
+/**
+ * Matcher used to compare Map instance either for full or partial contents
+ * matches.
+ *
+ * @param <K> The key type used to define the Map entry.
+ * @param <V> The value type used to define the Map entry.
+ */
+public class MapContentsMatcher<K, V> extends TypeSafeMatcher<Map<K, V>> {
+
+    public enum MatcherMode {
+        /**
+         * As long as all the added expected entries match the Map is considered
+         * to be matching.
+         */
+        PARTIAL_MATCH,
+        /**
+         * As long as both Maps have the same contents (and size) the Map is
+         * considered to be matching.
+         */
+        CONTENTS_MATCH,
+        /**
+         * The contents and size of the Map must match the expectations but
+         * also the order of Map entries iterated over must match the order
+         * of the expectations as they were added.
+         */
+        EXACT_MATCH
+    }
+
+    private final Map<K, V> expectedContents = new LinkedHashMap<>();
+
+    private MatcherMode mode;
+    private String mismatchDescription;
+
+    /**
+     * Creates a matcher that matches if any of the expected entries is found
+     */
+    public MapContentsMatcher() {
+        this(MatcherMode.PARTIAL_MATCH);
+    }
+
+    /**
+     * Creates a matcher that matches if the expected entries are the only entries
+     * in the Map but doesn't check order.
+     *
+     * @param entries
+     * 		The entries that must be matched.
+     */
+    public MapContentsMatcher(Map<K, V> entries) {
+        this(MatcherMode.CONTENTS_MATCH);
+
+        entries.forEach((k, v) -> addExpectedEntry(k, v));
+    }
+
+    /**
+     * Creates a matcher that matches if the expected entries are the only entries
+     * in the Map but doesn't check order.
+     *
+     * @param entries
+     * 		The entries that must be matched.
+     * @param strictOrder
+     * 		Controls if order also considered when matching.
+     */
+    public MapContentsMatcher(Map<K, V> entries, boolean strictOrder) {
+        this(strictOrder ? MatcherMode.EXACT_MATCH : MatcherMode.CONTENTS_MATCH);
+
+        entries.forEach((k, v) -> addExpectedEntry(k, v));
+    }
+
+    /**
+     * Creates a new {@link Map} contents matcher with the given strict setting.
+     * <p>
+     * When in strict mode the contents must match both in the entry values and the
+     * number of entries expected vs the number of entries in the given {@link Map}.
+     *
+     * @param mode
+     * 		The matcher mode to use when performing the match.
+     */
+    public MapContentsMatcher(MatcherMode mode) {
+        this.mode = mode;
+    }
+
+    @Override
+    public void describeTo(Description description) {
+        description.appendText(mismatchDescription);
+    }
+
+    @Override
+    protected boolean matchesSafely(Map<K, V> item) {
+        switch (mode) {
+            case CONTENTS_MATCH:
+                return performContentsOnlyMatch(item);
+            case EXACT_MATCH:
+                return performInOrderContentsMatch(item);
+            case PARTIAL_MATCH:
+            default:
+                return performPartialMatch(item);
+        }
+    }
+
+    private boolean performMapInvariantsCheck(Map<K, V> map) {
+        if (map.isEmpty() && !expectedContents.isEmpty()) {
+            mismatchDescription = String.format("Expecting an empty map but got a map of size %s instead", expectedContents.size());
+            return false;
+        } else if (!map.isEmpty() && expectedContents.isEmpty()) {
+            mismatchDescription = String.format("Expecting map of size %s but got an empty map instead", expectedContents.size());
+            return false;
+        } else if (map.size() != expectedContents.size()) {
+            mismatchDescription = String.format("Expecting map with %s items but got a map of size %s instead",
+                                                expectedContents.size(), map.size());
+            return false;
+        } else {
+            return true;
+        }
+    }
+
+    private boolean performInOrderContentsMatch(Map<K, V> map) {
+        if (!performMapInvariantsCheck(map)) {
+            return false;
+        }
+
+        final Iterator<Entry<K, V>> mapIterator = map.entrySet().iterator();
+
+        for (Entry<K, V> entry : expectedContents.entrySet()) {
+             Entry<K, V> mapEntry = mapIterator.next();
+
+            if (!entry.getKey().equals(mapEntry.getKey())) {
+                mismatchDescription = String.format(
+                    "Expected to find a key matching %s but got %s", entry.getKey(), mapEntry.getKey());
+                return false;
+            }
+
+            if (entry.getValue() == null && mapEntry.getValue() == null) {
+                continue;
+            }
+
+            if (!entry.getValue().equals(mapEntry.getValue())) {
+                mismatchDescription = String.format(
+                    "Expected to find a value matching %s for key %s but got %s",
+                    entry.getKey(), entry.getValue(), mapEntry.getKey());
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    private boolean performContentsOnlyMatch(Map<K, V> map) {
+        if (!performMapInvariantsCheck(map)) {
+            return false;
+        }
+
+        for (Entry<K, V> entry : expectedContents.entrySet()) {
+            if (!map.containsKey(entry.getKey())) {
+                mismatchDescription = String.format(
+                    "Expected to find a key matching %s but it wasn't found in the Map", entry.getKey());
+                return false;
+            } else if (!Objects.equals(entry.getValue(), map.get(entry.getKey()))) {
+                mismatchDescription = String.format(
+                    "Expected to find a value matching %s for key %s but got %s",
+                    entry.getKey(), entry.getValue(), map.get(entry.getKey()));
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    private boolean performPartialMatch(Map<K, V> map) {
+        for (Entry<K, V> entry : expectedContents.entrySet()) {
+            if (!map.containsKey(entry.getKey())) {
+                mismatchDescription = String.format(
+                    "Expected to find a key matching %s but it wasn't found in the Map", entry.getKey());
+                return false;
+            } else if (!Objects.equals(entry.getValue(), map.get(entry.getKey()))) {
+                mismatchDescription = String.format(
+                    "Expected to find a value matching %s for key %s but got %s",
+                    entry.getKey(), entry.getValue(), map.get(entry.getKey()));
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    public void addExpectedEntry(K key, V value) {
+        expectedContents.put(key, value);
+    }
+
+    /**
+     * @return true if the Maps must match as equal or if only some contents need to match.
+     */
+    public boolean isStrictEaulityMatching() {
+        return mode.ordinal() > MatcherMode.PARTIAL_MATCH.ordinal();
+    }
+}
diff --git a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/messaging/ModifiedMatcher.java b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/messaging/ModifiedMatcher.java
index 82ac62f3..39f9d88d 100644
--- a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/messaging/ModifiedMatcher.java
+++ b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/messaging/ModifiedMatcher.java
@@ -24,10 +24,14 @@ import org.apache.qpid.protonj2.test.driver.codec.messaging.Modified;
 import org.apache.qpid.protonj2.test.driver.codec.primitives.Symbol;
 import org.apache.qpid.protonj2.test.driver.codec.util.TypeMapper;
 import org.apache.qpid.protonj2.test.driver.matchers.ListDescribedTypeMatcher;
+import org.apache.qpid.protonj2.test.driver.matchers.MapContentsMatcher;
 import org.hamcrest.Matcher;
 
 public class ModifiedMatcher extends ListDescribedTypeMatcher {
 
+    // Only used if singular 'withAnnotation' API is used
+    private MapContentsMatcher<Symbol, Object> annotationsMatcher;
+
     public ModifiedMatcher() {
         super(Modified.Field.values().length, Modified.DESCRIPTOR_CODE, Modified.DESCRIPTOR_SYMBOL);
     }
@@ -56,11 +60,26 @@ public class ModifiedMatcher extends ListDescribedTypeMatcher {
     }
 
     public ModifiedMatcher withMessageAnnotationsMap(Map<Symbol, Object> sectionNo) {
+        annotationsMatcher = null; // Clear these as this overrides anything else
         return withMessageAnnotations(equalTo(sectionNo));
     }
 
     public ModifiedMatcher withMessageAnnotations(Map<String, Object> sectionNo) {
-        return withMessageAnnotations(equalTo(TypeMapper.toSymbolKeyedMap(sectionNo)));
+        return withMessageAnnotationsMap(TypeMapper.toSymbolKeyedMap(sectionNo));
+    }
+
+    public ModifiedMatcher withMessageAnnotation(String key, Object value) {
+        return withMessageAnnotation(Symbol.valueOf(key), value);
+    }
+
+    public ModifiedMatcher withMessageAnnotation(Symbol key, Object value) {
+        if (annotationsMatcher == null) {
+            annotationsMatcher = new MapContentsMatcher<Symbol, Object>();
+        }
+
+        annotationsMatcher.addExpectedEntry(key, value);
+
+        return withMessageAnnotations(annotationsMatcher);
     }
 
     //----- Matcher based with methods for more complex validation
diff --git a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/AttachMatcher.java b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/AttachMatcher.java
index 31b04d8c..55a2cf87 100644
--- a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/AttachMatcher.java
+++ b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/AttachMatcher.java
@@ -36,6 +36,7 @@ import org.apache.qpid.protonj2.test.driver.codec.transport.Role;
 import org.apache.qpid.protonj2.test.driver.codec.transport.SenderSettleMode;
 import org.apache.qpid.protonj2.test.driver.codec.util.TypeMapper;
 import org.apache.qpid.protonj2.test.driver.matchers.ListDescribedTypeMatcher;
+import org.apache.qpid.protonj2.test.driver.matchers.MapContentsMatcher;
 import org.apache.qpid.protonj2.test.driver.matchers.messaging.SourceMatcher;
 import org.apache.qpid.protonj2.test.driver.matchers.messaging.TargetMatcher;
 import org.apache.qpid.protonj2.test.driver.matchers.transactions.CoordinatorMatcher;
@@ -43,6 +44,9 @@ import org.hamcrest.Matcher;
 
 public class AttachMatcher extends ListDescribedTypeMatcher {
 
+    // Only used if singular 'withProperty' API is used
+    private MapContentsMatcher<Symbol, Object> propertiesMatcher;
+
     public AttachMatcher() {
         super(Attach.Field.values().length, Attach.DESCRIPTOR_CODE, Attach.DESCRIPTOR_SYMBOL);
     }
@@ -178,11 +182,26 @@ public class AttachMatcher extends ListDescribedTypeMatcher {
     }
 
     public AttachMatcher withPropertiesMap(Map<Symbol, Object> properties) {
+        propertiesMatcher = null; // Clear these as this overrides anything else
         return withProperties(equalTo(properties));
     }
 
     public AttachMatcher withProperties(Map<String, Object> properties) {
-        return withProperties(equalTo(TypeMapper.toSymbolKeyedMap(properties)));
+        return withPropertiesMap(TypeMapper.toSymbolKeyedMap(properties));
+    }
+
+    public AttachMatcher withProperty(String key, Object value) {
+        return withProperty(Symbol.valueOf(key), value);
+    }
+
+    public AttachMatcher withProperty(Symbol key, Object value) {
+        if (propertiesMatcher == null) {
+            propertiesMatcher = new MapContentsMatcher<Symbol, Object>();
+        }
+
+        propertiesMatcher.addExpectedEntry(key, value);
+
+        return withProperties(propertiesMatcher);
     }
 
     //----- Matcher based with methods for more complex validation
diff --git a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/BeginMatcher.java b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/BeginMatcher.java
index 521dee5c..7acd6d0d 100644
--- a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/BeginMatcher.java
+++ b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/BeginMatcher.java
@@ -26,10 +26,14 @@ import org.apache.qpid.protonj2.test.driver.codec.primitives.UnsignedShort;
 import org.apache.qpid.protonj2.test.driver.codec.transport.Begin;
 import org.apache.qpid.protonj2.test.driver.codec.util.TypeMapper;
 import org.apache.qpid.protonj2.test.driver.matchers.ListDescribedTypeMatcher;
+import org.apache.qpid.protonj2.test.driver.matchers.MapContentsMatcher;
 import org.hamcrest.Matcher;
 
 public class BeginMatcher extends ListDescribedTypeMatcher {
 
+    // Only used if singular 'withProperty' API is used
+    private MapContentsMatcher<Symbol, Object> propertiesMatcher;
+
     public BeginMatcher() {
         super(Begin.Field.values().length, Begin.DESCRIPTOR_CODE, Begin.DESCRIPTOR_SYMBOL);
     }
@@ -114,11 +118,26 @@ public class BeginMatcher extends ListDescribedTypeMatcher {
     }
 
     public BeginMatcher withPropertiesMap(Map<Symbol, Object> properties) {
+        propertiesMatcher = null; // Clear these as this overrides anything else
         return withProperties(equalTo(properties));
     }
 
     public BeginMatcher withProperties(Map<String, Object> properties) {
-        return withProperties(equalTo(TypeMapper.toSymbolKeyedMap(properties)));
+        return withPropertiesMap(TypeMapper.toSymbolKeyedMap(properties));
+    }
+
+    public BeginMatcher withProperty(String key, Object value) {
+        return withProperty(Symbol.valueOf(key), value);
+    }
+
+    public BeginMatcher withProperty(Symbol key, Object value) {
+        if (propertiesMatcher == null) {
+            propertiesMatcher = new MapContentsMatcher<>();
+        }
+
+        propertiesMatcher.addExpectedEntry(key, value);
+
+        return withProperties(propertiesMatcher);
     }
 
     //----- Matcher based with methods for more complex validation
diff --git a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/FlowMatcher.java b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/FlowMatcher.java
index a3b1ada0..5413f598 100644
--- a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/FlowMatcher.java
+++ b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/FlowMatcher.java
@@ -23,11 +23,16 @@ import java.util.Map;
 import org.apache.qpid.protonj2.test.driver.codec.primitives.Symbol;
 import org.apache.qpid.protonj2.test.driver.codec.primitives.UnsignedInteger;
 import org.apache.qpid.protonj2.test.driver.codec.transport.Flow;
+import org.apache.qpid.protonj2.test.driver.codec.util.TypeMapper;
 import org.apache.qpid.protonj2.test.driver.matchers.ListDescribedTypeMatcher;
+import org.apache.qpid.protonj2.test.driver.matchers.MapContentsMatcher;
 import org.hamcrest.Matcher;
 
 public class FlowMatcher extends ListDescribedTypeMatcher {
 
+    // Only used if singular 'withProperty' API is used
+    private MapContentsMatcher<Symbol, Object> propertiesMatcher;
+
     public FlowMatcher() {
         super(Flow.Field.values().length, Flow.DESCRIPTOR_CODE, Flow.DESCRIPTOR_SYMBOL);
     }
@@ -143,10 +148,29 @@ public class FlowMatcher extends ListDescribedTypeMatcher {
         return withEcho(equalTo(echo));
     }
 
-    public FlowMatcher withProperties(Map<Symbol, Object> properties) {
+    public FlowMatcher withPropertiesMap(Map<Symbol, Object> properties) {
+        propertiesMatcher = null; // Clear these as this overrides anything else
         return withProperties(equalTo(properties));
     }
 
+    public FlowMatcher withProperties(Map<String, Object> properties) {
+        return withPropertiesMap(TypeMapper.toSymbolKeyedMap(properties));
+    }
+
+    public FlowMatcher withProperty(String key, Object value) {
+        return withProperty(Symbol.valueOf(key), value);
+    }
+
+    public FlowMatcher withProperty(Symbol key, Object value) {
+        if (propertiesMatcher == null) {
+            propertiesMatcher = new MapContentsMatcher<>();
+        }
+
+        propertiesMatcher.addExpectedEntry(key, value);
+
+        return withProperties(propertiesMatcher);
+    }
+
     //----- Matcher based with methods for more complex validation
 
     public FlowMatcher withNextIncomingId(Matcher<?> m) {
diff --git a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/OpenMatcher.java b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/OpenMatcher.java
index 50f2e07b..7b747d99 100644
--- a/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/OpenMatcher.java
+++ b/protonj2-test-driver/src/main/java/org/apache/qpid/protonj2/test/driver/matchers/transport/OpenMatcher.java
@@ -26,10 +26,14 @@ import org.apache.qpid.protonj2.test.driver.codec.primitives.UnsignedShort;
 import org.apache.qpid.protonj2.test.driver.codec.transport.Open;
 import org.apache.qpid.protonj2.test.driver.codec.util.TypeMapper;
 import org.apache.qpid.protonj2.test.driver.matchers.ListDescribedTypeMatcher;
+import org.apache.qpid.protonj2.test.driver.matchers.MapContentsMatcher;
 import org.hamcrest.Matcher;
 
 public class OpenMatcher extends ListDescribedTypeMatcher {
 
+    // Only used if singular 'withProperty' API is used
+    private MapContentsMatcher<Symbol, Object> propertiesMatcher;
+
     public OpenMatcher() {
         super(Open.Field.values().length, Open.DESCRIPTOR_CODE, Open.DESCRIPTOR_SYMBOL);
     }
@@ -118,11 +122,26 @@ public class OpenMatcher extends ListDescribedTypeMatcher {
     }
 
     public OpenMatcher withPropertiesMap(Map<Symbol, Object> properties) {
+        propertiesMatcher = null; // Clear these as this overrides anything else
         return withProperties(equalTo(properties));
     }
 
     public OpenMatcher withProperties(Map<String, Object> properties) {
-        return withProperties(equalTo(TypeMapper.toSymbolKeyedMap(properties)));
+        return withPropertiesMap(TypeMapper.toSymbolKeyedMap(properties));
+    }
+
+    public OpenMatcher withProperty(String key, Object value) {
+        return withProperty(Symbol.valueOf(key), value);
+    }
+
+    public OpenMatcher withProperty(Symbol key, Object value) {
+        if (propertiesMatcher == null) {
+            propertiesMatcher = new MapContentsMatcher<>();
+        }
+
+        propertiesMatcher.addExpectedEntry(key, value);
+
+        return withProperties(propertiesMatcher);
     }
 
     //----- Matcher based with methods for more complex validation
diff --git a/protonj2-test-driver/src/test/java/org/apache/qpid/protonj2/test/driver/SenderHandlingTest.java b/protonj2-test-driver/src/test/java/org/apache/qpid/protonj2/test/driver/SenderHandlingTest.java
index 5d0fbe6f..42c3e968 100644
--- a/protonj2-test-driver/src/test/java/org/apache/qpid/protonj2/test/driver/SenderHandlingTest.java
+++ b/protonj2-test-driver/src/test/java/org/apache/qpid/protonj2/test/driver/SenderHandlingTest.java
@@ -463,4 +463,90 @@ class SenderHandlingTest extends TestPeerTestsBase {
             peer.waitForScriptToComplete(5, TimeUnit.SECONDS);
         }
     }
+
+    @Test
+    public void testSendRemoteCommandsWithSingularPropertyAPIsForBoth() throws Exception {
+        try (ProtonTestServer peer = new ProtonTestServer();
+             ProtonTestClient client = new ProtonTestClient()) {
+
+            peer.expectAMQPHeader().respondWithAMQPHeader();
+            peer.expectOpen().withProperty("test1", "entry");
+            peer.expectBegin().withProperty("test2", "entry");
+            peer.expectAttach().ofSender().withProperty("test3", "entry");
+            peer.start();
+
+            URI remoteURI = peer.getServerURI();
+
+            LOG.info("Test started, peer listening on: {}", remoteURI);
+
+            client.connect(remoteURI.getHost(), remoteURI.getPort());
+            client.expectAMQPHeader();
+            client.remoteAMQPHeader().now();
+            client.remoteOpen().withProperty("test1", "entry").now();
+            client.remoteBegin().withProperty("test2", "entry").now();
+            client.remoteAttach().ofSender().withProperty("test3", "entry").now();
+
+            // Wait for the above and then script next steps
+            client.waitForScriptToComplete(5, TimeUnit.SECONDS);
+            peer.waitForScriptToComplete(5, TimeUnit.SECONDS);
+        }
+    }
+
+    @Test
+    public void testSendRemoteAttachExpectingSinglePropertyFails() throws Exception {
+        try (ProtonTestServer peer = new ProtonTestServer();
+             ProtonTestClient client = new ProtonTestClient()) {
+
+            peer.expectAMQPHeader().respondWithAMQPHeader();
+            peer.expectOpen();
+            peer.expectBegin();
+            peer.expectAttach().ofSender().withProperty("test", "entry");
+            peer.start();
+
+            URI remoteURI = peer.getServerURI();
+
+            LOG.info("Test started, peer listening on: {}", remoteURI);
+
+            client.connect(remoteURI.getHost(), remoteURI.getPort());
+            client.expectAMQPHeader();
+            client.remoteAMQPHeader().now();
+            client.remoteOpen().now();
+            client.remoteBegin().now();
+            client.remoteAttach().ofSender().withProperty("fail", "entry").now();
+
+            // Wait for the above and then script next steps
+            client.waitForScriptToComplete(5, TimeUnit.SECONDS);
+
+            assertThrows(AssertionError.class, () -> peer.waitForScriptToComplete(5, TimeUnit.SECONDS));
+        }
+    }
+
+    @Test
+    public void testSenderAttachContainsAtLeastOneMatchedProperty() throws Exception {
+        try (ProtonTestServer peer = new ProtonTestServer();
+             ProtonTestClient client = new ProtonTestClient()) {
+
+            peer.expectAMQPHeader().respondWithAMQPHeader();
+            peer.expectOpen();
+            peer.expectBegin();
+            peer.expectAttach().ofSender().withProperty("test", "entry");
+            peer.start();
+
+            URI remoteURI = peer.getServerURI();
+
+            LOG.info("Test started, peer listening on: {}", remoteURI);
+
+            client.connect(remoteURI.getHost(), remoteURI.getPort());
+            client.expectAMQPHeader();
+            client.remoteAMQPHeader().now();
+            client.remoteOpen().now();
+            client.remoteBegin().now();
+            client.remoteAttach().ofSender().withProperty("test", "entry")
+                                            .withProperty("another", "property").now();
+
+            // Wait for the above and then script next steps
+            client.waitForScriptToComplete(5, TimeUnit.SECONDS);
+            peer.waitForScriptToComplete(5, TimeUnit.SECONDS);
+        }
+    }
 }
diff --git a/protonj2-test-driver/src/test/java/org/apache/qpid/protonj2/test/driver/matcher/MapContentsMatcherTest.java b/protonj2-test-driver/src/test/java/org/apache/qpid/protonj2/test/driver/matcher/MapContentsMatcherTest.java
new file mode 100644
index 00000000..f33162c7
--- /dev/null
+++ b/protonj2-test-driver/src/test/java/org/apache/qpid/protonj2/test/driver/matcher/MapContentsMatcherTest.java
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.qpid.protonj2.test.driver.matcher;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.qpid.protonj2.test.driver.matchers.MapContentsMatcher;
+import org.apache.qpid.protonj2.test.driver.matchers.MapContentsMatcher.MatcherMode;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test the custom {@link Map} contents matcher type
+ */
+public class MapContentsMatcherTest {
+
+    @Test
+    public void testEmptyMapsAreEqualInAllModes() {
+        final Map<String, String> map = new HashMap<>();
+
+        MapContentsMatcher<String, String> matcher1 = new MapContentsMatcher<>(MatcherMode.PARTIAL_MATCH);
+        MapContentsMatcher<String, String> matcher2 = new MapContentsMatcher<>(MatcherMode.CONTENTS_MATCH);
+        MapContentsMatcher<String, String> matcher3 = new MapContentsMatcher<>(MatcherMode.EXACT_MATCH);
+
+        assertTrue(matcher1.matches(map));
+        assertTrue(matcher2.matches(map));
+        assertTrue(matcher3.matches(map));
+    }
+
+    @Test
+    public void testNullValueMatchPartial() {
+        doTestNullValueMatches(MatcherMode.PARTIAL_MATCH);
+    }
+
+    @Test
+    public void testNullValueMatchContents() {
+        doTestNullValueMatches(MatcherMode.CONTENTS_MATCH);
+    }
+
+    @Test
+    public void testNullValueMatchExact() {
+        doTestNullValueMatches(MatcherMode.EXACT_MATCH);
+    }
+
+    protected void doTestNullValueMatches(MatcherMode mode) {
+        final Map<String, String> map = new HashMap<>();
+
+        map.put("one", null);
+
+        MapContentsMatcher<String, String> matcher = new MapContentsMatcher<>(mode);
+
+        matcher.addExpectedEntry("one", null);
+
+        assertTrue(matcher.matches(map));
+    }
+
+    @Test
+    public void testMapEqualsWhenTheyAre() {
+        final Map<String, String> map = new HashMap<>();
+
+        map.put("one", "1");
+        map.put("two", "2");
+        map.put("three", "3");
+
+        MapContentsMatcher<String, String> matcher = new MapContentsMatcher<>(MatcherMode.CONTENTS_MATCH);
+
+        matcher.addExpectedEntry("one", "1");
+        matcher.addExpectedEntry("two", "2");
+        matcher.addExpectedEntry("three", "3");
+
+        assertTrue(matcher.matches(map));
+    }
+
+    @Test
+    public void testMapEqualsWhenTheyAreNotForContensts() {
+        final Map<String, String> map = new HashMap<>();
+
+        map.put("one", "1");
+        map.put("two", "2");
+        map.put("three", "3");
+
+        MapContentsMatcher<String, String> matcher = new MapContentsMatcher<>(MatcherMode.CONTENTS_MATCH);
+
+        matcher.addExpectedEntry("one", "1");
+        assertFalse(matcher.matches(map));
+
+        matcher.addExpectedEntry("two", "2");
+        assertFalse(matcher.matches(map));
+
+        matcher.addExpectedEntry("three", "3");
+        assertTrue(matcher.matches(map));  // finally equal
+    }
+
+    @Test
+    public void testMapEqualsWhenTheyAreNotForContensts2() {
+        final Map<String, String> map = new LinkedHashMap<>();
+
+        map.put("one", "1");
+        map.put("two", "2");
+        map.put("three", "3");
+
+        MapContentsMatcher<String, String> matcher = new MapContentsMatcher<>(MatcherMode.EXACT_MATCH);
+
+        matcher.addExpectedEntry("one", "1");
+        assertFalse(matcher.matches(map));
+
+        matcher.addExpectedEntry("two", "2");
+        assertFalse(matcher.matches(map));
+
+        matcher.addExpectedEntry("three", "3");
+        assertTrue(matcher.matches(map));  // finally equal
+    }
+
+    @Test
+    public void testMapContentsMustBeInOrderForExactMatcher() {
+        final Map<String, String> map = new LinkedHashMap<>();
+
+        map.put("one", "1");
+        map.put("three", "3");
+        map.put("two", "2");
+
+        MapContentsMatcher<String, String> matcher = new MapContentsMatcher<>(MatcherMode.EXACT_MATCH);
+
+        matcher.addExpectedEntry("one", "1");
+        matcher.addExpectedEntry("two", "2");
+        matcher.addExpectedEntry("three", "3");
+
+        assertFalse(matcher.matches(map));
+    }
+
+    @Test
+    public void testMapEqualsWhenItContainsTheValueExpectedButAlsoOthers() {
+        final Map<String, String> map = new HashMap<>();
+
+        map.put("one", "1");
+        map.put("two", "2");
+        map.put("three", "3");
+        map.put("four", "4");
+        map.put("five", "5");
+
+        MapContentsMatcher<String, String> matcher = new MapContentsMatcher<>(MatcherMode.PARTIAL_MATCH);
+
+        matcher.addExpectedEntry("one", "1");
+        matcher.addExpectedEntry("two", "2");
+        matcher.addExpectedEntry("three", "3");
+
+        assertTrue(matcher.matches(map));
+    }
+
+    @Test
+    public void testExactMatchFailsWhenMoreElementsThanExpected() {
+        final Map<String, String> map = new HashMap<>();
+
+        map.put("one", "1");
+        map.put("two", "2");
+        map.put("three", "3");
+        map.put("four", "4");
+        map.put("five", "5");
+
+        MapContentsMatcher<String, String> matcher = new MapContentsMatcher<>(MatcherMode.EXACT_MATCH);
+
+        matcher.addExpectedEntry("one", "1");
+        matcher.addExpectedEntry("two", "2");
+        matcher.addExpectedEntry("three", "3");
+
+        assertFalse(matcher.matches(map));
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@qpid.apache.org
For additional commands, e-mail: commits-help@qpid.apache.org