Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
feat(assertions): add RequiresDynamicCode attribute for collection eq…
…uivalency assertions
  • Loading branch information
thomhurst committed Oct 20, 2025
commit c9c8484a52ffacb2f9fa5d2b996731126f286dd9
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,23 @@
return null;
}

// Check for RequiresDynamicCode attribute
var requiresDynamicCodeAttr = classSymbol.GetAttributes()
.FirstOrDefault(attr => attr.AttributeClass?.Name == "RequiresDynamicCodeAttribute");
string? requiresDynamicCodeMessage = null;
if (requiresDynamicCodeAttr != null && requiresDynamicCodeAttr.ConstructorArguments.Length > 0)
{
requiresDynamicCodeMessage = requiresDynamicCodeAttr.ConstructorArguments[0].Value?.ToString();
}

return new AssertionExtensionData(
classSymbol,
methodName!,
negatedMethodName,
assertionBaseType,
constructors,
overloadPriority
overloadPriority,
requiresDynamicCodeMessage
);
}

Expand Down Expand Up @@ -299,6 +309,13 @@
sourceBuilder.AppendLine($" /// Extension method for {assertionType.Name}.");
sourceBuilder.AppendLine(" /// </summary>");

// Add RequiresDynamicCode attribute if present
if (!string.IsNullOrEmpty(data.RequiresDynamicCodeMessage))
{
var escapedMessage = data.RequiresDynamicCodeMessage.Replace("\"", "\\\"");

Check warning on line 315 in TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (de-DE)

Dereferenzierung eines möglichen Nullverweises.

Check warning on line 315 in TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (pl-PL)

Wyłuskanie odwołania, które może mieć wartość null.

Check warning on line 315 in TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (fr-FR)

Déréférencement d'une éventuelle référence null.

Check warning on line 315 in TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Dereference of a possibly null reference.

Check warning on line 315 in TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Dereference of a possibly null reference.

Check warning on line 315 in TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Dereference of a possibly null reference.
sourceBuilder.AppendLine($" [global::System.Diagnostics.CodeAnalysis.RequiresDynamicCode(\"{escapedMessage}\")]");
}

// Add OverloadResolutionPriority attribute only if priority > 0
if (data.OverloadResolutionPriority > 0)
{
Expand Down Expand Up @@ -436,6 +453,7 @@
string? NegatedMethodName,
INamedTypeSymbol AssertionBaseType,
ImmutableArray<IMethodSymbol> Constructors,
int OverloadResolutionPriority
int OverloadResolutionPriority,
string? RequiresDynamicCodeMessage
);
}
14 changes: 12 additions & 2 deletions TUnit.Assertions.Tests/CollectionStructuralEquivalenceTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using TUnit.Assertions.Conditions.Helpers;
using TUnit.Assertions.Enums;

namespace TUnit.Assertions.Tests;

Expand Down Expand Up @@ -151,12 +152,21 @@ public async Task Collections_With_Null_Items_Are_Equivalent()
}

[Test]
public async Task Collections_With_Different_Null_Positions_Are_Not_Equivalent()
public async Task Collections_With_Different_Null_Positions_Are_Equivalent_By_Default()
{
var listA = new List<Message?> { new Message { Content = "Hello" }, null };
var listB = new List<Message?> { null, new Message { Content = "Hello" } };

await TUnitAssert.That(listA).IsNotEquivalentTo(listB);
await TUnitAssert.That(listA).IsEquivalentTo(listB);
}

[Test]
public async Task Collections_With_Different_Null_Positions_Are_Not_Equivalent_When_Order_Matters()
{
var listA = new List<Message?> { new Message { Content = "Hello" }, null };
var listB = new List<Message?> { null, new Message { Content = "Hello" } };

await TUnitAssert.That(listA).IsNotEquivalentTo(listB, CollectionOrdering.Matching);
}

[Test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace TUnit.Assertions.Conditions.Helpers;
/// For complex objects, performs deep comparison of properties and fields.
/// </summary>
/// <typeparam name="T">The type of objects to compare</typeparam>
[RequiresDynamicCode("Structural equality comparison uses reflection to access object members and is not compatible with AOT")]
public sealed class StructuralEqualityComparer<T> : IEqualityComparer<T>
{
/// <summary>
Expand Down Expand Up @@ -75,6 +76,7 @@ private static bool IsPrimitiveType(Type type)
;
}

[UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "GetType() is acceptable for runtime structural comparison")]
private bool CompareStructurally(object? x, object? y, HashSet<object> visited)
{
if (x == null && y == null)
Expand Down Expand Up @@ -124,9 +126,7 @@ private bool CompareStructurally(object? x, object? y, HashSet<object> visited)
return true;
}

#pragma warning disable IL2072
var members = GetMembersToCompare(xType);
#pragma warning restore IL2072

foreach (var member in members)
{
Expand Down
3 changes: 3 additions & 0 deletions TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
using TUnit.Assertions.Attributes;
using TUnit.Assertions.Conditions.Helpers;
Expand All @@ -13,6 +14,7 @@ namespace TUnit.Assertions.Conditions;
/// Inherits from CollectionComparerBasedAssertion to preserve collection type awareness in And/Or chains.
/// </summary>
[AssertionExtension("IsEquivalentTo")]
[RequiresDynamicCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
public class IsEquivalentToAssertion<TItem> : CollectionComparerBasedAssertion<TItem>
{
private readonly IEnumerable<TItem> _expected;
Expand Down Expand Up @@ -46,6 +48,7 @@ public IsEquivalentToAssertion<TItem> Using(IEqualityComparer<TItem> comparer)
return this;
}

[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Collection equivalency uses structural comparison which requires reflection")]
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<IEnumerable<TItem>> metadata)
{
var value = metadata.Value;
Expand Down
3 changes: 3 additions & 0 deletions TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
using TUnit.Assertions.Attributes;
using TUnit.Assertions.Conditions.Helpers;
Expand All @@ -12,6 +13,7 @@ namespace TUnit.Assertions.Conditions;
/// Inherits from CollectionComparerBasedAssertion to preserve collection type awareness in And/Or chains.
/// </summary>
[AssertionExtension("IsNotEquivalentTo")]
[RequiresDynamicCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
public class NotEquivalentToAssertion<TItem> : CollectionComparerBasedAssertion<TItem>
{
private readonly IEnumerable<TItem> _notExpected;
Expand All @@ -33,6 +35,7 @@ public NotEquivalentToAssertion<TItem> Using(IEqualityComparer<TItem> comparer)
return this;
}

[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Collection equivalency uses structural comparison which requires reflection")]
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<IEnumerable<TItem>> metadata)
{
var value = metadata.Value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -977,11 +977,14 @@ namespace .Conditions
protected override string GetExpectation() { }
public .<TValue> Using(.<TValue> comparer) { }
}
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
[.("IsEquivalentTo")]
public class IsEquivalentToAssertion<TItem> : .<TItem>
{
public IsEquivalentToAssertion(.<.<TItem>> context, .<TItem> expected, . ordering = 0) { }
public IsEquivalentToAssertion(.<.<TItem>> context, .<TItem> expected, .<TItem> comparer, . ordering = 0) { }
[.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")]
protected override .<.> CheckAsync(.<.<TItem>> metadata) { }
protected override string GetExpectation() { }
public .<TItem> Using(.<TItem> comparer) { }
Expand Down Expand Up @@ -1079,10 +1082,13 @@ namespace .Conditions
public .<TValue> IgnoringType( type) { }
public .<TValue> IgnoringType<TIgnore>() { }
}
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
[.("IsNotEquivalentTo")]
public class NotEquivalentToAssertion<TItem> : .<TItem>
{
public NotEquivalentToAssertion(.<.<TItem>> context, .<TItem> notExpected, . ordering = 0) { }
[.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")]
protected override .<.> CheckAsync(.<.<TItem>> metadata) { }
protected override string GetExpectation() { }
public .<TItem> Using(.<TItem> comparer) { }
Expand Down Expand Up @@ -1511,6 +1517,8 @@ namespace .
public bool Equals(T? x, T? y) { }
public int GetHashCode(T obj) { }
}
[.("Structural equality comparison uses reflection to access object members and is no" +
"t compatible with AOT")]
public sealed class StructuralEqualityComparer<T> : .<T>
{
public static readonly ..StructuralEqualityComparer<T> Instance;
Expand Down Expand Up @@ -2914,7 +2922,11 @@ namespace .Extensions
}
public static class IsEquivalentToAssertionExtensions
{
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
public static .<TItem> IsEquivalentTo<TItem>(this .<.<TItem>> source, .<TItem> expected, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("ordering")] string? orderingExpression = null) { }
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
public static .<TItem> IsEquivalentTo<TItem>(this .<.<TItem>> source, .<TItem> expected, .<TItem> comparer, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null) { }
}
public static class IsInAssertionExtensions
Expand Down Expand Up @@ -2976,6 +2988,8 @@ namespace .Extensions
}
public static class NotEquivalentToAssertionExtensions
{
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
public static .<TItem> IsNotEquivalentTo<TItem>(this .<.<TItem>> source, .<TItem> notExpected, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("ordering")] string? orderingExpression = null) { }
}
public static class NotSameReferenceAssertionExtensions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -974,11 +974,14 @@ namespace .Conditions
protected override string GetExpectation() { }
public .<TValue> Using(.<TValue> comparer) { }
}
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
[.("IsEquivalentTo")]
public class IsEquivalentToAssertion<TItem> : .<TItem>
{
public IsEquivalentToAssertion(.<.<TItem>> context, .<TItem> expected, . ordering = 0) { }
public IsEquivalentToAssertion(.<.<TItem>> context, .<TItem> expected, .<TItem> comparer, . ordering = 0) { }
[.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")]
protected override .<.> CheckAsync(.<.<TItem>> metadata) { }
protected override string GetExpectation() { }
public .<TItem> Using(.<TItem> comparer) { }
Expand Down Expand Up @@ -1076,10 +1079,13 @@ namespace .Conditions
public .<TValue> IgnoringType( type) { }
public .<TValue> IgnoringType<TIgnore>() { }
}
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
[.("IsNotEquivalentTo")]
public class NotEquivalentToAssertion<TItem> : .<TItem>
{
public NotEquivalentToAssertion(.<.<TItem>> context, .<TItem> notExpected, . ordering = 0) { }
[.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")]
protected override .<.> CheckAsync(.<.<TItem>> metadata) { }
protected override string GetExpectation() { }
public .<TItem> Using(.<TItem> comparer) { }
Expand Down Expand Up @@ -1508,6 +1514,8 @@ namespace .
public bool Equals(T? x, T? y) { }
public int GetHashCode(T obj) { }
}
[.("Structural equality comparison uses reflection to access object members and is no" +
"t compatible with AOT")]
public sealed class StructuralEqualityComparer<T> : .<T>
{
public static readonly ..StructuralEqualityComparer<T> Instance;
Expand Down Expand Up @@ -2905,7 +2913,11 @@ namespace .Extensions
}
public static class IsEquivalentToAssertionExtensions
{
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
public static .<TItem> IsEquivalentTo<TItem>(this .<.<TItem>> source, .<TItem> expected, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("ordering")] string? orderingExpression = null) { }
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
public static .<TItem> IsEquivalentTo<TItem>(this .<.<TItem>> source, .<TItem> expected, .<TItem> comparer, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null) { }
}
public static class IsInAssertionExtensions
Expand Down Expand Up @@ -2966,6 +2978,8 @@ namespace .Extensions
}
public static class NotEquivalentToAssertionExtensions
{
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
public static .<TItem> IsNotEquivalentTo<TItem>(this .<.<TItem>> source, .<TItem> notExpected, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("ordering")] string? orderingExpression = null) { }
}
public static class NotSameReferenceAssertionExtensions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -977,11 +977,14 @@ namespace .Conditions
protected override string GetExpectation() { }
public .<TValue> Using(.<TValue> comparer) { }
}
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
[.("IsEquivalentTo")]
public class IsEquivalentToAssertion<TItem> : .<TItem>
{
public IsEquivalentToAssertion(.<.<TItem>> context, .<TItem> expected, . ordering = 0) { }
public IsEquivalentToAssertion(.<.<TItem>> context, .<TItem> expected, .<TItem> comparer, . ordering = 0) { }
[.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")]
protected override .<.> CheckAsync(.<.<TItem>> metadata) { }
protected override string GetExpectation() { }
public .<TItem> Using(.<TItem> comparer) { }
Expand Down Expand Up @@ -1079,10 +1082,13 @@ namespace .Conditions
public .<TValue> IgnoringType( type) { }
public .<TValue> IgnoringType<TIgnore>() { }
}
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
[.("IsNotEquivalentTo")]
public class NotEquivalentToAssertion<TItem> : .<TItem>
{
public NotEquivalentToAssertion(.<.<TItem>> context, .<TItem> notExpected, . ordering = 0) { }
[.("AOT", "IL3050", Justification="Collection equivalency uses structural comparison which requires reflection")]
protected override .<.> CheckAsync(.<.<TItem>> metadata) { }
protected override string GetExpectation() { }
public .<TItem> Using(.<TItem> comparer) { }
Expand Down Expand Up @@ -1511,6 +1517,8 @@ namespace .
public bool Equals(T? x, T? y) { }
public int GetHashCode(T obj) { }
}
[.("Structural equality comparison uses reflection to access object members and is no" +
"t compatible with AOT")]
public sealed class StructuralEqualityComparer<T> : .<T>
{
public static readonly ..StructuralEqualityComparer<T> Instance;
Expand Down Expand Up @@ -2914,7 +2922,11 @@ namespace .Extensions
}
public static class IsEquivalentToAssertionExtensions
{
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
public static .<TItem> IsEquivalentTo<TItem>(this .<.<TItem>> source, .<TItem> expected, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("ordering")] string? orderingExpression = null) { }
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
public static .<TItem> IsEquivalentTo<TItem>(this .<.<TItem>> source, .<TItem> expected, .<TItem> comparer, . ordering = 0, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null, [.("ordering")] string? orderingExpression = null) { }
}
public static class IsInAssertionExtensions
Expand Down Expand Up @@ -2976,6 +2988,8 @@ namespace .Extensions
}
public static class NotEquivalentToAssertionExtensions
{
[.("Collection equivalency uses structural comparison for complex objects, which requ" +
"ires reflection and is not compatible with AOT")]
public static .<TItem> IsNotEquivalentTo<TItem>(this .<.<TItem>> source, .<TItem> notExpected, . ordering = 0, [.("notExpected")] string? notExpectedExpression = null, [.("ordering")] string? orderingExpression = null) { }
}
public static class NotSameReferenceAssertionExtensions
Expand Down
Loading