diff --git a/TUnit.Assertions.Tests/MemberNullabilityTests.cs b/TUnit.Assertions.Tests/MemberNullabilityTests.cs new file mode 100644 index 0000000000..41b9129b2d --- /dev/null +++ b/TUnit.Assertions.Tests/MemberNullabilityTests.cs @@ -0,0 +1,419 @@ +namespace TUnit.Assertions.Tests; + +/// +/// Tests for Member() and HasProperty() with various nullable and non-nullable type combinations. +/// Ensures the TMember? fix for nullable selectors works correctly for: +/// - Non-nullable reference types (string, object, custom class) +/// - Nullable reference types (string?, object?, custom class?) +/// - Non-nullable value types / structs (int, bool, DateTime, custom struct) +/// - Nullable value types / structs (int?, bool?, DateTime?, custom struct?) +/// - Enum types (nullable and non-nullable) +/// - Collection types (nullable and non-nullable) +/// +public class MemberNullabilityTests +{ + // ============ MODEL CLASSES ============ + + private sealed class ModelWithAllTypes + { + // Non-nullable reference types + public string Name { get; set; } = string.Empty; + public object Tag { get; set; } = new(); + public InnerModel Inner { get; set; } = new(); + public List Numbers { get; set; } = []; + + // Nullable reference types + public string? NullableName { get; set; } + public object? NullableTag { get; set; } + public InnerModel? NullableInner { get; set; } + public List? NullableNumbers { get; set; } + + // Non-nullable value types (structs) + public int Count { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + public MyStruct StructValue { get; set; } + public MyEnum EnumValue { get; set; } + + // Nullable value types (structs) + public int? NullableCount { get; set; } + public bool? NullableFlag { get; set; } + public DateTime? NullableDate { get; set; } + public MyStruct? NullableStructValue { get; set; } + public MyEnum? NullableEnumValue { get; set; } + } + + private sealed class InnerModel + { + public string Value { get; set; } = "default"; + } + + private struct MyStruct + { + public int X { get; set; } + public int Y { get; set; } + } + + private enum MyEnum + { + None = 0, + First = 1, + Second = 2 + } + + // ============ NON-NULLABLE REFERENCE TYPE TESTS ============ + + [Test] + public async Task Member_NonNullableString_Success() + { + var model = new ModelWithAllTypes { Name = "hello" }; + await Assert.That(model).Member(x => x.Name, name => name.IsEqualTo("hello")); + } + + [Test] + public async Task Member_NonNullableString_Failure() + { + var model = new ModelWithAllTypes { Name = "hello" }; + var ex = await Assert.ThrowsAsync(async () => + await Assert.That(model).Member(x => x.Name, name => name.IsEqualTo("world"))); + await Assert.That(ex!.Message).Contains("world"); + } + + [Test] + public async Task Member_NonNullableObject_Success() + { + var model = new ModelWithAllTypes { Tag = "tag-value" }; + await Assert.That(model).Member(x => x.Tag, tag => tag.IsNotNull()); + } + + [Test] + public async Task Member_NonNullableInnerModel_Success() + { + var model = new ModelWithAllTypes { Inner = new InnerModel { Value = "test" } }; + await Assert.That(model).Member(x => x.Inner, inner => inner.IsNotNull()); + } + + [Test] + public async Task Member_NonNullableCollection_Success() + { + var model = new ModelWithAllTypes { Numbers = [1, 2, 3] }; + await Assert.That(model).Member(x => x.Numbers, nums => nums.IsNotNull()); + } + + // ============ NULLABLE REFERENCE TYPE TESTS ============ + + [Test] + public async Task Member_NullableString_WithValue_Success() + { + var model = new ModelWithAllTypes { NullableName = "dbo" }; + await Assert.That(model).Member(x => x.NullableName, name => name.IsEqualTo("dbo")); + } + + [Test] + public async Task Member_NullableString_WithNull_IsNull_Success() + { + var model = new ModelWithAllTypes { NullableName = null }; + await Assert.That(model).Member(x => x.NullableName, name => name.IsNull()); + } + + [Test] + public async Task Member_NullableString_WithValue_IsNotNull_Success() + { + var model = new ModelWithAllTypes { NullableName = "test" }; + await Assert.That(model).Member(x => x.NullableName, name => name.IsNotNull()); + } + + [Test] + public async Task Member_NullableObject_WithValue_Success() + { + var model = new ModelWithAllTypes { NullableTag = "tag" }; + await Assert.That(model).Member(x => x.NullableTag, tag => tag.IsNotNull()); + } + + [Test] + public async Task Member_NullableObject_WithNull_Success() + { + var model = new ModelWithAllTypes { NullableTag = null }; + await Assert.That(model).Member(x => x.NullableTag, tag => tag.IsNull()); + } + + [Test] + public async Task Member_NullableInnerModel_WithValue_Success() + { + var model = new ModelWithAllTypes { NullableInner = new InnerModel { Value = "x" } }; + await Assert.That(model).Member(x => x.NullableInner, inner => inner.IsNotNull()); + } + + [Test] + public async Task Member_NullableInnerModel_WithNull_Success() + { + var model = new ModelWithAllTypes { NullableInner = null }; + await Assert.That(model).Member(x => x.NullableInner, inner => inner.IsNull()); + } + + [Test] + public async Task Member_NullableCollection_WithValue_Success() + { + var model = new ModelWithAllTypes { NullableNumbers = [10, 20] }; + await Assert.That(model).Member(x => x.NullableNumbers, nums => nums.IsNotNull()); + } + + [Test] + public async Task Member_NullableCollection_WithNull_Success() + { + var model = new ModelWithAllTypes { NullableNumbers = null }; + await Assert.That(model).Member(x => x.NullableNumbers, nums => nums.IsNull()); + } + + // ============ NON-NULLABLE VALUE TYPE (STRUCT) TESTS ============ + + [Test] + public async Task Member_NonNullableInt_Success() + { + var model = new ModelWithAllTypes { Count = 42 }; + await Assert.That(model).Member(x => x.Count, count => count.IsEqualTo(42)); + } + + [Test] + public async Task Member_NonNullableInt_Failure() + { + var model = new ModelWithAllTypes { Count = 42 }; + var ex = await Assert.ThrowsAsync(async () => + await Assert.That(model).Member(x => x.Count, count => count.IsEqualTo(99))); + await Assert.That(ex!.Message).Contains("99"); + } + + [Test] + public async Task Member_NonNullableBool_Success() + { + var model = new ModelWithAllTypes { IsActive = true }; + await Assert.That(model).Member(x => x.IsActive, active => active.IsTrue()); + } + + [Test] + public async Task Member_NonNullableDateTime_Success() + { + var date = new DateTime(2025, 6, 15, 0, 0, 0, DateTimeKind.Utc); + var model = new ModelWithAllTypes { CreatedAt = date }; + await Assert.That(model).Member(x => x.CreatedAt, d => d.IsEqualTo(date)); + } + + [Test] + public async Task Member_NonNullableCustomStruct_Success() + { + var expected = new MyStruct { X = 1, Y = 2 }; + var model = new ModelWithAllTypes { StructValue = expected }; + await Assert.That(model).Member(x => x.StructValue, s => s.IsEqualTo(expected)); + } + + [Test] + public async Task Member_NonNullableEnum_Success() + { + var model = new ModelWithAllTypes { EnumValue = MyEnum.First }; + await Assert.That(model).Member(x => x.EnumValue, e => e.IsEqualTo(MyEnum.First)); + } + + // ============ NULLABLE VALUE TYPE (STRUCT) TESTS ============ + + [Test] + public async Task Member_NullableInt_WithValue_Success() + { + var model = new ModelWithAllTypes { NullableCount = 10 }; + await Assert.That(model).Member(x => x.NullableCount, count => count.IsEqualTo(10)); + } + + [Test] + public async Task Member_NullableInt_WithNull_IsNull_Success() + { + var model = new ModelWithAllTypes { NullableCount = null }; + await Assert.That(model).Member(x => x.NullableCount, count => count.IsNull()); + } + + [Test] + public async Task Member_NullableInt_WithValue_IsNotNull_Success() + { + var model = new ModelWithAllTypes { NullableCount = 5 }; + await Assert.That(model).Member(x => x.NullableCount, count => count.IsNotNull()); + } + + [Test] + public async Task Member_NullableBool_WithValue_Success() + { + var model = new ModelWithAllTypes { NullableFlag = true }; + await Assert.That(model).Member(x => x.NullableFlag, flag => flag.IsEqualTo(true)); + } + + [Test] + public async Task Member_NullableBool_WithNull_IsNull_Success() + { + var model = new ModelWithAllTypes { NullableFlag = null }; + await Assert.That(model).Member(x => x.NullableFlag, flag => flag.IsNull()); + } + + [Test] + public async Task Member_NullableDateTime_WithValue_Success() + { + var date = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var model = new ModelWithAllTypes { NullableDate = date }; + await Assert.That(model).Member(x => x.NullableDate, d => d.IsEqualTo(date)); + } + + [Test] + public async Task Member_NullableDateTime_WithNull_IsNull_Success() + { + var model = new ModelWithAllTypes { NullableDate = null }; + await Assert.That(model).Member(x => x.NullableDate, d => d.IsNull()); + } + + [Test] + public async Task Member_NullableCustomStruct_WithValue_Success() + { + var model = new ModelWithAllTypes { NullableStructValue = new MyStruct { X = 3, Y = 4 } }; + await Assert.That(model).Member(x => x.NullableStructValue, s => s.IsNotNull()); + } + + [Test] + public async Task Member_NullableCustomStruct_WithNull_IsNull_Success() + { + var model = new ModelWithAllTypes { NullableStructValue = null }; + await Assert.That(model).Member(x => x.NullableStructValue, s => s.IsNull()); + } + + [Test] + public async Task Member_NullableEnum_WithValue_Success() + { + var model = new ModelWithAllTypes { NullableEnumValue = MyEnum.Second }; + await Assert.That(model).Member(x => x.NullableEnumValue, e => e.IsEqualTo(MyEnum.Second)); + } + + [Test] + public async Task Member_NullableEnum_WithNull_IsNull_Success() + { + var model = new ModelWithAllTypes { NullableEnumValue = null }; + await Assert.That(model).Member(x => x.NullableEnumValue, e => e.IsNull()); + } + + // ============ CHAINING TESTS (mixed types) ============ + + [Test] + public async Task Member_Chained_MixedNullableAndNonNullable() + { + var model = new ModelWithAllTypes + { + Name = "Test", + NullableName = "Schema", + Count = 5, + NullableCount = 10 + }; + + await Assert.That(model) + .Member(x => x.Name, name => name.IsEqualTo("Test")) + .And.Member(x => x.NullableName, name => name.IsEqualTo("Schema")) + .And.Member(x => x.Count, count => count.IsEqualTo(5)) + .And.Member(x => x.NullableCount, count => count.IsEqualTo(10)); + } + + [Test] + public async Task Member_Chained_NullableRefAndStruct() + { + var model = new ModelWithAllTypes + { + NullableName = null, + NullableCount = null + }; + + await Assert.That(model) + .Member(x => x.NullableName, name => name.IsNull()) + .And.Member(x => x.NullableCount, count => count.IsNull()); + } + + // ============ HasProperty TESTS ============ + + [Test] + public async Task HasProperty_NonNullableString_Success() + { + var model = new ModelWithAllTypes { Name = "hello" }; + await Assert.That(model).HasProperty(x => x.Name, "hello"); + } + + [Test] + public async Task HasProperty_NullableString_WithValue_Success() + { + var model = new ModelWithAllTypes { NullableName = "dbo" }; + await Assert.That(model).HasProperty(x => x.NullableName, "dbo"); + } + + [Test] + public async Task HasProperty_NonNullableInt_Success() + { + var model = new ModelWithAllTypes { Count = 42 }; + await Assert.That(model).HasProperty(x => x.Count, 42); + } + + [Test] + public async Task HasProperty_NullableInt_WithValue_Success() + { + var model = new ModelWithAllTypes { NullableCount = 7 }; + await Assert.That(model).HasProperty(x => x.NullableCount, 7); + } + + [Test] + public async Task HasProperty_NonNullableBool_Success() + { + var model = new ModelWithAllTypes { IsActive = true }; + await Assert.That(model).HasProperty(x => x.IsActive, true); + } + + [Test] + public async Task HasProperty_NonNullableEnum_Success() + { + var model = new ModelWithAllTypes { EnumValue = MyEnum.First }; + await Assert.That(model).HasProperty(x => x.EnumValue, MyEnum.First); + } + + [Test] + public async Task HasProperty_Fluent_NullableString_IsNull() + { + var model = new ModelWithAllTypes { NullableName = null }; + await Assert.That(model).HasProperty(x => x.NullableName).IsNull(); + } + + [Test] + public async Task HasProperty_Fluent_NullableString_IsNotNull() + { + var model = new ModelWithAllTypes { NullableName = "value" }; + await Assert.That(model).HasProperty(x => x.NullableName).IsNotNull(); + } + + [Test] + public async Task HasProperty_Fluent_NullableInt_IsNull() + { + var model = new ModelWithAllTypes { NullableCount = null }; + await Assert.That(model).HasProperty(x => x.NullableCount).IsNull(); + } + + [Test] + public async Task HasProperty_Fluent_NullableInt_IsNotNull() + { + var model = new ModelWithAllTypes { NullableCount = 42 }; + await Assert.That(model).HasProperty(x => x.NullableCount).IsNotNull(); + } + + [Test] + public async Task HasProperty_Chained_MixedTypes() + { + var model = new ModelWithAllTypes + { + Name = "Test", + Count = 5, + IsActive = true, + EnumValue = MyEnum.Second + }; + + await Assert.That(model) + .HasProperty(x => x.Name, "Test") + .And.HasProperty(x => x.Count, 5) + .And.HasProperty(x => x.IsActive, true) + .And.HasProperty(x => x.EnumValue, MyEnum.Second); + } +} diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index 6163550a53..97b713d664 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -481,7 +481,7 @@ public static MemberAssertionResult Member( [OverloadResolutionPriority(1)] public static MemberAssertionResult Member( this IAssertionSource source, - Expression> memberSelector, + Expression> memberSelector, Func, Assertion> assertions) { var parentContext = source.Context; @@ -532,7 +532,7 @@ public static MemberAssertionResult Member public static MemberAssertionResult Member( this IAssertionSource source, - Expression> memberSelector, + Expression> memberSelector, Func, Assertion> assertions) { var parentContext = source.Context; @@ -585,7 +585,7 @@ public static MemberAssertionResult Member( [RequiresDynamicCode("Uses reflection for legacy compatibility. For AOT compatibility, use the Member overload with strongly-typed assertions.")] public static MemberAssertionResult Member( this IAssertionSource source, - Expression> memberSelector, + Expression> memberSelector, Func, object> assertions) { var parentContext = source.Context; diff --git a/TUnit.Assertions/Extensions/PropertyAssertionExtensions.cs b/TUnit.Assertions/Extensions/PropertyAssertionExtensions.cs index 5dc760851a..738c49f5eb 100644 --- a/TUnit.Assertions/Extensions/PropertyAssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/PropertyAssertionExtensions.cs @@ -20,7 +20,7 @@ public static class PropertyAssertionExtensions /// public static MemberAssertionResult HasProperty( this IAssertionSource source, - Expression> propertySelector, + Expression> propertySelector, TProperty expectedValue, [CallerArgumentExpression(nameof(expectedValue))] string? expression = null) { @@ -39,7 +39,7 @@ public static MemberAssertionResult HasProperty( /// public static PropertyAssertion HasProperty( this IAssertionSource source, - Expression> propertySelector) + Expression> propertySelector) { var parentContext = source.Context; var memberPath = GetMemberPath(propertySelector); diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index f146dc72fa..2504fe78e4 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -2578,10 +2578,10 @@ namespace .Extensions "Object, TItem, TTransformed> overload with strongly-typed assertions.")] [.(1)] public static . Member(this . source, .<>> memberSelector, <.<., TItem>, object> assertions) { } - public static . Member(this . source, .<> memberSelector, <., .> assertions) { } + public static . Member(this . source, .<> memberSelector, <., .> assertions) { } [.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member overload with strongly-typed assertions.")] - public static . Member(this . source, .<> memberSelector, <., object> assertions) { } + public static . Member(this . source, .<> memberSelector, <., object> assertions) { } [.(2)] public static . Member(this . source, .<>> memberSelector, <.<., TItem>, .> assertions) { } [.(2)] @@ -2593,7 +2593,7 @@ namespace .Extensions public static . Member(this . source, .<>> memberSelector, <.<., TKey, TValue>, object> assertions) where TKey : notnull { } [.(1)] - public static . Member(this . source, .<> memberSelector, <., .> assertions) { } + public static . Member(this . source, .<> memberSelector, <., .> assertions) { } [.(3)] public static . Member(this . source, .<>> memberSelector, <.<., TKey, TValue>, .> assertions) where TKey : notnull { } @@ -4519,8 +4519,8 @@ namespace .Extensions } public static class PropertyAssertionExtensions { - public static . HasProperty(this . source, .<> propertySelector) { } - public static . HasProperty(this . source, .<> propertySelector, TProperty expectedValue, [.("expectedValue")] string? expression = null) { } + public static . HasProperty(this . source, .<> propertySelector) { } + public static . HasProperty(this . source, .<> propertySelector, TProperty expectedValue, [.("expectedValue")] string? expression = null) { } } public static class RangeAssertionExtensions { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index bec90a1f07..aabee6e0f3 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -2555,10 +2555,10 @@ namespace .Extensions [.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member overload with strongly-typed assertions.")] public static . Member(this . source, .<>> memberSelector, <.<., TItem>, object> assertions) { } - public static . Member(this . source, .<> memberSelector, <., .> assertions) { } + public static . Member(this . source, .<> memberSelector, <., .> assertions) { } [.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member overload with strongly-typed assertions.")] - public static . Member(this . source, .<> memberSelector, <., object> assertions) { } + public static . Member(this . source, .<> memberSelector, <., object> assertions) { } public static . Member(this . source, .<>> memberSelector, <.<., TItem>, .> assertions) { } public static . Member(this . source, .<>> memberSelector, <.<., TKey, TValue>, .<.>> assertions) where TKey : notnull { } @@ -2566,7 +2566,7 @@ namespace .Extensions "Object, TKey, TValue, TTransformed> overload with strongly-typed assertions.")] public static . Member(this . source, .<>> memberSelector, <.<., TKey, TValue>, object> assertions) where TKey : notnull { } - public static . Member(this . source, .<> memberSelector, <., .> assertions) { } + public static . Member(this . source, .<> memberSelector, <., .> assertions) { } public static . Member(this . source, .<>> memberSelector, <.<., TKey, TValue>, .> assertions) where TKey : notnull { } public static . Satisfies(this . source, predicate, [.("predicate")] string? expression = null) { } @@ -4469,8 +4469,8 @@ namespace .Extensions } public static class PropertyAssertionExtensions { - public static . HasProperty(this . source, .<> propertySelector) { } - public static . HasProperty(this . source, .<> propertySelector, TProperty expectedValue, [.("expectedValue")] string? expression = null) { } + public static . HasProperty(this . source, .<> propertySelector) { } + public static . HasProperty(this . source, .<> propertySelector, TProperty expectedValue, [.("expectedValue")] string? expression = null) { } } public static class RangeAssertionExtensions { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index e8dfe6eacd..e609932ca1 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -2578,10 +2578,10 @@ namespace .Extensions "Object, TItem, TTransformed> overload with strongly-typed assertions.")] [.(1)] public static . Member(this . source, .<>> memberSelector, <.<., TItem>, object> assertions) { } - public static . Member(this . source, .<> memberSelector, <., .> assertions) { } + public static . Member(this . source, .<> memberSelector, <., .> assertions) { } [.("Uses reflection for legacy compatibility. For AOT compatibility, use the Member overload with strongly-typed assertions.")] - public static . Member(this . source, .<> memberSelector, <., object> assertions) { } + public static . Member(this . source, .<> memberSelector, <., object> assertions) { } [.(2)] public static . Member(this . source, .<>> memberSelector, <.<., TItem>, .> assertions) { } [.(2)] @@ -2593,7 +2593,7 @@ namespace .Extensions public static . Member(this . source, .<>> memberSelector, <.<., TKey, TValue>, object> assertions) where TKey : notnull { } [.(1)] - public static . Member(this . source, .<> memberSelector, <., .> assertions) { } + public static . Member(this . source, .<> memberSelector, <., .> assertions) { } [.(3)] public static . Member(this . source, .<>> memberSelector, <.<., TKey, TValue>, .> assertions) where TKey : notnull { } @@ -4519,8 +4519,8 @@ namespace .Extensions } public static class PropertyAssertionExtensions { - public static . HasProperty(this . source, .<> propertySelector) { } - public static . HasProperty(this . source, .<> propertySelector, TProperty expectedValue, [.("expectedValue")] string? expression = null) { } + public static . HasProperty(this . source, .<> propertySelector) { } + public static . HasProperty(this . source, .<> propertySelector, TProperty expectedValue, [.("expectedValue")] string? expression = null) { } } public static class RangeAssertionExtensions {