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, .<