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
Next Next commit
feat: enhance AOT compatibility by adding RequiresUnreferencedCode at…
…tribute to assertion classes and introducing tests for custom comparer scenarios
  • Loading branch information
thomhurst committed Nov 15, 2025
commit af4a83d41e6297eaed010840894b636eb4db408a
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,22 @@
// Skip the first parameter (AssertionContext<T>)
var additionalParams = constructor.Parameters.Skip(1).ToArray();

// Check for RequiresUnreferencedCode attribute on the constructor first, then fall back to class-level
var constructorRequiresUnreferencedCodeAttr = constructor.GetAttributes()
.FirstOrDefault(attr => attr.AttributeClass?.Name == "RequiresUnreferencedCodeAttribute");

string? requiresUnreferencedCodeMessage = null;
if (constructorRequiresUnreferencedCodeAttr != null && constructorRequiresUnreferencedCodeAttr.ConstructorArguments.Length > 0)
{
// Constructor-level attribute takes precedence
requiresUnreferencedCodeMessage = constructorRequiresUnreferencedCodeAttr.ConstructorArguments[0].Value?.ToString();
}
else if (!string.IsNullOrEmpty(data.RequiresUnreferencedCodeMessage))
{
// Fall back to class-level attribute
requiresUnreferencedCodeMessage = data.RequiresUnreferencedCodeMessage;
}

// Build generic type parameters string
// Use the assertion class's own type parameters if it has them
var genericParams = new List<string>();
Expand Down Expand Up @@ -315,10 +331,10 @@
sourceBuilder.AppendLine($" /// Extension method for {assertionType.Name}.");
sourceBuilder.AppendLine(" /// </summary>");

// Add RequiresUnreferencedCode attribute if present
if (!string.IsNullOrEmpty(data.RequiresUnreferencedCodeMessage))
// Add RequiresUnreferencedCode attribute if present (from constructor or class level)
if (!string.IsNullOrEmpty(requiresUnreferencedCodeMessage))
{
var escapedMessage = data.RequiresUnreferencedCodeMessage!.Replace("\"", "\\\"");
var escapedMessage = requiresUnreferencedCodeMessage!.Replace("\"", "\\\"");
sourceBuilder.AppendLine($" [global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(\"{escapedMessage}\")]");
}

Expand All @@ -344,8 +360,8 @@
// The extension method extends IAssertionSource<T> where T is the type argument
// from the Assertion<T> base class.
string sourceType;
string genericTypeParam = null;

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

View workflow job for this annotation

GitHub Actions / modularpipeline (fr-FR)

Conversion de littéral ayant une valeur null ou d'une éventuelle valeur null en type non-nullable.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (pl-PL)

Konwertowanie literału null lub możliwej wartości null na nienullowalny typ.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (de-DE)

Das NULL-Literal oder ein möglicher NULL-Wert wird in einen Non-Nullable-Typ konvertiert.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Converting null literal or possible null value to non-nullable type.
string genericConstraint = null;

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

View workflow job for this annotation

GitHub Actions / modularpipeline (fr-FR)

Conversion de littéral ayant une valeur null ou d'une éventuelle valeur null en type non-nullable.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (pl-PL)

Konwertowanie literału null lub możliwej wartości null na nienullowalny typ.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (de-DE)

Das NULL-Literal oder ein möglicher NULL-Wert wird in einen Non-Nullable-Typ konvertiert.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Converting null literal or possible null value to non-nullable type.

if (isNullableOverload)
{
Expand All @@ -353,8 +369,8 @@
// because NRT annotations are erased at runtime - they're the same type to the CLR.
// Instead, just use the nullable version and accept both nullable and non-nullable sources.
sourceType = $"IAssertionSource<{typeParam.ToDisplayString()}>";
genericTypeParam = null;

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

View workflow job for this annotation

GitHub Actions / modularpipeline (fr-FR)

Conversion de littéral ayant une valeur null ou d'une éventuelle valeur null en type non-nullable.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (pl-PL)

Konwertowanie literału null lub możliwej wartości null na nienullowalny typ.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (de-DE)

Das NULL-Literal oder ein möglicher NULL-Wert wird in einen Non-Nullable-Typ konvertiert.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Converting null literal or possible null value to non-nullable type.
genericConstraint = null;

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

View workflow job for this annotation

GitHub Actions / modularpipeline (fr-FR)

Conversion de littéral ayant une valeur null ou d'une éventuelle valeur null en type non-nullable.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (pl-PL)

Konwertowanie literału null lub możliwej wartości null na nienullowalny typ.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (de-DE)

Das NULL-Literal oder ein möglicher NULL-Wert wird in einen Non-Nullable-Typ konvertiert.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Converting null literal or possible null value to non-nullable type.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Converting null literal or possible null value to non-nullable type.
}
else if (typeParam is ITypeParameterSymbol baseTypeParam)
{
Expand Down
5 changes: 3 additions & 2 deletions TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ namespace TUnit.Assertions.Conditions;
/// Inherits from CollectionComparerBasedAssertion to preserve collection type awareness in And/Or chains.
/// </summary>
[AssertionExtension("IsEquivalentTo")]
[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
public class IsEquivalentToAssertion<TCollection, TItem> : CollectionComparerBasedAssertion<TCollection, TItem>
where TCollection : IEnumerable<TItem>
{
private readonly IEnumerable<TItem> _expected;
private readonly CollectionOrdering _ordering;

[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
public IsEquivalentToAssertion(
AssertionContext<TCollection> context,
IEnumerable<TItem> expected,
Expand Down Expand Up @@ -49,7 +49,8 @@ public IsEquivalentToAssertion<TCollection, TItem> Using(IEqualityComparer<TItem
return this;
}

[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Collection equivalency uses structural comparison which requires reflection")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Structural comparison is only used when no custom comparer is provided. Custom comparer path is AOT-safe.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Structural comparison is only used when no custom comparer is provided. Custom comparer path is AOT-safe.")]
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollection> metadata)
{
var value = metadata.Value;
Expand Down
5 changes: 3 additions & 2 deletions TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ namespace TUnit.Assertions.Conditions;
/// Inherits from CollectionComparerBasedAssertion to preserve collection type awareness in And/Or chains.
/// </summary>
[AssertionExtension("IsNotEquivalentTo")]
[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
public class NotEquivalentToAssertion<TCollection, TItem> : CollectionComparerBasedAssertion<TCollection, TItem>
where TCollection : IEnumerable<TItem>
{
private readonly IEnumerable<TItem> _notExpected;
private readonly CollectionOrdering _ordering;

[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
public NotEquivalentToAssertion(
AssertionContext<TCollection> context,
IEnumerable<TItem> notExpected,
Expand All @@ -36,7 +36,8 @@ public NotEquivalentToAssertion<TCollection, TItem> Using(IEqualityComparer<TIte
return this;
}

[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Collection equivalency uses structural comparison which requires reflection")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Structural comparison is only used when no custom comparer is provided. Custom comparer path is AOT-safe.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Structural comparison is only used when no custom comparer is provided. Custom comparer path is AOT-safe.")]
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollection> metadata)
{
var value = metadata.Value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,32 @@ public async Task PropertyInjection_ShouldNotTriggerAotWarnings()
await Assert.That(InjectedProperty).IsNotNull();
await Assert.That(InjectedProperty).IsEqualTo("test value");
}

/// <summary>
/// Tests for issue #3851 - IsEquivalentTo with custom comparer should be AOT-compatible
/// When a custom comparer is provided, no reflection is used, so the method should not
/// have RequiresUnreferencedCode attribute and should be safe for AOT.
/// </summary>
[Test]
public async Task IsEquivalentTo_WithCustomComparer_ShouldNotTriggerAotWarnings()
{
// This test verifies that using IsEquivalentTo with a custom comparer doesn't trigger IL2026/IL3050
// The custom comparer path doesn't use StructuralEqualityComparer which requires reflection
var list1 = new List<int> { 1, 2, 3 };
var list2 = new List<int> { 3, 2, 1 };

// Using explicit comparer - should be AOT-safe
await Assert.That(list1).IsEquivalentTo(list2, EqualityComparer<int>.Default);
}

[Test]
public async Task IsEquivalentTo_WithCustomComparer_OrderMatching_ShouldNotTriggerAotWarnings()
{
// Verify that custom comparer works with both ordering modes
var list1 = new List<string> { "a", "b", "c" };
var list2 = new List<string> { "a", "b", "c" };

// Using custom comparer with order matching - should be AOT-safe
await Assert.That(list1).IsEquivalentTo(list2, StringComparer.OrdinalIgnoreCase, CollectionOrdering.Matching);
}
}
Loading