diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 112c2ecbeb..31b833ae8d 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -66,6 +66,12 @@ jobs:
- name: Build
run: dotnet build -c Release
+ - name: Publish AOT
+ run: dotnet publish TUnit.TestProject/TUnit.TestProject.csproj -c Release --use-current-runtime -p:Aot=true -o TESTPROJECT_AOT --framework net8.0
+
+ - name: Publish Single File
+ run: dotnet publish TUnit.TestProject/TUnit.TestProject.csproj -c Release --use-current-runtime -p:SingleFile=true -o TESTPROJECT_SINGLEFILE --framework net8.0
+
- name: Run Pipeline
uses: ./.github/actions/execute-pipeline
with:
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 018c34132e..2914315e37 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -80,11 +80,12 @@
+
-
-
-
+
+
+
diff --git a/TUnit.Analyzers/AnalyzerReleases.Unshipped.md b/TUnit.Analyzers/AnalyzerReleases.Unshipped.md
index 366f682b07..c52332fd49 100644
--- a/TUnit.Analyzers/AnalyzerReleases.Unshipped.md
+++ b/TUnit.Analyzers/AnalyzerReleases.Unshipped.md
@@ -1,5 +1,4 @@
### New Rules
-Rule ID | Category | Severity | Notes
---------|----------|----------|-------
-TUnit0052 | Usage | Warning | Multiple constructors found without [TestConstructor] attribute
\ No newline at end of file
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
\ No newline at end of file
diff --git a/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs b/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs
index df27b7c2c7..0c42f0d347 100644
--- a/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs
+++ b/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs
@@ -92,13 +92,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
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)
+ // Check for RequiresUnreferencedCode attribute
+ var RequiresUnreferencedCodeAttr = classSymbol.GetAttributes()
+ .FirstOrDefault(attr => attr.AttributeClass?.Name == "RequiresUnreferencedCodeAttribute");
+ string? RequiresUnreferencedCodeMessage = null;
+ if (RequiresUnreferencedCodeAttr != null && RequiresUnreferencedCodeAttr.ConstructorArguments.Length > 0)
{
- requiresDynamicCodeMessage = requiresDynamicCodeAttr.ConstructorArguments[0].Value?.ToString();
+ RequiresUnreferencedCodeMessage = RequiresUnreferencedCodeAttr.ConstructorArguments[0].Value?.ToString();
}
return new AssertionExtensionData(
@@ -108,7 +108,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
assertionBaseType,
constructors,
overloadPriority,
- requiresDynamicCodeMessage
+ RequiresUnreferencedCodeMessage
);
}
@@ -153,7 +153,7 @@ private static void GenerateExtensionMethods(SourceProductionContext context, As
sourceBuilder.AppendLine($"namespace TUnit.Assertions.Extensions;");
sourceBuilder.AppendLine();
-
+
// Extension class
var extensionClassName = $"{data.ClassSymbol.Name}Extensions";
sourceBuilder.AppendLine($"/// ");
@@ -309,11 +309,11 @@ private static void GenerateExtensionMethod(
sourceBuilder.AppendLine($" /// Extension method for {assertionType.Name}.");
sourceBuilder.AppendLine(" /// ");
- // Add RequiresDynamicCode attribute if present
- if (!string.IsNullOrEmpty(data.RequiresDynamicCodeMessage))
+ // Add RequiresUnreferencedCode attribute if present
+ if (!string.IsNullOrEmpty(data.RequiresUnreferencedCodeMessage))
{
- var escapedMessage = data.RequiresDynamicCodeMessage.Replace("\"", "\\\"");
- sourceBuilder.AppendLine($" [global::System.Diagnostics.CodeAnalysis.RequiresDynamicCode(\"{escapedMessage}\")]");
+ var escapedMessage = data.RequiresUnreferencedCodeMessage!.Replace("\"", "\\\"");
+ sourceBuilder.AppendLine($" [global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(\"{escapedMessage}\")]");
}
// Add OverloadResolutionPriority attribute only if priority > 0
@@ -454,6 +454,6 @@ private record AssertionExtensionData(
INamedTypeSymbol AssertionBaseType,
ImmutableArray Constructors,
int OverloadResolutionPriority,
- string? RequiresDynamicCodeMessage
+ string? RequiresUnreferencedCodeMessage
);
}
diff --git a/TUnit.Assertions.Tests/Bugs/Tests3489.cs b/TUnit.Assertions.Tests/Bugs/Tests3489.cs
new file mode 100644
index 0000000000..ce9e2b90d3
--- /dev/null
+++ b/TUnit.Assertions.Tests/Bugs/Tests3489.cs
@@ -0,0 +1,86 @@
+namespace TUnit.Assertions.Tests.Bugs;
+
+///
+/// Tests for issue #3489: IsEquivalentTo does not work properly with nested collections in records
+///
+public class Tests3489
+{
+ [Test]
+ public async Task IsEquivalentTo_WithNestedCollections_InRecords_ShouldSucceed()
+ {
+ // Arrange - Create identical hierarchical structures
+ var actual = new List
+ {
+ new()
+ {
+ Name = "Parent1",
+ Children =
+ [
+ new Person { Name = "Child1", Children = [] },
+ new Person { Name = "Child2", Children = [] }
+ ]
+ },
+ new()
+ {
+ Name = "Parent2",
+ Children = [new Person { Name = "Child3", Children = [] }]
+ }
+ };
+
+ var expected = new List
+ {
+ new()
+ {
+ Name = "Parent1",
+ Children =
+ [
+ new Person { Name = "Child1", Children = [] },
+ new Person { Name = "Child2", Children = [] }
+ ]
+ },
+ new()
+ {
+ Name = "Parent2",
+ Children = [new Person { Name = "Child3", Children = [] }]
+ }
+ };
+
+ // Act & Assert - Should recognize structural equivalence despite different List instances
+ await Assert.That(actual).IsEquivalentTo(expected);
+ }
+
+ [Test]
+ public async Task IsEquivalentTo_WithNestedCollections_InRecords_DifferentData_ShouldFail()
+ {
+ // Arrange
+ var actual = new List
+ {
+ new()
+ {
+ Name = "Parent1",
+ Children = [new Person { Name = "Child1", Children = [] }]
+ }
+ };
+
+ var expected = new List
+ {
+ new()
+ {
+ Name = "Parent1",
+ Children = [new Person { Name = "DifferentChild", Children = [] }]
+ }
+ };
+
+ // Act & Assert - Should fail when actual data is different
+ var exception = await Assert.ThrowsAsync(
+ async () => await Assert.That(actual).IsEquivalentTo(expected));
+
+ await Assert.That(exception).IsNotNull();
+ }
+
+ private sealed record Person
+ {
+ public required string Name { get; init; }
+ public required List Children { get; init; }
+ }
+}
diff --git a/TUnit.Assertions.Tests/DictionaryCollectionTests.cs b/TUnit.Assertions.Tests/DictionaryCollectionTests.cs
index 466ef9f457..46db66210d 100644
--- a/TUnit.Assertions.Tests/DictionaryCollectionTests.cs
+++ b/TUnit.Assertions.Tests/DictionaryCollectionTests.cs
@@ -184,7 +184,7 @@ public async Task Dictionary_IsEquivalentTo_Works()
// IsEquivalentTo works on collections regardless of order
// Cast both to IEnumerable to use collection equivalency
await Assert.That((IEnumerable>)dictionary1)
- .IsEquivalentTo((IEnumerable>)dictionary2);
+ .IsEquivalentTo(dictionary2);
}
[Test]
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.Tests/Old/DecimalEqualsToAssertionTests.cs b/TUnit.Assertions.Tests/Old/DecimalEqualsToAssertionTests.cs
index 24312a87b1..3da6dfc15c 100644
--- a/TUnit.Assertions.Tests/Old/DecimalEqualsToAssertionTests.cs
+++ b/TUnit.Assertions.Tests/Old/DecimalEqualsToAssertionTests.cs
@@ -24,19 +24,19 @@ public async Task Decimal_EqualsTo_Failure()
[Test]
public async Task Decimal_EqualsTo__With_Tolerance_Success()
{
- var double1 = 1.0001d;
- var double2 = 1.0002d;
-
- await TUnitAssert.That(double1).IsEqualTo(double2).Within(0.0001);
+ var decimal1 = 1.0001m;
+ var decimal2 = 1.0002m;
+
+ await TUnitAssert.That(decimal1).IsEqualTo(decimal2).Within(0.0001m);
}
-
+
[Test]
public async Task Decimal_EqualsTo__With_Tolerance_Failure()
{
- var double1 = 1.0001d;
- var double2 = 1.0003d;
-
- await TUnitAssert.ThrowsAsync(async () => await TUnitAssert.That(double1).IsEqualTo(double2).Within(0.0001));
+ var decimal1 = 1.0001m;
+ var decimal2 = 1.0003m;
+
+ await TUnitAssert.ThrowsAsync(async () => await TUnitAssert.That(decimal1).IsEqualTo(decimal2).Within(0.0001m));
}
#endif
}
diff --git a/TUnit.Assertions.Tests/Old/DictionaryAssertionTests.cs b/TUnit.Assertions.Tests/Old/DictionaryAssertionTests.cs
index 499cf6447f..e602b77e8b 100644
--- a/TUnit.Assertions.Tests/Old/DictionaryAssertionTests.cs
+++ b/TUnit.Assertions.Tests/Old/DictionaryAssertionTests.cs
@@ -32,7 +32,7 @@ public async Task String_ReadOnlyDictionary_Contains_Key()
{
var dictionary = new ReadDictionary();
- await TUnitAssert.That((IReadOnlyDictionary)dictionary).ContainsKey("Blah");
+ await TUnitAssert.That(dictionary).ContainsKey("Blah");
}
[Test]
diff --git a/TUnit.Assertions.Tests/Old/EquivalentAssertionTests.cs b/TUnit.Assertions.Tests/Old/EquivalentAssertionTests.cs
index 8b2fc9b555..ea82d335f6 100644
--- a/TUnit.Assertions.Tests/Old/EquivalentAssertionTests.cs
+++ b/TUnit.Assertions.Tests/Old/EquivalentAssertionTests.cs
@@ -98,7 +98,7 @@ public async Task Different_Dictionaries_Are_Equivalent_With_Different_Ordered_K
// Dictionaries are equivalent regardless of key order by default
// Cast both to IEnumerable to use collection equivalency
await TUnitAssert.That((IEnumerable>)dict1)
- .IsEquivalentTo((IEnumerable>)dict2);
+ .IsEquivalentTo(dict2);
}
[Test]
diff --git a/TUnit.Assertions.Tests/TypeOfTests.cs b/TUnit.Assertions.Tests/TypeOfTests.cs
index 630c418606..2af746a842 100644
--- a/TUnit.Assertions.Tests/TypeOfTests.cs
+++ b/TUnit.Assertions.Tests/TypeOfTests.cs
@@ -242,7 +242,7 @@ public async Task CollectionAssertion_NullableByteArray_CanUseCollectionMethods(
// Should be able to use collection assertion methods
await Assert.That(nullableBytes).HasCount(5);
- await Assert.That(nullableBytes).Contains((byte)3);
+ await Assert.That(nullableBytes).Contains(3);
await Assert.That(nullableBytes).IsInOrder();
}
diff --git a/TUnit.Assertions/Assertions/Enums/EnumAssertions.cs b/TUnit.Assertions/Assertions/Enums/EnumAssertions.cs
index bcbc0d1eb2..9aec4b26ff 100644
--- a/TUnit.Assertions/Assertions/Enums/EnumAssertions.cs
+++ b/TUnit.Assertions/Assertions/Enums/EnumAssertions.cs
@@ -30,8 +30,8 @@ protected override Task CheckAsync(EvaluationMetadata me
}
// Use HasFlag method for enum flag checking
- var enumValue = (Enum)(object)value;
- var enumFlag = (Enum)(object)_expectedFlag;
+ var enumValue = (Enum)value;
+ var enumFlag = (Enum)_expectedFlag;
if (enumValue.HasFlag(enumFlag))
{
@@ -70,8 +70,8 @@ protected override Task CheckAsync(EvaluationMetadata me
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().Name}: {exception.Message}"));
}
- var enumValue = (Enum)(object)value;
- var enumFlag = (Enum)(object)_unexpectedFlag;
+ var enumValue = (Enum)value;
+ var enumFlag = (Enum)_unexpectedFlag;
if (!enumValue.HasFlag(enumFlag))
{
@@ -106,7 +106,11 @@ protected override Task CheckAsync(EvaluationMetadata me
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().Name}: {exception.Message}"));
}
+#if NET
+ if (Enum.IsDefined(value))
+#else
if (Enum.IsDefined(typeof(TEnum), value))
+#endif
{
return Task.FromResult(AssertionResult.Passed);
}
@@ -139,7 +143,11 @@ protected override Task CheckAsync(EvaluationMetadata me
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().Name}: {exception.Message}"));
}
+#if NET
+ if (!Enum.IsDefined(value))
+#else
if (!Enum.IsDefined(typeof(TEnum), value))
+#endif
{
return Task.FromResult(AssertionResult.Passed);
}
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/Helpers/StructuralEqualityComparer.cs b/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs
index 9a723cd5de..af1a1876e9 100644
--- a/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs
+++ b/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs
@@ -10,7 +10,7 @@ namespace TUnit.Assertions.Conditions.Helpers;
/// For complex objects, performs deep comparison of properties and fields.
///
/// The type of objects to compare
-[RequiresDynamicCode("Structural equality comparison uses reflection to access object members and is not compatible with AOT")]
+[RequiresUnreferencedCode("Structural equality comparison uses reflection to access object members and is not compatible with AOT")]
public sealed class StructuralEqualityComparer : IEqualityComparer
{
///
@@ -41,11 +41,6 @@ public bool Equals(T? x, T? y)
return EqualityComparer.Default.Equals(x, y);
}
- if (typeof(IEquatable).IsAssignableFrom(type))
- {
- return ((IEquatable)x).Equals(y);
- }
-
return CompareStructurally(x, y, new HashSet
[AssertionExtension("IsEquivalentTo")]
-[RequiresDynamicCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
+[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
public class IsEquivalentToAssertion : CollectionComparerBasedAssertion
where TCollection : IEnumerable
{
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/Conditions/NotEquivalentToAssertion.cs b/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs
index 66444cef0d..5fbb18d0d7 100644
--- a/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs
+++ b/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs
@@ -13,7 +13,7 @@ namespace TUnit.Assertions.Conditions;
/// Inherits from CollectionComparerBasedAssertion to preserve collection type awareness in And/Or chains.
///
[AssertionExtension("IsNotEquivalentTo")]
-[RequiresDynamicCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
+[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
public class NotEquivalentToAssertion : CollectionComparerBasedAssertion
where TCollection : IEnumerable
{
diff --git a/TUnit.Assertions/Conditions/NotStructuralEquivalencyAssertion.cs b/TUnit.Assertions/Conditions/NotStructuralEquivalencyAssertion.cs
index 50b4ebf588..4e58fd0bb6 100644
--- a/TUnit.Assertions/Conditions/NotStructuralEquivalencyAssertion.cs
+++ b/TUnit.Assertions/Conditions/NotStructuralEquivalencyAssertion.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text;
using TUnit.Assertions.Core;
@@ -6,6 +7,7 @@ namespace TUnit.Assertions.Conditions;
///
/// Asserts that two objects are NOT structurally equivalent.
///
+[RequiresUnreferencedCode("Uses reflection for structural equivalency comparison")]
public class NotStructuralEquivalencyAssertion : Assertion
{
private readonly object? _notExpected;
diff --git a/TUnit.Assertions/Conditions/SpecializedEqualityAssertions.cs b/TUnit.Assertions/Conditions/SpecializedEqualityAssertions.cs
index c722fd80dc..4ed908fb22 100644
--- a/TUnit.Assertions/Conditions/SpecializedEqualityAssertions.cs
+++ b/TUnit.Assertions/Conditions/SpecializedEqualityAssertions.cs
@@ -309,6 +309,41 @@ protected override bool AreExactlyEqual(long actual, long expected)
}
}
+///
+/// Asserts that a decimal value is equal to another, with optional tolerance.
+///
+[AssertionExtension("IsEqualTo", OverloadResolutionPriority = 2)]
+public class DecimalEqualsAssertion : ToleranceBasedEqualsAssertion
+{
+ public DecimalEqualsAssertion(
+ AssertionContext context,
+ decimal expected)
+ : base(context, expected)
+ {
+ }
+
+ protected override bool HasToleranceValue()
+ {
+ return true; // decimal? always has meaningful value when not null
+ }
+
+ protected override bool IsWithinTolerance(decimal actual, decimal expected, decimal tolerance)
+ {
+ var diff = Math.Abs(actual - expected);
+ return diff <= tolerance;
+ }
+
+ protected override object CalculateDifference(decimal actual, decimal expected)
+ {
+ return Math.Abs(actual - expected);
+ }
+
+ protected override bool AreExactlyEqual(decimal actual, decimal expected)
+ {
+ return actual == expected;
+ }
+}
+
///
/// Asserts that a DateTimeOffset value is equal to another, with optional tolerance.
///
diff --git a/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs b/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs
index 8eee381ec2..8591bf4da9 100644
--- a/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs
+++ b/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs
@@ -10,6 +10,7 @@ namespace TUnit.Assertions.Conditions;
/// Asserts that two objects are structurally equivalent by comparing their properties and fields.
/// Supports partial equivalency and member exclusion.
///
+[RequiresUnreferencedCode("Uses reflection to compare object properties and fields.")]
public class StructuralEquivalencyAssertion : Assertion
{
private readonly object? _expected;
@@ -166,9 +167,7 @@ internal AssertionResult CompareObjects(object? actual, object? expected, string
}
// Compare properties and fields
-#pragma warning disable IL2072 // GetType() does not preserve DynamicallyAccessedMembers - acceptable for runtime structural comparison
var expectedMembers = GetMembersToCompare(expectedType);
-#pragma warning restore IL2072
foreach (var member in expectedMembers)
{
@@ -199,9 +198,7 @@ internal AssertionResult CompareObjects(object? actual, object? expected, string
// In partial equivalency mode, skip members that don't exist on actual
if (_usePartialEquivalency)
{
-#pragma warning disable IL2072 // GetType() does not preserve DynamicallyAccessedMembers - acceptable for runtime structural comparison
var actualMember = GetMemberInfo(actualType, member.Name);
-#pragma warning restore IL2072
if (actualMember == null)
{
continue;
@@ -211,9 +208,7 @@ internal AssertionResult CompareObjects(object? actual, object? expected, string
}
else
{
-#pragma warning disable IL2072 // GetType() does not preserve DynamicallyAccessedMembers - acceptable for runtime structural comparison
var actualMember = GetMemberInfo(actualType, member.Name);
-#pragma warning restore IL2072
if (actualMember == null)
{
return AssertionResult.Failed($"Property {memberPath} did not match{Environment.NewLine}Expected: {FormatValue(expectedValue)}{Environment.NewLine}Received: null");
@@ -231,9 +226,7 @@ internal AssertionResult CompareObjects(object? actual, object? expected, string
// In non-partial mode, check for extra properties on actual
if (!_usePartialEquivalency)
{
-#pragma warning disable IL2072 // GetType() does not preserve DynamicallyAccessedMembers - acceptable for runtime structural comparison
var actualMembers = GetMembersToCompare(actualType);
-#pragma warning restore IL2072
var expectedMemberNames = new HashSet(expectedMembers.Select(m => m.Name));
foreach (var member in actualMembers)
diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs
index e6fbe3d50d..aa2eedb6b1 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