Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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
36 changes: 36 additions & 0 deletions TUnit.Assertions.Tests/Bugs/Issue5702Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace TUnit.Assertions.Tests.Bugs;

/// <summary>
/// Regression tests for GitHub issue #5702:
/// `.Member(x => x.StringProperty, p => p.IsEqualTo("..."))` incorrectly resolved the
/// collection `Member&lt;TObject, TItem&gt;` overload (TItem=char) instead of the general
/// `Member&lt;TObject, TMember&gt;` (TMember=string) overload because the collection
/// overload carries a higher `[OverloadResolutionPriority]`. The resulting
/// `IAssertionSource&lt;IEnumerable&lt;char&gt;&gt;` triggers TUnitAssertions0016 and
/// uses reference equality on strings, so non-interned but equal strings fail.
/// </summary>
public class Issue5702Tests
{
[Test]
public async Task Member_On_String_Property_Uses_String_Equality_Not_Reference()
{
var value = KeyValuePair.Create<string, object?>(NonInterned("error"), null);

await Assert.That(value).Member(kvp => kvp.Key, key => key.IsEqualTo(NonInterned("error")));
}

[Test]
public async Task Member_On_String_Property_With_Nested_Object()
{
var model = new Wrapper { Name = NonInterned("hello") };

await Assert.That(model).Member(m => m.Name, n => n.IsEqualTo(NonInterned("hello")));
}

private static string NonInterned(string s) => new(s.ToCharArray());

private sealed class Wrapper
{
public string Name { get; set; } = string.Empty;
}
}
40 changes: 40 additions & 0 deletions TUnit.Assertions/Extensions/AssertionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,46 @@ public static MemberAssertionResult<TObject> Member<TObject, TKey, TValue>(
return BuildMemberResult(parentContext, pendingAssertion, combinerType, erasedAssertion);
}

/// <summary>
/// Asserts on a string member of an object using a lambda selector and assertion lambda.
/// This overload takes priority over the collection overload so that `string` is treated as a
/// scalar value rather than an `IEnumerable&lt;char&gt;`.
/// Example: await Assert.That(obj).Member(x => x.Name, n => n.IsEqualTo("expected"));
/// </summary>
[OverloadResolutionPriority(3)]
public static MemberAssertionResult<TObject> Member<TObject, TTransformed>(
this IAssertionSource<TObject> source,
Expression<Func<TObject, string?>> memberSelector,
Func<IAssertionSource<string>, Assertion<TTransformed>> assertions)
=> Member<TObject, string, TTransformed>(source, memberSelector, assertions);

/// <summary>
/// Asserts on a string member of an object using a lambda selector and assertion lambda.
/// This overload takes priority over the collection overload so that `string` is treated as a
/// scalar value rather than an `IEnumerable&lt;char&gt;`.
/// </summary>
[OverloadResolutionPriority(3)]
public static MemberAssertionResult<TObject> Member<TObject>(
this IAssertionSource<TObject> source,
Expression<Func<TObject, string?>> memberSelector,
Func<IAssertionSource<string>, Assertion<string>> assertions)
=> Member<TObject, string>(source, memberSelector, assertions);

/// <summary>
/// Asserts on a string member of an object using a lambda selector and assertion lambda
/// that returns an untyped assertion (for extension methods returning non-<see cref="Assertion{T}"/> types).
/// This overload takes priority over the collection overload so that `string` is treated as a
/// scalar value rather than an `IEnumerable&lt;char&gt;`.
/// Note: For AOT compatibility, use the TTransformed overload instead.
/// </summary>
[OverloadResolutionPriority(3)]
[RequiresDynamicCode("Uses reflection for legacy compatibility. For AOT compatibility, use the Member<TObject, TTransformed> overload with strongly-typed assertions.")]
public static MemberAssertionResult<TObject> Member<TObject>(
this IAssertionSource<TObject> source,
Expression<Func<TObject, string?>> memberSelector,
Func<IAssertionSource<string>, object> assertions)
=> Member<TObject, string>(source, memberSelector, assertions);

/// <summary>
/// Asserts on a collection member of an object using a lambda selector and assertion lambda.
/// The assertion lambda receives collection assertion methods (Count, Contains, IsEmpty, etc.).
Expand Down
Loading