Skip to content

Commit d1dc35c

Browse files
authored
feat(assertions): implement structural equality comparison for collections (#3458)
* feat(assertions): implement structural equality comparison for collections * feat(assertions): implement structural equality comparison for collections * refactor(tests): remove redundant comments from structural equality tests * feat(assertions): add RequiresDynamicCode attribute for collection equivalency assertions
1 parent 4935900 commit d1dc35c

10 files changed

+570
-4
lines changed

TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,23 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
9292
return null;
9393
}
9494

95+
// Check for RequiresDynamicCode attribute
96+
var requiresDynamicCodeAttr = classSymbol.GetAttributes()
97+
.FirstOrDefault(attr => attr.AttributeClass?.Name == "RequiresDynamicCodeAttribute");
98+
string? requiresDynamicCodeMessage = null;
99+
if (requiresDynamicCodeAttr != null && requiresDynamicCodeAttr.ConstructorArguments.Length > 0)
100+
{
101+
requiresDynamicCodeMessage = requiresDynamicCodeAttr.ConstructorArguments[0].Value?.ToString();
102+
}
103+
95104
return new AssertionExtensionData(
96105
classSymbol,
97106
methodName!,
98107
negatedMethodName,
99108
assertionBaseType,
100109
constructors,
101-
overloadPriority
110+
overloadPriority,
111+
requiresDynamicCodeMessage
102112
);
103113
}
104114

@@ -299,6 +309,13 @@ private static void GenerateExtensionMethod(
299309
sourceBuilder.AppendLine($" /// Extension method for {assertionType.Name}.");
300310
sourceBuilder.AppendLine(" /// </summary>");
301311

312+
// Add RequiresDynamicCode attribute if present
313+
if (!string.IsNullOrEmpty(data.RequiresDynamicCodeMessage))
314+
{
315+
var escapedMessage = data.RequiresDynamicCodeMessage.Replace("\"", "\\\"");
316+
sourceBuilder.AppendLine($" [global::System.Diagnostics.CodeAnalysis.RequiresDynamicCode(\"{escapedMessage}\")]");
317+
}
318+
302319
// Add OverloadResolutionPriority attribute only if priority > 0
303320
if (data.OverloadResolutionPriority > 0)
304321
{
@@ -436,6 +453,7 @@ private record AssertionExtensionData(
436453
string? NegatedMethodName,
437454
INamedTypeSymbol AssertionBaseType,
438455
ImmutableArray<IMethodSymbol> Constructors,
439-
int OverloadResolutionPriority
456+
int OverloadResolutionPriority,
457+
string? RequiresDynamicCodeMessage
440458
);
441459
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
using TUnit.Assertions.Conditions.Helpers;
2+
using TUnit.Assertions.Enums;
3+
4+
namespace TUnit.Assertions.Tests;
5+
6+
/// <summary>
7+
/// Tests for issue #3454: Collection IsEquivalentTo should use structural equality for complex objects
8+
/// </summary>
9+
public class CollectionStructuralEquivalenceTests
10+
{
11+
[Test]
12+
public async Task Collections_With_Structurally_Equal_Objects_Are_Equivalent()
13+
{
14+
var a = new Message { Content = "Hello" };
15+
var b = new Message { Content = "Hello" };
16+
var listA = new List<Message> { a, a };
17+
var listB = new List<Message> { b, b };
18+
19+
await TUnitAssert.That(listA).IsEquivalentTo(listB);
20+
}
21+
22+
[Test]
23+
public async Task Collections_With_Structurally_Different_Objects_Are_Not_Equivalent()
24+
{
25+
var a = new Message { Content = "Hello" };
26+
var b = new Message { Content = "World" };
27+
var listA = new List<Message> { a };
28+
var listB = new List<Message> { b };
29+
30+
await TUnitAssert.That(listA).IsNotEquivalentTo(listB);
31+
}
32+
33+
[Test]
34+
public async Task Collections_With_Nested_Objects_Are_Equivalent()
35+
{
36+
var listA = new List<MessageWithNested>
37+
{
38+
new() { Content = "Hello", Nested = new Message { Content = "World" } }
39+
};
40+
var listB = new List<MessageWithNested>
41+
{
42+
new() { Content = "Hello", Nested = new Message { Content = "World" } }
43+
};
44+
45+
await TUnitAssert.That(listA).IsEquivalentTo(listB);
46+
}
47+
48+
[Test]
49+
public async Task Collections_With_Different_Nested_Objects_Are_Not_Equivalent()
50+
{
51+
var listA = new List<MessageWithNested>
52+
{
53+
new() { Content = "Hello", Nested = new Message { Content = "World" } }
54+
};
55+
var listB = new List<MessageWithNested>
56+
{
57+
new() { Content = "Hello", Nested = new Message { Content = "Universe" } }
58+
};
59+
60+
await TUnitAssert.That(listA).IsNotEquivalentTo(listB);
61+
}
62+
63+
[Test]
64+
public async Task Collections_With_Nested_Collections_Are_Equivalent()
65+
{
66+
var listA = new List<MessageWithCollection>
67+
{
68+
new() { Content = "Hello", Messages = [new Message { Content = "A" }, new Message { Content = "B" }] }
69+
};
70+
var listB = new List<MessageWithCollection>
71+
{
72+
new() { Content = "Hello", Messages = [new Message { Content = "A" }, new Message { Content = "B" }] }
73+
};
74+
75+
await TUnitAssert.That(listA).IsEquivalentTo(listB);
76+
}
77+
78+
[Test]
79+
public async Task Collections_With_Different_Nested_Collections_Are_Not_Equivalent()
80+
{
81+
var listA = new List<MessageWithCollection>
82+
{
83+
new() { Content = "Hello", Messages = [new Message { Content = "A" }] }
84+
};
85+
var listB = new List<MessageWithCollection>
86+
{
87+
new() { Content = "Hello", Messages = [new Message { Content = "B" }] }
88+
};
89+
90+
await TUnitAssert.That(listA).IsNotEquivalentTo(listB);
91+
}
92+
93+
[Test]
94+
public async Task Collections_With_ReferenceEqualityComparer_Uses_Reference_Equality()
95+
{
96+
var a = new Message { Content = "Hello" };
97+
var b = new Message { Content = "Hello" };
98+
var listA = new List<Message> { a };
99+
var listB = new List<Message> { b };
100+
101+
var exception = await TUnitAssert.ThrowsAsync<TUnitAssertionException>(
102+
async () => await TUnitAssert.That(listA).IsEquivalentTo(listB).Using(ReferenceEqualityComparer<Message>.Instance)
103+
);
104+
105+
await TUnitAssert.That(exception).IsNotNull();
106+
}
107+
108+
[Test]
109+
public async Task Collections_With_Same_Reference_And_ReferenceEqualityComparer_Are_Equivalent()
110+
{
111+
var a = new Message { Content = "Hello" };
112+
var listA = new List<Message> { a };
113+
var listB = new List<Message> { a };
114+
115+
await TUnitAssert.That(listA).IsEquivalentTo(listB).Using(ReferenceEqualityComparer<Message>.Instance);
116+
}
117+
118+
[Test]
119+
public async Task Primitives_Still_Work_With_Structural_Comparer()
120+
{
121+
var listA = new List<int> { 1, 2, 3 };
122+
var listB = new List<int> { 1, 2, 3 };
123+
124+
await TUnitAssert.That(listA).IsEquivalentTo(listB);
125+
}
126+
127+
[Test]
128+
public async Task Strings_Still_Work_With_Structural_Comparer()
129+
{
130+
var listA = new List<string> { "a", "b", "c" };
131+
var listB = new List<string> { "a", "b", "c" };
132+
133+
await TUnitAssert.That(listA).IsEquivalentTo(listB);
134+
}
135+
136+
[Test]
137+
public async Task Collections_With_Equatable_Objects_Use_Equatable_Implementation()
138+
{
139+
var listA = new List<EquatableMessage> { new("Hello"), new("World") };
140+
var listB = new List<EquatableMessage> { new("Hello"), new("World") };
141+
142+
await TUnitAssert.That(listA).IsEquivalentTo(listB);
143+
}
144+
145+
[Test]
146+
public async Task Collections_With_Null_Items_Are_Equivalent()
147+
{
148+
var listA = new List<Message?> { new Message { Content = "Hello" }, null, new Message { Content = "World" } };
149+
var listB = new List<Message?> { new Message { Content = "Hello" }, null, new Message { Content = "World" } };
150+
151+
await TUnitAssert.That(listA).IsEquivalentTo(listB);
152+
}
153+
154+
[Test]
155+
public async Task Collections_With_Different_Null_Positions_Are_Equivalent_By_Default()
156+
{
157+
var listA = new List<Message?> { new Message { Content = "Hello" }, null };
158+
var listB = new List<Message?> { null, new Message { Content = "Hello" } };
159+
160+
await TUnitAssert.That(listA).IsEquivalentTo(listB);
161+
}
162+
163+
[Test]
164+
public async Task Collections_With_Different_Null_Positions_Are_Not_Equivalent_When_Order_Matters()
165+
{
166+
var listA = new List<Message?> { new Message { Content = "Hello" }, null };
167+
var listB = new List<Message?> { null, new Message { Content = "Hello" } };
168+
169+
await TUnitAssert.That(listA).IsNotEquivalentTo(listB, CollectionOrdering.Matching);
170+
}
171+
172+
[Test]
173+
public async Task Single_Object_IsEquivalentTo_Still_Works_As_Before()
174+
{
175+
var a = new Message { Content = "Hello" };
176+
var b = new Message { Content = "Hello" };
177+
178+
await TUnitAssert.That(a).IsEquivalentTo(b);
179+
}
180+
181+
[Test]
182+
public async Task Collections_With_Custom_Comparer_Uses_Custom_Comparer()
183+
{
184+
var listA = new List<string> { "hello", "world" };
185+
var listB = new List<string> { "HELLO", "WORLD" };
186+
187+
await TUnitAssert.That(listA).IsEquivalentTo(listB).Using(StringComparer.OrdinalIgnoreCase);
188+
}
189+
public class Message
190+
{
191+
public string? Content { get; set; }
192+
}
193+
194+
public class MessageWithNested
195+
{
196+
public string? Content { get; set; }
197+
public Message? Nested { get; set; }
198+
}
199+
200+
public class MessageWithCollection
201+
{
202+
public string? Content { get; set; }
203+
public List<Message>? Messages { get; set; }
204+
}
205+
206+
public class EquatableMessage : IEquatable<EquatableMessage>
207+
{
208+
public string Content { get; }
209+
210+
public EquatableMessage(string content)
211+
{
212+
Content = content;
213+
}
214+
215+
public bool Equals(EquatableMessage? other)
216+
{
217+
if (other == null)
218+
{
219+
return false;
220+
}
221+
222+
return Content == other.Content;
223+
}
224+
225+
public override bool Equals(object? obj)
226+
{
227+
return Equals(obj as EquatableMessage);
228+
}
229+
230+
public override int GetHashCode()
231+
{
232+
return Content.GetHashCode();
233+
}
234+
}
235+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace TUnit.Assertions.Conditions.Helpers;
2+
3+
/// <summary>
4+
/// An equality comparer that uses reference equality (ReferenceEquals) for comparison.
5+
/// Useful when you want to assert that collections contain the exact same object instances,
6+
/// not just structurally equivalent objects.
7+
/// </summary>
8+
/// <typeparam name="T">The type of objects to compare</typeparam>
9+
public sealed class ReferenceEqualityComparer<T> : IEqualityComparer<T> where T : class
10+
{
11+
/// <summary>
12+
/// Singleton instance of the reference equality comparer.
13+
/// </summary>
14+
public static readonly ReferenceEqualityComparer<T> Instance = new();
15+
16+
private ReferenceEqualityComparer()
17+
{
18+
}
19+
20+
public bool Equals(T? x, T? y)
21+
{
22+
return ReferenceEquals(x, y);
23+
}
24+
25+
public int GetHashCode(T obj)
26+
{
27+
return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
28+
}
29+
}

0 commit comments

Comments
 (0)