diff --git a/TUnit.Assertions.Tests/MemberCollectionAssertionTests.cs b/TUnit.Assertions.Tests/MemberCollectionAssertionTests.cs
new file mode 100644
index 0000000000..f8e6234352
--- /dev/null
+++ b/TUnit.Assertions.Tests/MemberCollectionAssertionTests.cs
@@ -0,0 +1,372 @@
+namespace TUnit.Assertions.Tests;
+
+///
+/// Tests for Member assertions with collection types (arrays, lists, dictionaries).
+/// Addresses issue #3495 where collection assertion methods were not available within Member lambdas.
+///
+public class MemberCollectionAssertionTests
+{
+ [Test]
+ public async Task Member_Array_HasCount_Passes()
+ {
+ var obj = new TestClass
+ {
+ Tags = ["tag1", "tag2", "tag3"]
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Tags, tags => tags.HasCount(3));
+ }
+
+ [Test]
+ public async Task Member_Array_HasCount_Fails()
+ {
+ var obj = new TestClass
+ {
+ Tags = ["tag1", "tag2"]
+ };
+
+ var exception = await Assert.ThrowsAsync(async () =>
+ await Assert.That(obj).Member(x => x.Tags, tags => tags.HasCount(5)));
+
+ await Assert.That(exception.Message).Contains("to have count 5");
+ await Assert.That(exception.Message).Contains("but found 2");
+ }
+
+ [Test]
+ public async Task Member_Array_Contains_Passes()
+ {
+ var obj = new TestClass
+ {
+ Tags = ["tag1", "tag2", "tag3"]
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Tags, tags => tags.Contains("tag2"));
+ }
+
+ [Test]
+ public async Task Member_Array_Contains_Fails()
+ {
+ var obj = new TestClass
+ {
+ Tags = ["tag1", "tag2"]
+ };
+
+ var exception = await Assert.ThrowsAsync(async () =>
+ await Assert.That(obj).Member(x => x.Tags, tags => tags.Contains("missing")));
+
+ await Assert.That(exception.Message).Contains("to contain");
+ await Assert.That(exception.Message).Contains("missing");
+ }
+
+ [Test]
+ public async Task Member_Array_IsEmpty_Passes()
+ {
+ var obj = new TestClass
+ {
+ Tags = []
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Tags, tags => tags.IsEmpty());
+ }
+
+ [Test]
+ public async Task Member_Array_IsEmpty_Fails()
+ {
+ var obj = new TestClass
+ {
+ Tags = ["tag1"]
+ };
+
+ var exception = await Assert.ThrowsAsync(async () =>
+ await Assert.That(obj).Member(x => x.Tags, tags => tags.IsEmpty()));
+
+ await Assert.That(exception.Message).Contains("to be empty");
+ }
+
+ [Test]
+ public async Task Member_Array_IsNotEmpty_Passes()
+ {
+ var obj = new TestClass
+ {
+ Tags = ["tag1"]
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Tags, tags => tags.IsNotEmpty());
+ }
+
+ [Test]
+ public async Task Member_Array_Chained_Assertions_Passes()
+ {
+ var obj = new TestClass
+ {
+ Tags = ["tag1", "tag2"]
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Tags, tags => tags.HasCount(2).And.Contains("tag1").And.Contains("tag2"));
+ }
+
+ [Test]
+ public async Task Member_Array_Chained_With_Parent_IsNotNull()
+ {
+ var obj = new TestClass
+ {
+ Tags = ["tag1"]
+ };
+
+ await Assert.That(obj)
+ .IsNotNull()
+ .And.Member(x => x.Tags, tags => tags.HasCount(1).And.Contains("tag1"));
+ }
+
+ [Test]
+ public async Task Member_List_HasCount_Passes()
+ {
+ var obj = new TestClass
+ {
+ Items = new List { 1, 2, 3 }
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Items, items => items.HasCount(3));
+ }
+
+ [Test]
+ public async Task Member_List_Contains_Passes()
+ {
+ var obj = new TestClass
+ {
+ Items = new List { 1, 2, 3 }
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Items, items => items.Contains(2));
+ }
+
+ [Test]
+ public async Task Member_List_All_Predicate_Passes()
+ {
+ var obj = new TestClass
+ {
+ Items = new List { 2, 4, 6 }
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Items, items => items.All(x => x % 2 == 0));
+ }
+
+ [Test]
+ public async Task Member_List_Any_Predicate_Passes()
+ {
+ var obj = new TestClass
+ {
+ Items = new List { 1, 2, 3 }
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Items, items => items.Any(x => x > 2));
+ }
+
+ [Test]
+ public async Task Member_Dictionary_IsEmpty_Passes()
+ {
+ var obj = new TestClass
+ {
+ Attributes = new Dictionary()
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Attributes, attrs => attrs.IsEmpty());
+ }
+
+ [Test]
+ public async Task Member_Dictionary_IsEmpty_Fails()
+ {
+ var obj = new TestClass
+ {
+ Attributes = new Dictionary { ["key"] = "value" }
+ };
+
+ var exception = await Assert.ThrowsAsync(async () =>
+ await Assert.That(obj).Member(x => x.Attributes, attrs => attrs.IsEmpty()));
+
+ await Assert.That(exception.Message).Contains("to be empty");
+ }
+
+ [Test]
+ public async Task Member_Dictionary_HasCount_Passes()
+ {
+ var obj = new TestClass
+ {
+ Attributes = new Dictionary { ["key1"] = "value1", ["key2"] = "value2" }
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Attributes, attrs => attrs.HasCount(2));
+ }
+
+ [Test]
+ public async Task Member_Dictionary_ContainsKey_Passes()
+ {
+ var obj = new TestClass
+ {
+ Attributes = new Dictionary { ["status"] = "active", ["priority"] = "high" }
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Attributes, attrs => attrs.ContainsKey("status"));
+ }
+
+ [Test]
+ public async Task Member_Dictionary_ContainsKey_And_IsNotEmpty()
+ {
+ var obj = new TestClass
+ {
+ Attributes = new Dictionary { ["status"] = "active" }
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Attributes, attrs => attrs.ContainsKey("status").And.IsNotEmpty());
+ }
+
+ [Test]
+ public async Task Member_Dictionary_DoesNotContainKey_Passes()
+ {
+ var obj = new TestClass
+ {
+ Attributes = new Dictionary { ["key1"] = "value1" }
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Attributes, attrs => attrs.DoesNotContainKey("missing"));
+ }
+
+ [Test]
+ public async Task Member_Enumerable_IsInOrder_Passes()
+ {
+ var obj = new TestClass
+ {
+ Items = new List { 1, 2, 3, 4, 5 }
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Items, items => items.IsInOrder());
+ }
+
+ [Test]
+ public async Task Member_Complex_Chain_Multiple_Collections()
+ {
+ var obj = new TestClass
+ {
+ Tags = ["important", "urgent"],
+ Items = new List { 1, 2, 3 },
+ Attributes = new Dictionary { ["status"] = "active" }
+ };
+
+ await Assert.That(obj)
+ .IsNotNull()
+ .And.Member(x => x.Tags, tags => tags.HasCount(2).And.Contains("important"))
+ .And.Member(x => x.Items, items => items.HasCount(3).And.All(x => x > 0))
+ .And.Member(x => x.Attributes, attrs => attrs.HasCount(1));
+ }
+
+ [Test]
+ public async Task Member_Array_Contains_Predicate_Passes()
+ {
+ var obj = new TestClass
+ {
+ Tags = ["test1", "test2", "other"]
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Tags, tags => tags.Contains(t => t.StartsWith("test")));
+ }
+
+ [Test]
+ public async Task Member_Array_DoesNotContain_Passes()
+ {
+ var obj = new TestClass
+ {
+ Tags = ["tag1", "tag2"]
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Tags, tags => tags.DoesNotContain("missing"));
+ }
+
+ [Test]
+ public async Task Member_Array_HasSingleItem_Passes()
+ {
+ var obj = new TestClass
+ {
+ Tags = ["only"]
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Tags, tags => tags.HasSingleItem());
+ }
+
+ [Test]
+ public async Task Member_IEnumerable_HasCount_Passes()
+ {
+ var obj = new TestClass
+ {
+ Sequence = Enumerable.Range(1, 5)
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Sequence, seq => seq.HasCount(5));
+ }
+
+ [Test]
+ public async Task Issue3495_ReportedCase_Array_HasCount_And_Contains()
+ {
+ // This is the exact scenario reported in issue #3495
+ var obj = new TestClass
+ {
+ Tags = ["pile", "other"]
+ };
+
+ await Assert.That(obj)
+ .IsNotNull()
+ .And.Member(x => x.Tags, tags => tags.HasCount(2).And.Contains("pile"));
+ }
+
+ [Test]
+ public async Task Issue3495_ReportedCase_Dictionary_IsEmpty()
+ {
+ // This is the dictionary scenario mentioned in issue #3495
+ var obj = new TestClass
+ {
+ Attributes = new Dictionary()
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Attributes, attrs => attrs.IsEmpty());
+ }
+
+ [Test]
+ public async Task Issue3495_Dictionary_ContainsKey()
+ {
+ // Testing dictionary-specific methods like ContainsKey
+ var obj = new TestClass
+ {
+ Attributes = new Dictionary { ["status"] = "active" }
+ };
+
+ await Assert.That(obj)
+ .Member(x => x.Attributes, attrs => attrs.ContainsKey("status").And.HasCount(1));
+ }
+
+ private class TestClass
+ {
+ public string[] Tags { get; init; } = [];
+ public List Items { get; init; } = [];
+ public Dictionary Attributes { get; init; } = new();
+ public IEnumerable Sequence { get; init; } = Enumerable.Empty();
+ }
+}
diff --git a/TUnit.Assertions/Conditions/CollectionAssertions.cs b/TUnit.Assertions/Conditions/CollectionAssertions.cs
index 4a640ca8a4..bf9973f7c4 100644
--- a/TUnit.Assertions/Conditions/CollectionAssertions.cs
+++ b/TUnit.Assertions/Conditions/CollectionAssertions.cs
@@ -336,7 +336,7 @@ protected override Task CheckAsync(EvaluationMetadata $"to have count {_expectedCount}";
diff --git a/TUnit.Assertions/Conditions/EqualsAssertion.cs b/TUnit.Assertions/Conditions/EqualsAssertion.cs
index 8c845236d2..821a2b4e7f 100644
--- a/TUnit.Assertions/Conditions/EqualsAssertion.cs
+++ b/TUnit.Assertions/Conditions/EqualsAssertion.cs
@@ -212,7 +212,7 @@ private static (bool IsSuccess, string? Message) DeepEquals(object? actual, obje
return (true, null);
}
- protected override string GetExpectation() => $"to be equal to {_expected}";
+ protected override string GetExpectation() => $"to be equal to {(_expected is string s ? $"\"{s}\"" : _expected)}";
///
/// Comparer that uses reference equality instead of value equality.
diff --git a/TUnit.Assertions/Conditions/MemberAssertion.cs b/TUnit.Assertions/Conditions/MemberAssertion.cs
index d3213ad753..78969efaa1 100644
--- a/TUnit.Assertions/Conditions/MemberAssertion.cs
+++ b/TUnit.Assertions/Conditions/MemberAssertion.cs
@@ -143,6 +143,32 @@ public AssertionSourceAdapter(AssertionContext context)
}
}
+///
+/// Specialized adapter for collection member assertions.
+/// Implements IAssertionSource<TCollection> while providing collection assertion methods from CollectionAssertionBase.
+///
+public class CollectionMemberAssertionAdapter : Sources.CollectionAssertionBase
+ where TCollection : IEnumerable
+{
+ internal CollectionMemberAssertionAdapter(AssertionContext context)
+ : base(context)
+ {
+ }
+}
+
+///
+/// Specialized adapter for dictionary member assertions.
+/// Implements IAssertionSource<TDictionary> while providing dictionary assertion methods from DictionaryAssertionBase.
+///
+public class DictionaryMemberAssertionAdapter : Sources.DictionaryAssertionBase
+ where TDictionary : IReadOnlyDictionary
+{
+ internal DictionaryMemberAssertionAdapter(AssertionContext context)
+ : base(context)
+ {
+ }
+}
+
///
/// Combines a pending assertion with a member assertion using AND logic.
/// Both assertions must pass for the overall assertion to succeed.
diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs
index e6fbe3d50d..55d966c3d5 100644
--- a/TUnit.Assertions/Extensions/AssertionExtensions.cs
+++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs
@@ -177,6 +177,216 @@ public static IsTypeOfRuntimeAssertion IsOfType(
return new IsTypeOfRuntimeAssertion(source.Context, expectedType);
}
+ ///
+ /// Asserts on a dictionary member of an object using a lambda selector and assertion lambda.
+ /// The assertion lambda receives dictionary assertion methods (ContainsKey, ContainsValue, IsEmpty, etc.).
+ /// After the member assertion completes, returns to the parent object context for further chaining.
+ /// Example: await Assert.That(myObject).Member(x => x.Attributes, attrs => attrs.ContainsKey("status").And.IsNotEmpty());
+ ///
+ [OverloadResolutionPriority(2)]
+ public static MemberAssertionResult Member(
+ this IAssertionSource source,
+ Expression>> memberSelector,
+ Func, TKey, TValue>, Assertion>> assertions)
+ {
+ var parentContext = source.Context;
+ var memberPath = GetMemberPath(memberSelector);
+
+ parentContext.ExpressionBuilder.Append($".Member(x => x.{memberPath}, ...)");
+
+ // Check if there's a pending link (from .And or .Or) that needs to be consumed
+ var (pendingAssertion, combinerType) = parentContext.ConsumePendingLink();
+
+ // Map to member context
+ var memberContext = parentContext.Map>(obj =>
+ {
+ if (obj == null)
+ {
+ throw new InvalidOperationException($"Object `{typeof(TObject).Name}` was null");
+ }
+
+ var compiled = memberSelector.Compile();
+ return compiled(obj);
+ });
+
+ // Create a DictionaryMemberAssertionAdapter for the member
+ var dictionaryAdapter = new DictionaryMemberAssertionAdapter, TKey, TValue>(memberContext);
+ var memberAssertion = assertions(dictionaryAdapter);
+
+ // Type-erase to object? for storage
+ var erasedAssertion = new TypeErasedAssertion>(memberAssertion);
+
+ // If there was a pending link, wrap both assertions together
+ if (pendingAssertion != null && combinerType != null)
+ {
+ // Create a combined wrapper that executes the pending assertion first (or together for Or)
+ Assertion